grub
This commit is contained in:
commit
70acdb39b4
14 changed files with 3947 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
/node_modules
|
1549
Cargo.lock
generated
Normal file
1549
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "grub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.72"
|
||||
automerge = "0.5.1"
|
||||
axum = { version = "0.6.20", features = ["http2", "macros", "ws"] }
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
ciborium = "0.2.1"
|
||||
dashmap = "5.5.0"
|
||||
derivative = "2.2.0"
|
||||
flume = "0.10.14"
|
||||
futures = "0.3.28"
|
||||
serde = { version = "1.0.183", features = ["derive"] }
|
||||
serde_json = "1.0.104"
|
||||
tokio = { version = "1.30.0", features = ["full"] }
|
||||
uuid = "1.4.1"
|
||||
|
||||
[dependencies.mzlib]
|
||||
git = "https://git.mzhang.io/michael/mzlib"
|
||||
features = ["axum"]
|
17
client/App.tsx
Normal file
17
client/App.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { v4 } from "uuid";
|
||||
import Room from "./Room";
|
||||
|
||||
export default function App() {
|
||||
const x = new URL(window.location.href);
|
||||
const roomId = x.pathname.replace(/^\//, "");
|
||||
|
||||
if (!roomId) {
|
||||
location.href = `/${v4()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Hellosu, <Room roomId={roomId} />
|
||||
</>
|
||||
);
|
||||
}
|
200
client/Room.tsx
Normal file
200
client/Room.tsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
import CBOR from "cbor";
|
||||
import { useEffect, useState } from "react";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import * as Automerge from "@automerge/automerge";
|
||||
import * as uuid from "uuid";
|
||||
|
||||
const connectionStatusMap = {
|
||||
[ReadyState.CONNECTING]: "Connecting",
|
||||
[ReadyState.OPEN]: "Open",
|
||||
[ReadyState.CLOSING]: "Closing",
|
||||
[ReadyState.CLOSED]: "Closed",
|
||||
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
|
||||
};
|
||||
|
||||
export default function Room({ roomId }) {
|
||||
const [readyState, setReadyState] = useState(ReadyState.CLOSED);
|
||||
const [connectedClients, setConnectedClients] = useState([]);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
const [chats, setChats] = useState([]);
|
||||
|
||||
const [doc, setDoc] = useState(Automerge.init());
|
||||
const [syncState, setSyncState] = useState(Automerge.initSyncState());
|
||||
function updateDoc(newDoc) {
|
||||
setDoc(newDoc);
|
||||
}
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
lastJsonMessage,
|
||||
readyState: newReadyState,
|
||||
} = useWebSocket("ws://localhost:3100/ws", {
|
||||
onOpen: ({}) => {
|
||||
console.log("Shiet, connected.");
|
||||
},
|
||||
onMessage: async (event) => {
|
||||
const data = CBOR.decode(await event.data.arrayBuffer());
|
||||
console.log("received", data);
|
||||
|
||||
if (data.type === "ServerHello") {
|
||||
setClientId(uuid.stringify(data.client_id));
|
||||
}
|
||||
|
||||
if (data.type === "RoomClientList") {
|
||||
setConnectedClients(data.clients.map((x) => uuid.stringify(x)));
|
||||
}
|
||||
|
||||
if (data.type === "ChatMessage") {
|
||||
setChats([...chats, data]);
|
||||
}
|
||||
|
||||
if (data.type === "Automerge") {
|
||||
const [nextDoc, nextSyncState, patch] = Automerge.receiveSyncMessage(
|
||||
doc,
|
||||
syncState,
|
||||
data.message[1]
|
||||
);
|
||||
setDoc(nextDoc);
|
||||
setSyncState(nextSyncState);
|
||||
console.log("patch", patch);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function sendWtfMessage(data) {
|
||||
let cbor = CBOR.encode(data);
|
||||
console.log(
|
||||
"cbor-encoded",
|
||||
[...new Uint8Array(cbor)]
|
||||
.map((x) => x.toString(16).padStart(2, "0"))
|
||||
.join(" ")
|
||||
);
|
||||
sendMessage(cbor);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log("hellosu", readyState, newReadyState);
|
||||
if (
|
||||
readyState === ReadyState.CONNECTING &&
|
||||
newReadyState === ReadyState.OPEN
|
||||
) {
|
||||
// On Open
|
||||
sendWtfMessage({ type: "JoinRoom", room_id: roomId });
|
||||
console.log("Sent connection message");
|
||||
}
|
||||
setReadyState(newReadyState);
|
||||
}, [newReadyState]);
|
||||
|
||||
const connectionStatus = connectionStatusMap[readyState];
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Room Id: {roomId}</p>
|
||||
<p>Connection status: {connectionStatus}</p>
|
||||
{newReadyState === ReadyState.OPEN && (
|
||||
<ReadyPart
|
||||
doc={doc}
|
||||
updateDoc={updateDoc}
|
||||
syncState={syncState}
|
||||
chats={chats}
|
||||
roomId={roomId}
|
||||
clientId={clientId}
|
||||
connectedClients={connectedClients}
|
||||
sendWtfMessage={sendWtfMessage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadyPart({
|
||||
doc,
|
||||
syncState,
|
||||
roomId,
|
||||
clientId,
|
||||
chats,
|
||||
connectedClients,
|
||||
sendWtfMessage,
|
||||
updateDoc,
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [addItemName, setAddItemName] = useState("");
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
sendWtfMessage({
|
||||
type: "ChatMessage",
|
||||
timestamp: new Date().toISOString(),
|
||||
message_id: uuid.v4(),
|
||||
room_id: roomId,
|
||||
author: clientId,
|
||||
content: message,
|
||||
});
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
const items = doc.items || [];
|
||||
|
||||
function addItem(e) {
|
||||
e.preventDefault();
|
||||
const newDoc = Automerge.change(doc, (doc) => {
|
||||
if (!doc.items) doc.items = [];
|
||||
doc.items.push({
|
||||
id: uuid.v4(),
|
||||
content: addItemName,
|
||||
});
|
||||
});
|
||||
updateDoc(newDoc);
|
||||
const [syncMessage, binary] = Automerge.generateSyncMessage(doc, syncState);
|
||||
if (syncMessage)
|
||||
sendWtfMessage({
|
||||
type: "Automerge",
|
||||
client_id: clientId,
|
||||
room_id: roomId,
|
||||
message: binary,
|
||||
});
|
||||
setAddItemName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Connected:
|
||||
<ul>
|
||||
{connectedClients.map((x) => (
|
||||
<li key={x}>{x}</li>
|
||||
))}
|
||||
</ul>
|
||||
Messages:
|
||||
<ul>
|
||||
{chats.map((x) => (
|
||||
<li key={x.message_id}>
|
||||
[{x.timestamp}] {uuid.stringify(x.author)}: {x.content}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={onSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
</form>
|
||||
Grubs:
|
||||
<ul>
|
||||
{items.map((x) => (
|
||||
<li key={x.id}>{x.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={addItem}>
|
||||
<input
|
||||
type="text"
|
||||
value={addItemName}
|
||||
onChange={(e) => setAddItemName(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<button type="submit">add item</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
6
client/index.ts
Normal file
6
client/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const el = document.getElementById("app")!;
|
||||
const root = createRoot(el);
|
||||
root.render(App());
|
7
index.html
Normal file
7
index.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="client/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
1739
package-lock.json
generated
Normal file
1739
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"scripts": {
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@automerge/automerge": "^2.0.3",
|
||||
"cbor": "npm:@jprochazk/cbor@^0.5.0",
|
||||
"localforage": "^1.10.0",
|
||||
"match-sorter": "^6.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-use-websocket": "^4.3.1",
|
||||
"sort-by": "^1.2.0",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
4
rustfmt.toml
Normal file
4
rustfmt.toml
Normal file
|
@ -0,0 +1,4 @@
|
|||
max_width = 80
|
||||
tab_spaces = 2
|
||||
wrap_comments = true
|
||||
fn_single_line = true
|
271
src/main.rs
Normal file
271
src/main.rs
Normal file
|
@ -0,0 +1,271 @@
|
|||
pub mod message;
|
||||
|
||||
#[macro_use]
|
||||
extern crate derivative;
|
||||
|
||||
use std::{io::Cursor, mem, sync::Arc};
|
||||
|
||||
use automerge::{
|
||||
sync::{State as SyncState, SyncDoc},
|
||||
Automerge, Value,
|
||||
};
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use flume::r#async::SendSink;
|
||||
use futures::{stream, FutureExt, SinkExt, StreamExt};
|
||||
use message::Message;
|
||||
use mzlib::axum_error::Result;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct AppState {
|
||||
clients: Clients,
|
||||
rooms: Rooms,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Clients(Arc<DashMap<Uuid, Client>>);
|
||||
|
||||
struct Client {
|
||||
writer: SendSink<'static, Result<axum::extract::ws::Message, axum::Error>>,
|
||||
rooms: DashSet<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Rooms(Arc<DashMap<Uuid, Room>>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Room {
|
||||
document: Automerge,
|
||||
sync_state: SyncState,
|
||||
connected_clients: DashSet<Uuid>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn split_borrow(&mut self) -> (&mut Automerge, &mut SyncState) {
|
||||
(&mut self.document, &mut self.sync_state)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let state = AppState::default();
|
||||
|
||||
let app = Router::new().route("/ws", get(handler)).with_state(state);
|
||||
|
||||
axum::Server::bind(&"0.0.0.0:3100".parse().unwrap())
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(|socket| handle_socket(state, socket).map(|res| res.unwrap()))
|
||||
}
|
||||
|
||||
async fn handle_socket(state: AppState, socket: WebSocket) -> Result<()> {
|
||||
let (mut socket_tx, socket_rx) = socket.split();
|
||||
|
||||
// Generate an ID for this connection
|
||||
let client_id = Uuid::new_v4();
|
||||
println!("Connected client {client_id:?}");
|
||||
|
||||
// First, let's create multiplexed versions of these
|
||||
let mut socket_tx = {
|
||||
let (tx, rx) = flume::unbounded();
|
||||
tokio::spawn(rx.into_stream().forward(socket_tx));
|
||||
tx.into_sink()
|
||||
};
|
||||
let mut socket_rx = {
|
||||
let (tx, rx) = flume::unbounded();
|
||||
tokio::spawn(
|
||||
socket_rx
|
||||
.forward(tx.into_sink().sink_map_err(|err| axum::Error::new(err))),
|
||||
);
|
||||
rx.into_stream()
|
||||
};
|
||||
|
||||
// Now register this client into the set of clients
|
||||
let rooms = DashSet::default();
|
||||
let client = Client {
|
||||
writer: socket_tx.clone(),
|
||||
rooms: rooms.clone(),
|
||||
};
|
||||
state.clients.0.insert(client_id, client);
|
||||
|
||||
// Send a hello message
|
||||
socket_tx
|
||||
.send(Ok(
|
||||
Message::ServerHello {
|
||||
timestamp: Utc::now(),
|
||||
client_id: client_id.clone(),
|
||||
}
|
||||
.into_ws_message()?,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Wait for messages
|
||||
while let Some(msg) = socket_rx.next().await {
|
||||
match msg {
|
||||
WsMessage::Binary(ref bin) => {
|
||||
let cursor = Cursor::new(bin);
|
||||
let message = ciborium::from_reader::<Message, _>(cursor)?;
|
||||
println!("received message: {message:?}");
|
||||
|
||||
match &message {
|
||||
Message::Automerge {
|
||||
client_id,
|
||||
room_id,
|
||||
message: inner_message,
|
||||
} => {
|
||||
let mut room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
rooms.insert(room_id.clone());
|
||||
|
||||
// Update local state
|
||||
{
|
||||
let (document, sync_state) = room.split_borrow();
|
||||
document
|
||||
.receive_sync_message(sync_state, inner_message.clone().0)?;
|
||||
}
|
||||
println!("inner doc: {:?}", room.document);
|
||||
|
||||
// Remove current client, so send to everyone else
|
||||
let mut connected_clients = room.connected_clients.clone();
|
||||
connected_clients.remove(client_id);
|
||||
|
||||
// Send to everyone else
|
||||
let message = message.clone();
|
||||
broadcast(&state, connected_clients, message).await;
|
||||
}
|
||||
|
||||
Message::JoinRoom { room_id } => {
|
||||
let room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
rooms.insert(room_id.clone());
|
||||
|
||||
// Add to the list of connected clients
|
||||
room.connected_clients.insert(client_id);
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
mem::drop(room);
|
||||
println!("Added to room");
|
||||
|
||||
// Tell each clients which clients are connected
|
||||
broadcast(
|
||||
&state,
|
||||
connected_clients.clone(),
|
||||
Message::RoomClientList {
|
||||
room_id: room_id.clone(),
|
||||
clients: connected_clients.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Message::ChatMessage {
|
||||
timestamp,
|
||||
message_id,
|
||||
room_id,
|
||||
author,
|
||||
content,
|
||||
} => {
|
||||
let room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
rooms.insert(room_id.clone());
|
||||
|
||||
// Add to the list of connected clients
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
mem::drop(room);
|
||||
println!("Added to room");
|
||||
|
||||
// Broadcast to the room
|
||||
broadcast(&state, connected_clients.clone(), message).await;
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
state.clients.0.remove(&client_id);
|
||||
println!("Disconnecting client {client_id:?}");
|
||||
|
||||
// Tell other clients about the disconnection
|
||||
stream::iter(rooms)
|
||||
.for_each(|room_id| {
|
||||
let room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
|
||||
room.connected_clients.remove(&client_id);
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
mem::drop(room);
|
||||
|
||||
let room_id = room_id.clone();
|
||||
{
|
||||
let state_ref = &state;
|
||||
async move {
|
||||
broadcast(
|
||||
state_ref,
|
||||
connected_clients.clone(),
|
||||
Message::RoomClientList {
|
||||
room_id: room_id,
|
||||
clients: connected_clients.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast<I>(state: &AppState, client_ids: I, message: Message)
|
||||
where
|
||||
I: IntoIterator<Item = Uuid>,
|
||||
{
|
||||
let client_ids = client_ids.into_iter().collect::<Vec<_>>();
|
||||
println!("Broadcasting message to {} clients:", client_ids.len());
|
||||
println!(" - message: {message:?}");
|
||||
|
||||
stream::iter(
|
||||
client_ids
|
||||
.into_iter()
|
||||
.filter_map(|client_id| state.clients.0.get(&client_id))
|
||||
.map(|client| client.writer.clone()),
|
||||
)
|
||||
.for_each_concurrent(None, move |mut writer| {
|
||||
let message = message.clone();
|
||||
async move {
|
||||
writer.send(Ok(message.into_ws_message().unwrap())).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
91
src/message.rs
Normal file
91
src/message.rs
Normal file
|
@ -0,0 +1,91 @@
|
|||
use std::collections::HashSet;
|
||||
use std::fmt::{write, Formatter};
|
||||
use std::io::Cursor;
|
||||
|
||||
use axum::extract::ws;
|
||||
use chrono::{DateTime, Utc};
|
||||
use ciborium::Value;
|
||||
use mzlib::axum_error::Result;
|
||||
use serde::de::Visitor;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Derivative, Clone, Serialize, Deserialize)]
|
||||
#[derivative(Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Message {
|
||||
ServerHello {
|
||||
timestamp: DateTime<Utc>,
|
||||
client_id: Uuid,
|
||||
},
|
||||
JoinRoom {
|
||||
room_id: Uuid,
|
||||
},
|
||||
RoomClientList {
|
||||
room_id: Uuid,
|
||||
clients: HashSet<Uuid>,
|
||||
},
|
||||
ChatMessage {
|
||||
timestamp: DateTime<Utc>,
|
||||
message_id: Uuid,
|
||||
room_id: Uuid,
|
||||
author: Uuid,
|
||||
content: String,
|
||||
},
|
||||
Automerge {
|
||||
client_id: Uuid,
|
||||
room_id: Uuid,
|
||||
#[derivative(Debug = "ignore")]
|
||||
message: AutomergeMessage,
|
||||
},
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn into_ws_message(self) -> Result<ws::Message> {
|
||||
let vec = Vec::new();
|
||||
let mut cursor = Cursor::new(vec);
|
||||
ciborium::into_writer(&self, &mut cursor)?;
|
||||
let vec = cursor.into_inner();
|
||||
Ok(ws::Message::Binary(vec))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AutomergeMessage(pub automerge::sync::Message);
|
||||
|
||||
impl Serialize for AutomergeMessage {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let bytes = self.0.clone().encode();
|
||||
serializer.serialize_bytes(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AutomergeMessage {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct ThisVisitor;
|
||||
impl<'v> Visitor<'v> for ThisVisitor {
|
||||
type Value = AutomergeMessage;
|
||||
|
||||
fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
|
||||
write!(f, "an automerge sync message")
|
||||
}
|
||||
|
||||
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
automerge::sync::Message::decode(v)
|
||||
.map(AutomergeMessage)
|
||||
.map_err(|_err| E::custom("invalid automerge sync message"))
|
||||
}
|
||||
}
|
||||
let visitor = ThisVisitor;
|
||||
deserializer.deserialize_bytes(visitor)
|
||||
}
|
||||
}
|
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"compilerOptions": { "lib": ["ESNext", "DOM"], "jsx": "react-jsx" }
|
||||
}
|
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), wasm(), topLevelAwait()],
|
||||
});
|
Loading…
Reference in a new issue