Moved to ReactJS
This commit is contained in:
parent
c8ef2b248f
commit
1d0ebeb2b7
100 changed files with 1909 additions and 6056 deletions
|
|
@ -16,6 +16,7 @@
|
|||
"@noble/curves": "^2.0.1",
|
||||
"@tensamin/ui": "workspace:*",
|
||||
"comlink": "^4.4.2",
|
||||
"solid-js": "^1.9.11"
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,41 @@
|
|||
import {
|
||||
createContext,
|
||||
onMount,
|
||||
onCleanup,
|
||||
createSignal,
|
||||
Show,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import type { ParentProps } from "solid-js";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Comlink from "comlink";
|
||||
import Loading from "@tensamin/ui/screens/loading";
|
||||
|
||||
export const context = createContext<contextType>();
|
||||
export const context = React.createContext<contextType | undefined>(undefined);
|
||||
|
||||
export default function Provider(props: ParentProps) {
|
||||
let apiRef: ApiRef | null = null;
|
||||
const [isWorkerReady, setIsWorkerReady] = createSignal(false);
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
const apiRef = React.useRef<ApiRef | null>(null);
|
||||
const [isWorkerReady, setIsWorkerReady] = React.useState(false);
|
||||
|
||||
const { encrypt, decrypt, get_shared_secret } = createCryptoActions(
|
||||
() => apiRef,
|
||||
const { encrypt, decrypt, get_shared_secret } = React.useMemo(
|
||||
() => createCryptoActions(() => apiRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
React.useEffect(() => {
|
||||
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
apiRef = Comlink.wrap(worker);
|
||||
|
||||
apiRef.current = Comlink.wrap(worker);
|
||||
setIsWorkerReady(true);
|
||||
|
||||
onCleanup(() => {
|
||||
apiRef = null;
|
||||
return () => {
|
||||
apiRef.current = null;
|
||||
worker.terminate();
|
||||
});
|
||||
});
|
||||
setIsWorkerReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isWorkerReady) {
|
||||
return <Loading progress={10} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={isWorkerReady()} fallback={<Loading progress={10} />}>
|
||||
<context.Provider value={{ encrypt, decrypt, get_shared_secret }}>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
</Show>
|
||||
<context.Provider value={{ encrypt, decrypt, get_shared_secret }}>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +64,7 @@ export function createCryptoActions(
|
|||
): contextType {
|
||||
const encrypt = async (secret: string, message: string): Promise<string> => {
|
||||
const apiRef = getApiRef();
|
||||
if (!apiRef) throw "API not initialized";
|
||||
if (!apiRef) throw new Error("API not initialized");
|
||||
return await apiRef.encrypt(secret, message);
|
||||
};
|
||||
|
||||
|
|
@ -77,7 +73,7 @@ export function createCryptoActions(
|
|||
encryptedMessage: string,
|
||||
): Promise<string> => {
|
||||
const apiRef = getApiRef();
|
||||
if (!apiRef) throw "API not initialized";
|
||||
if (!apiRef) throw new Error("API not initialized");
|
||||
return await apiRef.decrypt(secret, encryptedMessage);
|
||||
};
|
||||
|
||||
|
|
@ -87,7 +83,7 @@ export function createCryptoActions(
|
|||
other_public_key: string,
|
||||
): Promise<string> => {
|
||||
const apiRef = getApiRef();
|
||||
if (!apiRef) throw "API not initialized";
|
||||
if (!apiRef) throw new Error("API not initialized");
|
||||
return await apiRef.get_shared_secret(
|
||||
own_private_key,
|
||||
own_public_key,
|
||||
|
|
@ -99,9 +95,9 @@ export function createCryptoActions(
|
|||
}
|
||||
|
||||
export function useCrypto(): contextType {
|
||||
const ctx = useContext(context);
|
||||
const ctx = React.useContext(context);
|
||||
if (!ctx) {
|
||||
throw new Error("useCrypto must be used within a CryptoProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@tensamin/ui": "workspace:*",
|
||||
"solid-js": "^1.9.11"
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,9 @@ import {
|
|||
indentWithTab,
|
||||
} from "@codemirror/commands";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Setter,
|
||||
} from "solid-js";
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
||||
|
||||
|
|
@ -34,7 +31,7 @@ export type InputProps = {
|
|||
ref?: HTMLDivElement;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
setValue: Setter<string>;
|
||||
setValue: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
invertEnterBehavior?: boolean;
|
||||
};
|
||||
|
|
@ -79,21 +76,18 @@ const markdownDecorations = ViewPlugin.fromClass(
|
|||
export default function Input(props: InputProps) {
|
||||
ensureMarkdownStyles();
|
||||
|
||||
// eslint-disable-next-line no-unassigned-vars
|
||||
let element: HTMLDivElement | undefined;
|
||||
let view: EditorView | undefined;
|
||||
let ignoreSync = false;
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||
const ignoreSyncRef = useRef(false);
|
||||
|
||||
const externalValue = createMemo(() => props.value);
|
||||
|
||||
onMount(() => {
|
||||
if (!element) return;
|
||||
useEffect(() => {
|
||||
if (!elementRef.current) return;
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: props.value,
|
||||
extensions: createEditorExtensions(
|
||||
(value) => {
|
||||
ignoreSync = true;
|
||||
ignoreSyncRef.current = true;
|
||||
props.setValue(value);
|
||||
},
|
||||
() => props.placeholder,
|
||||
|
|
@ -102,21 +96,28 @@ export default function Input(props: InputProps) {
|
|||
),
|
||||
});
|
||||
|
||||
view = new EditorView({
|
||||
viewRef.current = new EditorView({
|
||||
state,
|
||||
parent: element,
|
||||
parent: elementRef.current,
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const editor = view;
|
||||
return () => {
|
||||
viewRef.current?.destroy();
|
||||
viewRef.current = undefined;
|
||||
};
|
||||
// Run once to initialize/destroy the editor instance.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = viewRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const next = externalValue();
|
||||
const next = props.value;
|
||||
const current = editor.state.doc.toString();
|
||||
|
||||
if (ignoreSync) {
|
||||
ignoreSync = false;
|
||||
if (ignoreSyncRef.current) {
|
||||
ignoreSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -129,14 +130,9 @@ export default function Input(props: InputProps) {
|
|||
insert: next,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [props.value]);
|
||||
|
||||
onCleanup(() => {
|
||||
view?.destroy();
|
||||
view = undefined;
|
||||
});
|
||||
|
||||
return <div ref={element} class="tm-md-root" />;
|
||||
return <div ref={elementRef} className="tm-md-root" />;
|
||||
}
|
||||
|
||||
function createEditorExtensions(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { For, Index, type JSX } from "solid-js";
|
||||
import * as React from "react";
|
||||
|
||||
type InlineNode =
|
||||
| { type: "text"; value: string }
|
||||
|
|
@ -345,31 +345,53 @@ export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
|
|||
return blocks;
|
||||
}
|
||||
|
||||
export function renderInline(nodes: InlineNode[]): JSX.Element[] {
|
||||
return nodes.map((node) => {
|
||||
export function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
||||
return nodes.map((node, index) => {
|
||||
if (node.type === "text") {
|
||||
return node.value;
|
||||
}
|
||||
|
||||
if (node.type === "strong") {
|
||||
return <strong class="tm-md-strong">{node.value}</strong>;
|
||||
return (
|
||||
<strong key={index} className="tm-md-strong">
|
||||
{node.value}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "em") {
|
||||
return <em class="tm-md-em">{node.value}</em>;
|
||||
return (
|
||||
<em key={index} className="tm-md-em">
|
||||
{node.value}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "del") {
|
||||
return <del class="tm-md-del">{node.value}</del>;
|
||||
return (
|
||||
<del key={index} className="tm-md-del">
|
||||
{node.value}
|
||||
</del>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "code") {
|
||||
return <code class="tm-md-code">{node.value}</code>;
|
||||
return (
|
||||
<code key={index} className="tm-md-code">
|
||||
{node.value}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "link") {
|
||||
return (
|
||||
<a class="tm-md-link" href={node.href} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
key={index}
|
||||
className="tm-md-link"
|
||||
href={node.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{node.label}
|
||||
</a>
|
||||
);
|
||||
|
|
@ -377,7 +399,8 @@ export function renderInline(nodes: InlineNode[]): JSX.Element[] {
|
|||
|
||||
return (
|
||||
<img
|
||||
class="tm-md-image"
|
||||
key={index}
|
||||
className="tm-md-image"
|
||||
src={node.src}
|
||||
alt={node.alt}
|
||||
loading="lazy"
|
||||
|
|
@ -387,44 +410,44 @@ export function renderInline(nodes: InlineNode[]): JSX.Element[] {
|
|||
});
|
||||
}
|
||||
|
||||
export function renderBlocks(blocks: MarkdownBlock[]): JSX.Element {
|
||||
export function renderBlocks(blocks: MarkdownBlock[]): React.ReactElement {
|
||||
return (
|
||||
<For each={blocks}>
|
||||
{(block) => {
|
||||
<>
|
||||
{blocks.map((block, blockIndex) => {
|
||||
if (block.type === "heading") {
|
||||
const className = `tm-md-heading tm-md-h${String(block.level)}`;
|
||||
if (block.level === 1)
|
||||
return (
|
||||
<h1 class={className}>
|
||||
<h1 key={blockIndex} className={className}>
|
||||
{renderInline(parseInlineNodes(block.text))}
|
||||
</h1>
|
||||
);
|
||||
if (block.level === 2)
|
||||
return (
|
||||
<h2 class={className}>
|
||||
<h2 key={blockIndex} className={className}>
|
||||
{renderInline(parseInlineNodes(block.text))}
|
||||
</h2>
|
||||
);
|
||||
if (block.level === 3)
|
||||
return (
|
||||
<h3 class={className}>
|
||||
<h3 key={blockIndex} className={className}>
|
||||
{renderInline(parseInlineNodes(block.text))}
|
||||
</h3>
|
||||
);
|
||||
if (block.level === 4)
|
||||
return (
|
||||
<h4 class={className}>
|
||||
<h4 key={blockIndex} className={className}>
|
||||
{renderInline(parseInlineNodes(block.text))}
|
||||
</h4>
|
||||
);
|
||||
if (block.level === 5)
|
||||
return (
|
||||
<h5 class={className}>
|
||||
<h5 key={blockIndex} className={className}>
|
||||
{renderInline(parseInlineNodes(block.text))}
|
||||
</h5>
|
||||
);
|
||||
return (
|
||||
<h6 class={className}>
|
||||
<h6 key={blockIndex} className={className}>
|
||||
{renderInline(parseInlineNodes(block.text))}
|
||||
</h6>
|
||||
);
|
||||
|
|
@ -432,18 +455,18 @@ export function renderBlocks(blocks: MarkdownBlock[]): JSX.Element {
|
|||
|
||||
if (block.type === "blockquote") {
|
||||
return (
|
||||
<blockquote class="tm-md-blockquote">
|
||||
<For each={block.text.split("\n")}>
|
||||
{(line) => <p>{renderInline(parseInlineNodes(line))}</p>}
|
||||
</For>
|
||||
<blockquote key={blockIndex} className="tm-md-blockquote">
|
||||
{block.text.split("\n").map((line, lineIndex) => (
|
||||
<p key={lineIndex}>{renderInline(parseInlineNodes(line))}</p>
|
||||
))}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "code") {
|
||||
return (
|
||||
<pre class="tm-md-pre">
|
||||
<code class="tm-md-codeblock" data-language={block.language}>
|
||||
<pre key={blockIndex} className="tm-md-pre">
|
||||
<code className="tm-md-codeblock" data-language={block.language}>
|
||||
{block.code}
|
||||
</code>
|
||||
</pre>
|
||||
|
|
@ -453,51 +476,43 @@ export function renderBlocks(blocks: MarkdownBlock[]): JSX.Element {
|
|||
if (block.type === "list") {
|
||||
const Tag = block.ordered ? "ol" : "ul";
|
||||
return (
|
||||
<Tag class={block.ordered ? "tm-md-ol" : "tm-md-ul"}>
|
||||
<For each={block.items}>
|
||||
{(item) => (
|
||||
<li class="tm-md-li">
|
||||
<Tag key={blockIndex} className={block.ordered ? "tm-md-ol" : "tm-md-ul"}>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li key={itemIndex} className="tm-md-li">
|
||||
{item.checked !== null ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
disabled
|
||||
class="tm-md-checkbox"
|
||||
className="tm-md-checkbox"
|
||||
/>
|
||||
) : null}
|
||||
<span>{renderInline(parseInlineNodes(item.text))}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "table") {
|
||||
return (
|
||||
<div class="tm-md-table-wrap">
|
||||
<table class="tm-md-table">
|
||||
<div key={blockIndex} className="tm-md-table-wrap">
|
||||
<table className="tm-md-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<For each={block.headers}>
|
||||
{(header) => (
|
||||
<th>{renderInline(parseInlineNodes(header))}</th>
|
||||
)}
|
||||
</For>
|
||||
{block.headers.map((header, headerIndex) => (
|
||||
<th key={headerIndex}>{renderInline(parseInlineNodes(header))}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={block.rows}>
|
||||
{(row) => (
|
||||
<tr>
|
||||
<For each={row}>
|
||||
{(cell) => (
|
||||
<td>{renderInline(parseInlineNodes(cell))}</td>
|
||||
)}
|
||||
</For>
|
||||
{block.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex}>{renderInline(parseInlineNodes(cell))}</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -505,23 +520,21 @@ export function renderBlocks(blocks: MarkdownBlock[]): JSX.Element {
|
|||
}
|
||||
|
||||
if (block.type === "hr") {
|
||||
return <hr class="tm-md-hr" />;
|
||||
return <hr key={blockIndex} className="tm-md-hr" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<p class="tm-md-p">
|
||||
<Index each={block.text.split("\n")}>
|
||||
{(line, idx) => (
|
||||
<>
|
||||
{idx > 0 ? <br /> : null}
|
||||
{renderInline(parseInlineNodes(line()))}
|
||||
</>
|
||||
)}
|
||||
</Index>
|
||||
<p key={blockIndex} className="tm-md-p">
|
||||
{block.text.split("\n").map((line, lineIndex) => (
|
||||
<React.Fragment key={lineIndex}>
|
||||
{lineIndex > 0 ? <br /> : null}
|
||||
{renderInline(parseInlineNodes(line))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMemo } from "solid-js";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ensureMarkdownStyles,
|
||||
|
|
@ -13,7 +13,7 @@ export type TextProps = {
|
|||
export default function Text(props: TextProps) {
|
||||
ensureMarkdownStyles();
|
||||
|
||||
const blocks = createMemo(() => parseMarkdownBlocks(props.value));
|
||||
const blocks = React.useMemo(() => parseMarkdownBlocks(props.value), [props.value]);
|
||||
|
||||
return <div class="tm-md-root">{renderBlocks(blocks())}</div>;
|
||||
return <div className="tm-md-root">{renderBlocks(blocks)}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"dependencies": {
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/ui": "workspace:*",
|
||||
"solid-js": "^1.9.11"
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import {
|
||||
createContext,
|
||||
createSignal,
|
||||
Show,
|
||||
useContext,
|
||||
type ParentProps,
|
||||
} from "solid-js";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type Storage as StorageSchema,
|
||||
storageDefaults as defaults,
|
||||
} from "@tensamin/shared/data";
|
||||
import { getEntry, setEntry, deleteEntry } from "./indexed-db";
|
||||
import ErrorScreen from "@tensamin/ui/screens/error";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
interface StorageContextValue {
|
||||
|
|
@ -23,109 +16,121 @@ interface StorageContextValue {
|
|||
clear: () => Promise<void>;
|
||||
}
|
||||
|
||||
const StorageContext = createContext<StorageContextValue>();
|
||||
const StorageContext = React.createContext<StorageContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const isIndexedDBSupported = typeof indexedDB !== "undefined";
|
||||
|
||||
export default function StorageProvider(props: ParentProps) {
|
||||
const [storage, setStorage] = createStore<StorageSchema>(defaults);
|
||||
export default function StorageProvider(props: { children: React.ReactNode }) {
|
||||
const [storage, setStorage] = React.useState<StorageSchema>(defaults);
|
||||
const storageRef = React.useRef(storage);
|
||||
|
||||
const [error, setError] = createSignal<string>("");
|
||||
const [errorDescription, setErrorDescription] = createSignal<string>("");
|
||||
const [error, setError] = React.useState("");
|
||||
const [errorDescription, setErrorDescription] = React.useState("");
|
||||
|
||||
async function loadIO<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<StorageSchema[K]> {
|
||||
let stored;
|
||||
try {
|
||||
stored = await getEntry(key);
|
||||
} catch (err) {
|
||||
setError("Failed to load data");
|
||||
setErrorDescription(
|
||||
"An error occurred while loading data from IndexedDB. Please try again.",
|
||||
);
|
||||
log(0, "Storage", "red", err);
|
||||
}
|
||||
if (stored !== undefined) {
|
||||
setStorage(key, stored);
|
||||
return stored;
|
||||
}
|
||||
return defaults[key];
|
||||
}
|
||||
React.useEffect(() => {
|
||||
storageRef.current = storage;
|
||||
}, [storage]);
|
||||
|
||||
async function saveIO<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
value: StorageSchema[K],
|
||||
): Promise<void> {
|
||||
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
||||
await deleteEntry(key);
|
||||
setStorage(key, defaults[key]);
|
||||
} else {
|
||||
await setEntry(key, value);
|
||||
setStorage(key, value);
|
||||
}
|
||||
}
|
||||
const loadIO = React.useCallback(
|
||||
async <K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]> => {
|
||||
let stored: StorageSchema[K] | undefined;
|
||||
|
||||
const value: StorageContextValue = {
|
||||
async load<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
): Promise<StorageSchema[K]> {
|
||||
if (
|
||||
storage[key] === undefined ||
|
||||
JSON.stringify(storage[key]) === JSON.stringify(defaults[key])
|
||||
) {
|
||||
const value = await loadIO(key);
|
||||
setStorage(key, value);
|
||||
try {
|
||||
stored = await getEntry(key);
|
||||
} catch (err) {
|
||||
setError("Failed to load data");
|
||||
setErrorDescription(
|
||||
"An error occurred while loading data from IndexedDB. Please try again.",
|
||||
);
|
||||
log(0, "Storage", "red", err);
|
||||
}
|
||||
|
||||
return storage[key];
|
||||
},
|
||||
if (stored !== undefined) {
|
||||
setStorage((prev) => ({ ...prev, [key]: stored }));
|
||||
return stored;
|
||||
}
|
||||
|
||||
async save<K extends keyof StorageSchema>(
|
||||
return defaults[key];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const saveIO = React.useCallback(
|
||||
async <K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
value: StorageSchema[K],
|
||||
): Promise<void> {
|
||||
await saveIO(key, value);
|
||||
setStorage(key, value);
|
||||
},
|
||||
|
||||
async clear() {
|
||||
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
|
||||
await Promise.all(keys.map((key) => deleteEntry(key)));
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error development utility
|
||||
window.save = value.save;
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={error() !== "" && errorDescription() !== ""}
|
||||
fallback={
|
||||
<Show
|
||||
when={isIndexedDBSupported}
|
||||
fallback={
|
||||
<ErrorScreen
|
||||
error="Unsupported Browser"
|
||||
description="Your browser does not support IndexedDB, which is required for this application to function."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<StorageContext.Provider value={value}>
|
||||
{props.children}
|
||||
</StorageContext.Provider>
|
||||
</Show>
|
||||
): Promise<void> => {
|
||||
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
||||
await deleteEntry(key);
|
||||
setStorage((prev) => ({ ...prev, [key]: defaults[key] }));
|
||||
} else {
|
||||
await setEntry(key, value);
|
||||
setStorage((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
>
|
||||
<ErrorScreen error={error()} description={errorDescription()} />
|
||||
</Show>
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = React.useMemo<StorageContextValue>(
|
||||
() => ({
|
||||
async load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]> {
|
||||
const current = storageRef.current[key];
|
||||
|
||||
if (
|
||||
current === undefined ||
|
||||
JSON.stringify(current) === JSON.stringify(defaults[key])
|
||||
) {
|
||||
const loadedValue = await loadIO(key);
|
||||
setStorage((prev) => ({ ...prev, [key]: loadedValue }));
|
||||
return loadedValue;
|
||||
}
|
||||
|
||||
return current;
|
||||
},
|
||||
|
||||
async save<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
nextValue: StorageSchema[K],
|
||||
): Promise<void> {
|
||||
await saveIO(key, nextValue);
|
||||
},
|
||||
|
||||
async clear() {
|
||||
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
|
||||
await Promise.all(keys.map((key) => deleteEntry(key)));
|
||||
setStorage(defaults);
|
||||
},
|
||||
}),
|
||||
[loadIO, saveIO],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// @ts-expect-error development utility
|
||||
window.save = value.save;
|
||||
}, [value]);
|
||||
|
||||
if (error !== "" && errorDescription !== "") {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
||||
if (!isIndexedDBSupported) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
error="Unsupported Browser"
|
||||
description="Your browser does not support IndexedDB, which is required for this application to function."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <StorageContext.Provider value={value}>{props.children}</StorageContext.Provider>;
|
||||
}
|
||||
|
||||
export function useStorage(): StorageContextValue {
|
||||
const context = useContext(StorageContext);
|
||||
const context = React.useContext(StorageContext);
|
||||
if (!context) {
|
||||
throw new Error("useStorage must be used within a StorageProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
"@tensamin/core-crypto": "workspace:*",
|
||||
"@tensamin/core-storage": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/ui": "workspace:*",
|
||||
"solid-js": "^1.9.10",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
Show,
|
||||
untrack,
|
||||
useContext,
|
||||
type ParentProps,
|
||||
} from "solid-js";
|
||||
|
||||
import { createTransportClient, READY_STATE, type BoundSendFn } from "./core";
|
||||
import {
|
||||
PING_INTERVAL,
|
||||
|
|
@ -21,17 +11,8 @@ import {
|
|||
socket as schemas,
|
||||
type Socket as Schemas,
|
||||
} from "@tensamin/shared/data";
|
||||
import { useStorage } from "@tensamin/core-storage/context";
|
||||
import { useCrypto } from "@tensamin/core-crypto/context";
|
||||
import Loading from "@tensamin/ui/screens/loading";
|
||||
import ErrorScreen from "@tensamin/ui/screens/error";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
|
||||
type OmikronData = {
|
||||
id: number;
|
||||
public_key: string;
|
||||
ip_address: string;
|
||||
};
|
||||
|
||||
type ContextType = {
|
||||
send: BoundSendFn<Schemas>;
|
||||
|
|
@ -40,273 +21,217 @@ type ContextType = {
|
|||
iotaPing: () => number;
|
||||
};
|
||||
|
||||
const socketContext = createContext<ContextType>();
|
||||
const socketContext = React.createContext<ContextType | undefined>(undefined);
|
||||
|
||||
export default function Provider(props: ParentProps) {
|
||||
const [omikron, setOmikron] = createSignal<OmikronData | null>(null);
|
||||
const [readyState, setReadyState] = createSignal<number>(READY_STATE.CLOSED);
|
||||
const [identified, setIdentified] = createSignal<boolean>(false);
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
const [readyState, setReadyState] = React.useState<number>(READY_STATE.CLOSED);
|
||||
const [connected, setConnected] = React.useState<boolean>(false);
|
||||
|
||||
const [ownPing, setOwnPing] = createSignal<number>(0);
|
||||
const [iotaPing, setIotaPing] = createSignal<number>(0);
|
||||
const [ownPing, setOwnPing] = React.useState<number>(0);
|
||||
const [iotaPing, setIotaPing] = React.useState<number>(0);
|
||||
|
||||
const [error, setError] = createSignal<string>("");
|
||||
const [errorDescription, setErrorDescription] = createSignal<string>("");
|
||||
const [error, setError] = React.useState("");
|
||||
const [errorDescription, setErrorDescription] = React.useState("");
|
||||
|
||||
const { load } = useStorage();
|
||||
const { get_shared_secret, decrypt } = useCrypto();
|
||||
const clientRef = React.useRef<ReturnType<
|
||||
typeof createTransportClient<Schemas>
|
||||
> | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const send = React.useCallback<BoundSendFn<Schemas>>(
|
||||
((
|
||||
type: string,
|
||||
data?: Record<string, unknown>,
|
||||
options?: { id?: number; noResponse?: boolean },
|
||||
) => {
|
||||
const client = clientRef.current;
|
||||
|
||||
let client: ReturnType<typeof createTransportClient<Schemas>> | null = null;
|
||||
|
||||
// Load Omikron
|
||||
createEffect(() => {
|
||||
if (omikron()) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const userId = await load("user_id");
|
||||
|
||||
// Redirect to login
|
||||
if (userId === 0) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"https://omega.tensamin.net/api/get/omikron/" + String(userId),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const data = await res.json();
|
||||
setOmikron(data);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
setError("Failed to load Omikron data");
|
||||
setErrorDescription(
|
||||
"An error occurred while fetching the Omikron server data. Please try again later.",
|
||||
);
|
||||
log(0, "Socket", "red", "Failed to fetch Omikron data", e);
|
||||
if (!client) {
|
||||
return Promise.reject(new Error("Socket is not connected"));
|
||||
}
|
||||
})();
|
||||
|
||||
onCleanup(() => controller.abort());
|
||||
});
|
||||
if (options?.noResponse) {
|
||||
return client.send(type as keyof Schemas & string, data as never, {
|
||||
...options,
|
||||
noResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (identified()) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const originalNow = Date.now();
|
||||
return client.send(type as keyof Schemas & string, data as never, {
|
||||
...options,
|
||||
noResponse: false,
|
||||
});
|
||||
}) as BoundSendFn<Schemas>,
|
||||
[],
|
||||
);
|
||||
|
||||
const data = await send("ping", {
|
||||
last_ping: originalNow,
|
||||
});
|
||||
|
||||
const travelTime = Date.now() - originalNow;
|
||||
|
||||
setOwnPing(travelTime);
|
||||
setIotaPing(data.data.ping_iota);
|
||||
} catch (error) {
|
||||
log(1, "Socket", "yellow", "Ping failed", error);
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
|
||||
onCleanup(() => clearInterval(interval));
|
||||
React.useEffect(() => {
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Create connection
|
||||
createEffect(() => {
|
||||
if (!omikron()) return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const originalNow = Date.now();
|
||||
|
||||
const data = await send("ping", {
|
||||
last_ping: originalNow,
|
||||
});
|
||||
|
||||
const travelTime = Date.now() - originalNow;
|
||||
setOwnPing(travelTime);
|
||||
|
||||
const remotePing = data.data.ping_iota;
|
||||
if (typeof remotePing === "number") {
|
||||
setIotaPing(remotePing);
|
||||
}
|
||||
} catch (intervalError) {
|
||||
log(1, "Socket", "yellow", "Ping failed", intervalError);
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [connected, send]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let attempts = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectScheduled = false;
|
||||
let disposed = false;
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (!reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
reconnectScheduled = false;
|
||||
};
|
||||
|
||||
const scheduleReconnect = (reason?: unknown) => {
|
||||
if (disposed || reconnectScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts >= RETRY_COUNT) {
|
||||
setError("Connection Failed");
|
||||
setErrorDescription(
|
||||
"Unable to connect to the server after multiple attempts. Please check your internet connection or try again later.",
|
||||
);
|
||||
log(0, "Socket", "red", "Reconnection attempts exhausted", reason);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
reconnectScheduled = true;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectScheduled = false;
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
}, RETRY_INTERVAL);
|
||||
};
|
||||
|
||||
const transportClient = createTransportClient(schemas, {
|
||||
url: TRANSPORT_URL,
|
||||
onReadyStateChange: setReadyState,
|
||||
onReadyStateChange: (state) => {
|
||||
setReadyState(state);
|
||||
|
||||
if (state === READY_STATE.OPEN) {
|
||||
attempts = 0;
|
||||
clearReconnectTimer();
|
||||
setConnected(true);
|
||||
setError("");
|
||||
setErrorDescription("");
|
||||
log(1, "Socket", "green", "Connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setConnected(false);
|
||||
},
|
||||
onClose: ({ error: closeError, intentional }) => {
|
||||
setIdentified(false);
|
||||
setConnected(false);
|
||||
|
||||
if (disposed || intentional) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(0, "Socket", "red", "Disconnected", closeError);
|
||||
|
||||
if (attempts < RETRY_COUNT) {
|
||||
attempts += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
void connect();
|
||||
}, RETRY_INTERVAL);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("Connection Failed");
|
||||
setErrorDescription(
|
||||
"Unable to connect to the server after multiple attempts. Please check your internet connection or try again later.",
|
||||
);
|
||||
log(0, "Socket", "red", "Reconnection attempts exhausted", closeError);
|
||||
scheduleReconnect(closeError);
|
||||
},
|
||||
});
|
||||
|
||||
client = transportClient;
|
||||
|
||||
async function identify(activeClient: typeof transportClient) {
|
||||
const userId = await load("user_id");
|
||||
const privateKey = await load("private_key");
|
||||
const currentOmikron = untrack(() => omikron());
|
||||
|
||||
activeClient
|
||||
.send("identification", { user_id: userId })
|
||||
.then(async (data) => {
|
||||
const ownUserData = await activeClient.send("get_user_data", {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (!currentOmikron?.public_key) {
|
||||
setError("Omikron data missing");
|
||||
setErrorDescription(
|
||||
"Omikron server data is missing. Please try again later.",
|
||||
);
|
||||
log(0, "Socket", "red", "Omikron public key missing");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sharedSecret = await get_shared_secret(
|
||||
privateKey,
|
||||
ownUserData.data.public_key,
|
||||
currentOmikron.public_key,
|
||||
);
|
||||
|
||||
const solvedChallenge = await decrypt(
|
||||
sharedSecret,
|
||||
data.data.challenge,
|
||||
);
|
||||
|
||||
activeClient
|
||||
.send("challenge_response", {
|
||||
challenge: btoa(solvedChallenge),
|
||||
})
|
||||
.then(() => {
|
||||
if (client !== activeClient || disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(1, "Socket", "green", "Identification successful");
|
||||
setIdentified(true);
|
||||
setError("");
|
||||
setErrorDescription("");
|
||||
})
|
||||
.catch((error) => {
|
||||
log(0, "Socket", "red", "Challenge failed", error);
|
||||
setError("Challenge Failed");
|
||||
setErrorDescription(
|
||||
"Failed to respond to the server's challenge. Please try again later.",
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
setError("Validation Failed");
|
||||
setErrorDescription(
|
||||
"Failed to validate the server's identity. Please try again later.",
|
||||
);
|
||||
log(0, "Socket", "red", "Server identity validation failed", err);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
log(0, "Socket", "red", "Identification failed", e);
|
||||
setError("Identification Failed");
|
||||
setErrorDescription(
|
||||
"Failed to identify with the server. Please try again later.",
|
||||
);
|
||||
});
|
||||
}
|
||||
clientRef.current = transportClient;
|
||||
|
||||
async function connect() {
|
||||
if (disposed) return;
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transportClient.connect(TRANSPORT_URL);
|
||||
attempts = 0;
|
||||
await identify(transportClient);
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
log(0, "Socket", "red", "Connection attempt failed", error);
|
||||
} catch (connectError) {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(0, "Socket", "red", "Connection attempt failed", connectError);
|
||||
scheduleReconnect(connectError);
|
||||
}
|
||||
}
|
||||
|
||||
void connect();
|
||||
|
||||
onCleanup(() => {
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (client === transportClient) {
|
||||
client = null;
|
||||
clearReconnectTimer();
|
||||
|
||||
if (clientRef.current === transportClient) {
|
||||
clientRef.current = null;
|
||||
}
|
||||
|
||||
void transportClient.close("context-dispose");
|
||||
setReadyState(READY_STATE.CLOSED);
|
||||
setIdentified(false);
|
||||
});
|
||||
});
|
||||
setConnected(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create Send Function
|
||||
const send: BoundSendFn<Schemas> = ((
|
||||
type: string,
|
||||
data?: Record<string, unknown>,
|
||||
options?: { id?: number; noResponse?: boolean },
|
||||
) => {
|
||||
if (!client) {
|
||||
return Promise.reject(new Error("Socket is not connected"));
|
||||
}
|
||||
|
||||
if (options?.noResponse) {
|
||||
return client.send(type as keyof Schemas & string, data as never, {
|
||||
...options,
|
||||
noResponse: true,
|
||||
});
|
||||
}
|
||||
return client.send(type as keyof Schemas & string, data as never, {
|
||||
...options,
|
||||
noResponse: false,
|
||||
});
|
||||
}) as BoundSendFn<Schemas>;
|
||||
|
||||
const progress = () => {
|
||||
if (!omikron()) return 40;
|
||||
if (readyState() !== READY_STATE.OPEN) return 70;
|
||||
if (!identified()) return 90;
|
||||
const progress = React.useMemo(() => {
|
||||
if (readyState === READY_STATE.CONNECTING) return 70;
|
||||
if (!connected) return 90;
|
||||
return 100;
|
||||
};
|
||||
}, [connected, readyState]);
|
||||
|
||||
const contextValue = React.useMemo<ContextType>(
|
||||
() => ({
|
||||
send,
|
||||
readyState: () => readyState,
|
||||
ownPing: () => ownPing,
|
||||
iotaPing: () => iotaPing,
|
||||
}),
|
||||
[iotaPing, ownPing, readyState, send],
|
||||
);
|
||||
|
||||
if (error !== "" && errorDescription !== "") {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return <Loading progress={progress} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={error() === "" && errorDescription() === ""}
|
||||
fallback={
|
||||
<ErrorScreen error={error()} description={errorDescription()} />
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={omikron() && identified()}
|
||||
fallback={<Loading progress={progress()} />}
|
||||
>
|
||||
<socketContext.Provider value={{ send, readyState, ownPing, iotaPing }}>
|
||||
{props.children}
|
||||
</socketContext.Provider>
|
||||
</Show>
|
||||
</Show>
|
||||
<socketContext.Provider value={contextValue}>
|
||||
{props.children}
|
||||
</socketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSocket(): ContextType {
|
||||
const context = useContext(socketContext);
|
||||
if (!context)
|
||||
const context = React.useContext(socketContext);
|
||||
if (!context) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,14 @@ const APPLICATION_CLOSE_CODE = 0;
|
|||
const APPLICATION_CLOSE_REASON = "epsilon-close";
|
||||
const MAX_REQUEST_ID = 0xffff_fffe;
|
||||
|
||||
const DATA_VALUE_KIND_BOOL_TRUE = 0x01;
|
||||
const DATA_VALUE_KIND_BOOL_FALSE = 0x02;
|
||||
const DATA_VALUE_KIND_NUMBER = 0x03;
|
||||
const DATA_VALUE_KIND_STRING = 0x04;
|
||||
const DATA_VALUE_KIND_ARRAY = 0x05;
|
||||
const DATA_VALUE_KIND_CONTAINER = 0x06;
|
||||
const DATA_VALUE_KIND_NULL = 0x07;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
|
|
@ -966,34 +974,40 @@ function encodeCommunicationMessage(
|
|||
message: TypedMessage<Record<string, unknown>>,
|
||||
) {
|
||||
const typeIndex = parseCommunicationType(message.type);
|
||||
const dataBuffer = encodeValueForKind("container", message.data, "payload");
|
||||
const hasId = message.id !== 0;
|
||||
const totalLength = 2 + (hasId ? 4 : 0) + 4 + dataBuffer.byteLength;
|
||||
const buffer = new Uint8Array(totalLength);
|
||||
const dataBuffer = encodeContainerPayload(message.data, "payload");
|
||||
const payloadLength = 2 + (hasId ? 4 : 0) + dataBuffer.byteLength;
|
||||
const buffer = new Uint8Array(4 + payloadLength);
|
||||
|
||||
buffer[0] = typeIndex;
|
||||
buffer[1] = hasId ? 0b0010_0000 : 0;
|
||||
writeU32(buffer, 0, payloadLength);
|
||||
buffer[4] = typeIndex;
|
||||
buffer[5] = hasId ? 0b0000_0100 : 0;
|
||||
|
||||
let offset = 2;
|
||||
let offset = 6;
|
||||
if (hasId) {
|
||||
writeU32(buffer, offset, message.id);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
writeU32(buffer, offset, dataBuffer.byteLength);
|
||||
offset += 4;
|
||||
buffer.set(dataBuffer, offset);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function decodeCommunicationMessage(payload: Uint8Array): TypedMessage {
|
||||
const reader = new ByteReader(payload);
|
||||
function decodeCommunicationMessage(frame: Uint8Array): TypedMessage {
|
||||
const reader = new ByteReader(frame);
|
||||
const payloadLength = reader.readU32();
|
||||
if (payloadLength !== frame.byteLength - 4) {
|
||||
throw new Error(
|
||||
`Communication payload length mismatch: expected ${payloadLength}, received ${frame.byteLength - 4}`,
|
||||
);
|
||||
}
|
||||
|
||||
const typeIndex = reader.readU8();
|
||||
const flags = reader.readU8();
|
||||
const hasSender = (flags & 0b1000_0000) !== 0;
|
||||
const hasReceiver = (flags & 0b0100_0000) !== 0;
|
||||
const hasId = (flags & 0b0010_0000) !== 0;
|
||||
const hasSender = (flags & 0b0000_0001) !== 0;
|
||||
const hasReceiver = (flags & 0b0000_0010) !== 0;
|
||||
const hasId = (flags & 0b0000_0100) !== 0;
|
||||
|
||||
const id = hasId ? reader.readU32() : 0;
|
||||
if (hasSender) {
|
||||
|
|
@ -1003,11 +1017,18 @@ function decodeCommunicationMessage(payload: Uint8Array): TypedMessage {
|
|||
reader.readU48();
|
||||
}
|
||||
|
||||
const dataLength = reader.readU32();
|
||||
const dataBytes = reader.readBytes(dataLength);
|
||||
const decodedData = decodeValue(new ByteReader(dataBytes));
|
||||
if (!isPlainObject(decodedData)) {
|
||||
throw new Error("Protocol payload container was not decoded as an object");
|
||||
const consumedHeaderBytes =
|
||||
2 + (hasId ? 4 : 0) + (hasSender ? 6 : 0) + (hasReceiver ? 6 : 0);
|
||||
if (consumedHeaderBytes > payloadLength) {
|
||||
throw new Error("Communication header exceeds payload length");
|
||||
}
|
||||
|
||||
const dataLength = payloadLength - consumedHeaderBytes;
|
||||
const dataReader = new ByteReader(reader.readBytes(dataLength));
|
||||
const decodedData = decodeContainerPayload(dataReader);
|
||||
|
||||
if (!dataReader.isAtEnd()) {
|
||||
throw new Error("Trailing bytes found after communication data payload");
|
||||
}
|
||||
|
||||
if (!reader.isAtEnd()) {
|
||||
|
|
@ -1051,13 +1072,21 @@ function getExpectedKind(type: string) {
|
|||
return kind;
|
||||
}
|
||||
|
||||
function encodeValueForKind(
|
||||
type EncodedDataValue = {
|
||||
kind: number;
|
||||
payload: Uint8Array;
|
||||
};
|
||||
|
||||
function encodeDataValueForKind(
|
||||
kind: DataKind,
|
||||
value: unknown,
|
||||
path: string,
|
||||
): Uint8Array {
|
||||
): EncodedDataValue {
|
||||
if (typeof kind === "object") {
|
||||
return encodeArrayValue(kind.array, value, path);
|
||||
return {
|
||||
kind: DATA_VALUE_KIND_ARRAY,
|
||||
payload: encodeArrayPayload(kind.array, value, path),
|
||||
};
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
|
|
@ -1066,35 +1095,50 @@ function encodeValueForKind(
|
|||
throw new Error(`Expected boolean at "${path}"`);
|
||||
}
|
||||
|
||||
return Uint8Array.of(0x03, value ? 1 : 0);
|
||||
return {
|
||||
kind: value ? DATA_VALUE_KIND_BOOL_TRUE : DATA_VALUE_KIND_BOOL_FALSE,
|
||||
payload: new Uint8Array(0),
|
||||
};
|
||||
|
||||
case "number":
|
||||
return encodeNumberValue(value, path);
|
||||
return {
|
||||
kind: DATA_VALUE_KIND_NUMBER,
|
||||
payload: encodeNumberPayload(value, path),
|
||||
};
|
||||
|
||||
case "string":
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`Expected string at "${path}"`);
|
||||
}
|
||||
|
||||
return encodeStringValue(value);
|
||||
return {
|
||||
kind: DATA_VALUE_KIND_STRING,
|
||||
payload: textEncoder.encode(value),
|
||||
};
|
||||
|
||||
case "container":
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error(`Expected object at "${path}"`);
|
||||
}
|
||||
|
||||
return encodeContainerValue(value, path);
|
||||
return {
|
||||
kind: DATA_VALUE_KIND_CONTAINER,
|
||||
payload: encodeContainerPayload(value, path),
|
||||
};
|
||||
|
||||
case "null":
|
||||
if (value !== null && value !== undefined) {
|
||||
throw new Error(`Expected null at "${path}"`);
|
||||
}
|
||||
|
||||
return Uint8Array.of(0x06);
|
||||
return {
|
||||
kind: DATA_VALUE_KIND_NULL,
|
||||
payload: new Uint8Array(0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function encodeNumberValue(value: unknown, path: string) {
|
||||
function encodeNumberPayload(value: unknown, path: string) {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
|
|
@ -1103,22 +1147,12 @@ function encodeNumberValue(value: unknown, path: string) {
|
|||
throw new Error(`Expected safe integer at "${path}"`);
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(9);
|
||||
buffer[0] = 0x01;
|
||||
writeI64(buffer, 1, value);
|
||||
const buffer = new Uint8Array(8);
|
||||
writeI64(buffer, 0, value);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function encodeStringValue(value: string) {
|
||||
const bytes = textEncoder.encode(value);
|
||||
const buffer = new Uint8Array(5 + bytes.byteLength);
|
||||
buffer[0] = 0x02;
|
||||
writeU32(buffer, 1, bytes.byteLength);
|
||||
buffer.set(bytes, 5);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function encodeArrayValue(
|
||||
function encodeArrayPayload(
|
||||
innerKind: PrimitiveDataKind | "container" | "null",
|
||||
value: unknown,
|
||||
path: string,
|
||||
|
|
@ -1132,28 +1166,44 @@ function encodeArrayValue(
|
|||
}
|
||||
|
||||
const encodedItems = value.map((entry, index) =>
|
||||
encodeValueForKind(innerKind, entry, `${path}[${index}]`),
|
||||
encodeDataValueForKind(innerKind, entry, `${path}[${index}]`),
|
||||
);
|
||||
|
||||
const byteLength = encodedItems.reduce(
|
||||
(sum, item) => sum + item.byteLength,
|
||||
0,
|
||||
);
|
||||
const buffer = new Uint8Array(4 + byteLength);
|
||||
buffer[0] = 0x04;
|
||||
buffer[1] = value.length === 0 ? 0x00 : kindToMarker(innerKind);
|
||||
writeU16(buffer, 2, value.length);
|
||||
let totalLength = 2;
|
||||
for (const encodedItem of encodedItems) {
|
||||
totalLength += 1;
|
||||
|
||||
let offset = 4;
|
||||
if (!isBoolKindMarker(encodedItem.kind)) {
|
||||
if (encodedItem.payload.byteLength > 0xffff) {
|
||||
throw new Error(`Array item at "${path}" is too large for protocol encoding`);
|
||||
}
|
||||
|
||||
totalLength += 2 + encodedItem.payload.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(totalLength);
|
||||
writeU16(buffer, 0, value.length);
|
||||
|
||||
let offset = 2;
|
||||
for (const item of encodedItems) {
|
||||
buffer.set(item, offset);
|
||||
offset += item.byteLength;
|
||||
buffer[offset] = item.kind;
|
||||
offset += 1;
|
||||
|
||||
if (isBoolKindMarker(item.kind)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeU16(buffer, offset, item.payload.byteLength);
|
||||
offset += 2;
|
||||
buffer.set(item.payload, offset);
|
||||
offset += item.payload.byteLength;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function encodeContainerValue(value: Record<string, unknown>, path: string) {
|
||||
function encodeContainerPayload(value: Record<string, unknown>, path: string) {
|
||||
const normalizedEntries = new Map<
|
||||
string,
|
||||
{ index: number; value: unknown }
|
||||
|
|
@ -1171,82 +1221,103 @@ function encodeContainerValue(value: Record<string, unknown>, path: string) {
|
|||
([, left], [, right]) => left.index - right.index,
|
||||
);
|
||||
|
||||
const encodedEntries: Uint8Array[] = [];
|
||||
let totalLength = 3;
|
||||
if (entries.length > 0xffff) {
|
||||
throw new Error(`Container at "${path}" has too many entries for protocol encoding`);
|
||||
}
|
||||
|
||||
const encodedEntries: Array<{ keyIndex: number; value: EncodedDataValue }> = [];
|
||||
let totalLength = 2;
|
||||
|
||||
for (const [name, entry] of entries) {
|
||||
const expectedKind = getExpectedKind(name);
|
||||
const pathForEntry = `${path}.${name}`;
|
||||
|
||||
if (expectedKind === "bool") {
|
||||
if (typeof entry.value !== "boolean") {
|
||||
throw new Error(`Expected boolean at "${pathForEntry}"`);
|
||||
}
|
||||
|
||||
const encoded = Uint8Array.of(entry.index, entry.value ? 1 : 0);
|
||||
encodedEntries.push(encoded);
|
||||
totalLength += encoded.byteLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
const encodedValue = encodeValueForKind(
|
||||
const encodedValue = encodeDataValueForKind(
|
||||
expectedKind,
|
||||
entry.value,
|
||||
pathForEntry,
|
||||
);
|
||||
const body = encodedValue.subarray(1);
|
||||
const encoded = new Uint8Array(5 + body.byteLength);
|
||||
encoded[0] = entry.index;
|
||||
writeU32(encoded, 1, body.byteLength);
|
||||
encoded.set(body, 5);
|
||||
encodedEntries.push(encoded);
|
||||
totalLength += encoded.byteLength;
|
||||
|
||||
if (isBoolKindMarker(encodedValue.kind)) {
|
||||
totalLength += 2;
|
||||
} else {
|
||||
if (encodedValue.payload.byteLength > 0xffff) {
|
||||
throw new Error(`Container entry "${pathForEntry}" is too large for protocol encoding`);
|
||||
}
|
||||
|
||||
totalLength += 4 + encodedValue.payload.byteLength;
|
||||
}
|
||||
|
||||
encodedEntries.push({
|
||||
keyIndex: entry.index,
|
||||
value: encodedValue,
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(totalLength);
|
||||
buffer[0] = 0x05;
|
||||
writeU16(buffer, 1, entries.length);
|
||||
writeU16(buffer, 0, entries.length);
|
||||
|
||||
let offset = 3;
|
||||
for (const encoded of encodedEntries) {
|
||||
buffer.set(encoded, offset);
|
||||
offset += encoded.byteLength;
|
||||
let offset = 2;
|
||||
for (const entry of encodedEntries) {
|
||||
buffer[offset] = entry.value.kind;
|
||||
offset += 1;
|
||||
|
||||
if (isBoolKindMarker(entry.value.kind)) {
|
||||
buffer[offset] = entry.keyIndex;
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
writeU16(buffer, offset, entry.value.payload.byteLength);
|
||||
offset += 2;
|
||||
buffer[offset] = entry.keyIndex;
|
||||
offset += 1;
|
||||
buffer.set(entry.value.payload, offset);
|
||||
offset += entry.value.payload.byteLength;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function decodeValue(reader: ByteReader): unknown {
|
||||
const marker = reader.readU8();
|
||||
function decodeValuePayload(marker: number, payload: Uint8Array): unknown {
|
||||
const reader = new ByteReader(payload);
|
||||
|
||||
switch (marker) {
|
||||
case 0x01:
|
||||
return reader.readI64();
|
||||
|
||||
case 0x02: {
|
||||
const length = reader.readU32();
|
||||
return textDecoder.decode(reader.readBytes(length));
|
||||
}
|
||||
|
||||
case 0x03:
|
||||
return reader.readU8() !== 0;
|
||||
|
||||
case 0x04: {
|
||||
reader.readU8();
|
||||
const length = reader.readU16();
|
||||
const values: unknown[] = [];
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
values.push(decodeValue(reader));
|
||||
case DATA_VALUE_KIND_BOOL_TRUE:
|
||||
if (!reader.isAtEnd()) {
|
||||
throw new Error("Unexpected payload for boolean true value");
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
return true;
|
||||
|
||||
case 0x05:
|
||||
return decodeContainer(reader);
|
||||
case DATA_VALUE_KIND_BOOL_FALSE:
|
||||
if (!reader.isAtEnd()) {
|
||||
throw new Error("Unexpected payload for boolean false value");
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
case DATA_VALUE_KIND_NUMBER:
|
||||
if (payload.byteLength !== 8) {
|
||||
throw new Error(`Invalid number payload length ${payload.byteLength}`);
|
||||
}
|
||||
|
||||
return reader.readI64();
|
||||
|
||||
case DATA_VALUE_KIND_STRING:
|
||||
return textDecoder.decode(payload);
|
||||
|
||||
case DATA_VALUE_KIND_ARRAY:
|
||||
return decodeArrayPayload(reader);
|
||||
|
||||
case DATA_VALUE_KIND_CONTAINER:
|
||||
return decodeContainerPayload(reader);
|
||||
|
||||
case DATA_VALUE_KIND_NULL:
|
||||
if (!reader.isAtEnd()) {
|
||||
throw new Error("Unexpected payload for null value");
|
||||
}
|
||||
|
||||
case 0x06:
|
||||
return null;
|
||||
|
||||
default:
|
||||
|
|
@ -1254,39 +1325,86 @@ function decodeValue(reader: ByteReader): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
function decodeContainer(reader: ByteReader) {
|
||||
const length = reader.readU16();
|
||||
const value: Record<string, unknown> = {};
|
||||
function decodeArrayPayload(reader: ByteReader) {
|
||||
const itemCount = reader.readU16();
|
||||
const values: unknown[] = [];
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const keyIndex = reader.readU8();
|
||||
const key = DATA_TYPES[keyIndex];
|
||||
if (!key) {
|
||||
throw new Error(`Unknown data type index ${keyIndex}`);
|
||||
}
|
||||
for (let index = 0; index < itemCount; index += 1) {
|
||||
const marker = reader.readU8();
|
||||
|
||||
const expectedKind = getExpectedKind(key);
|
||||
|
||||
if (expectedKind === "bool") {
|
||||
value[key] = normalizeIncomingValue(key, reader.readU8() !== 0);
|
||||
if (isBoolKindMarker(marker)) {
|
||||
values.push(marker === DATA_VALUE_KIND_BOOL_TRUE);
|
||||
continue;
|
||||
}
|
||||
|
||||
const encodedLength = reader.readU32();
|
||||
const encodedValue = reader.readBytes(encodedLength);
|
||||
const fullValue = new Uint8Array(1 + encodedValue.byteLength);
|
||||
fullValue[0] = kindToMarker(expectedKind);
|
||||
fullValue.set(encodedValue, 1);
|
||||
const payloadLength = reader.readU16();
|
||||
const payload = reader.readBytes(payloadLength);
|
||||
values.push(decodeValuePayload(marker, payload));
|
||||
}
|
||||
|
||||
value[key] = normalizeIncomingValue(
|
||||
key,
|
||||
decodeValue(new ByteReader(fullValue)),
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeContainerPayload(reader: ByteReader) {
|
||||
const entryCount = reader.readU16();
|
||||
const value: Record<string, unknown> = {};
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
const marker = reader.readU8();
|
||||
const payloadLength = isBoolKindMarker(marker) ? 0 : reader.readU16();
|
||||
const keyIndex = reader.readU8();
|
||||
const key = getDataTypeNameByIndex(keyIndex);
|
||||
const payload = isBoolKindMarker(marker)
|
||||
? new Uint8Array(0)
|
||||
: reader.readBytes(payloadLength);
|
||||
|
||||
const expectedKind = getExpectedKind(key);
|
||||
if (!isMarkerCompatibleWithKind(marker, expectedKind)) {
|
||||
throw new Error(
|
||||
`Unexpected marker 0x${marker.toString(16)} for data type "${key}"`,
|
||||
);
|
||||
}
|
||||
|
||||
value[key] = normalizeIncomingValue(key, decodeValuePayload(marker, payload));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getDataTypeNameByIndex(index: number) {
|
||||
const key = DATA_TYPES[index];
|
||||
if (!key) {
|
||||
throw new Error(`Unknown data type index ${index}`);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function isBoolKindMarker(marker: number) {
|
||||
return (
|
||||
marker === DATA_VALUE_KIND_BOOL_TRUE || marker === DATA_VALUE_KIND_BOOL_FALSE
|
||||
);
|
||||
}
|
||||
|
||||
function isMarkerCompatibleWithKind(marker: number, kind: DataKind) {
|
||||
if (typeof kind === "object") {
|
||||
return marker === DATA_VALUE_KIND_ARRAY;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "bool":
|
||||
return isBoolKindMarker(marker);
|
||||
case "number":
|
||||
return marker === DATA_VALUE_KIND_NUMBER;
|
||||
case "string":
|
||||
return marker === DATA_VALUE_KIND_STRING;
|
||||
case "container":
|
||||
return marker === DATA_VALUE_KIND_CONTAINER;
|
||||
case "null":
|
||||
return marker === DATA_VALUE_KIND_NULL;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutgoingValue(type: string, value: unknown) {
|
||||
if (SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && typeof value === "number") {
|
||||
return [value];
|
||||
|
|
@ -1308,25 +1426,6 @@ function normalizeIncomingValue(type: string, value: unknown) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function kindToMarker(kind: DataKind) {
|
||||
if (typeof kind === "object") {
|
||||
return 0x04;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "number":
|
||||
return 0x01;
|
||||
case "string":
|
||||
return 0x02;
|
||||
case "bool":
|
||||
return 0x03;
|
||||
case "container":
|
||||
return 0x05;
|
||||
case "null":
|
||||
return 0x06;
|
||||
}
|
||||
}
|
||||
|
||||
function writeU16(buffer: Uint8Array, offset: number, value: number) {
|
||||
new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint16(
|
||||
offset,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"dependencies": {
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"solid-js": "^1.9.11",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import * as React from "react";
|
||||
import { useSocket } from "@tensamin/ttp/context";
|
||||
|
||||
import { socket as schemas } from "@tensamin/shared/data";
|
||||
|
|
@ -12,15 +11,15 @@ interface contextValue {
|
|||
get(userId: number): Promise<User>;
|
||||
}
|
||||
|
||||
const UserContext = createContext<contextValue>();
|
||||
const UserContext = React.createContext<contextValue | undefined>(undefined);
|
||||
|
||||
export default function UserProvider(props: ParentProps) {
|
||||
const [storage, setStorage] = createStore<Record<number, User>>({});
|
||||
export default function UserProvider(props: { children: React.ReactNode }) {
|
||||
const storageRef = React.useRef<Record<number, User>>({});
|
||||
|
||||
const { send } = useSocket();
|
||||
|
||||
async function get(userId: number): Promise<User> {
|
||||
if (storage[userId] === undefined) {
|
||||
if (storageRef.current[userId] === undefined) {
|
||||
try {
|
||||
const userData = await send("get_user_data", { user_id: userId });
|
||||
|
||||
|
|
@ -30,12 +29,12 @@ export default function UserProvider(props: ParentProps) {
|
|||
: undefined;
|
||||
// Temp end
|
||||
|
||||
setStorage(userId, userData.data);
|
||||
storageRef.current[userId] = userData.data;
|
||||
} catch {
|
||||
setStorage(userId, failedUser);
|
||||
storageRef.current[userId] = failedUser;
|
||||
}
|
||||
}
|
||||
return storage[userId];
|
||||
return storageRef.current[userId];
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -46,7 +45,7 @@ export default function UserProvider(props: ParentProps) {
|
|||
}
|
||||
|
||||
export function useUser(): contextValue {
|
||||
const context = useContext(UserContext);
|
||||
const context = React.useContext(UserContext);
|
||||
if (!context) {
|
||||
throw new Error("useUser must be used within a UserProvider");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
import { createEffect, createSignal, type JSX } from "solid-js";
|
||||
import * as React from "react";
|
||||
import { useUser, type User } from "./context";
|
||||
|
||||
export default function Wrapper(props: {
|
||||
userId: number;
|
||||
component: (user: User) => JSX.Element;
|
||||
component: (user: User) => React.ReactNode;
|
||||
}) {
|
||||
const { get } = useUser();
|
||||
const [user, setUser] = createSignal<User | null>(null);
|
||||
const [user, setUser] = React.useState<User | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
get(props.userId).then(setUser);
|
||||
});
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
get(props.userId).then((value) => {
|
||||
if (active) {
|
||||
setUser(value);
|
||||
}
|
||||
});
|
||||
|
||||
return <>{user() ? props.component(user() as User) : null}</>;
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [get, props.userId]);
|
||||
|
||||
return <>{user ? props.component(user) : null}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
|
|
|
|||
Loading…
Reference in a new issue