Merge remote-tracking branch 'jazz-react/main'

This commit is contained in:
Anselm
2023-08-15 18:11:56 +01:00
6 changed files with 593 additions and 0 deletions

18
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
project: './tsconfig.json',
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-floating-promises": "error",
},
};

171
.gitignore vendored Normal file
View File

@@ -0,0 +1,171 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
.DS_Store

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "jazz-react",
"version": "0.0.6",
"main": "src/index.tsx",
"types": "src/index.tsx",
"license": "MIT",
"dependencies": {
"cojson": "^0.0.14",
"typescript": "^5.1.6"
},
"devDependencies": {
"@types/react": "^18.2.19"
},
"peerDependencies": {
"react": "^17.0.2"
}
}

291
src/index.tsx Normal file
View File

@@ -0,0 +1,291 @@
import {
LocalNode,
internals as cojsonInternals,
SessionID,
ContentType,
SyncMessage,
AgentSecret,
} from "cojson";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { ReadableStream, WritableStream } from "isomorphic-streams";
import { CoID } from "cojson";
import { AgentID } from "cojson/src/ids";
import { getAgentID } from "cojson/src/crypto";
import { AnonymousControlledAccount } from "cojson/src/account";
import { IDBStorage } from "jazz-storage-indexeddb";
type JazzContext = {
localNode: LocalNode;
};
const JazzContext = React.createContext<JazzContext | undefined>(undefined);
export type AuthComponent = (props: {
onCredential: (credentials: AgentSecret) => void;
}) => React.ReactElement;
export function WithJazz({
children,
auth: Auth,
syncAddress = "wss://sync.jazz.tools",
}: {
children: React.ReactNode;
auth: AuthComponent;
syncAddress?: string;
}) {
const [node, setNode] = useState<LocalNode | undefined>();
const sessionDone = useRef<() => void>();
const onCredential = useCallback((credential: AgentSecret) => {
const agentID = getAgentID(
credential
);
const sessionHandle = getSessionFor(agentID);
sessionHandle.session.then((sessionID) =>
setNode(new LocalNode(new AnonymousControlledAccount(credential), sessionID))
);
sessionDone.current = sessionHandle.done;
}, []);
useEffect(() => {
return () => {
sessionDone.current && sessionDone.current();
};
}, []);
useEffect(() => {
if (node) {
IDBStorage.connectTo(node, {trace: true})
let shouldTryToReconnect = true;
let ws: WebSocket | undefined;
(async function websocketReconnectLoop() {
while (shouldTryToReconnect) {
ws = new WebSocket(syncAddress);
const timeToReconnect = new Promise<void>((resolve) => {
if (
!ws ||
ws.readyState === WebSocket.CLOSING ||
ws.readyState === WebSocket.CLOSED
)
resolve();
ws?.addEventListener(
"close",
() => {
console.log(
"Connection closed, reconnecting in 5s"
);
setTimeout(resolve, 5000);
},
{ once: true }
);
});
const incoming = websocketReadableStream<SyncMessage>(ws);
const outgoing = websocketWritableStream<SyncMessage>(ws);
node.sync.addPeer({
id: syncAddress + "@" + new Date().toISOString(),
incoming,
outgoing,
role: "server",
});
await timeToReconnect;
}
})();
return () => {
shouldTryToReconnect = false;
ws?.close();
};
}
}, [node, syncAddress]);
return node ? (
<JazzContext.Provider value={{ localNode: node }}>
<>{children}</>
</JazzContext.Provider>
) : (
<Auth onCredential={onCredential} />
);
}
type SessionHandle = {
session: Promise<SessionID>;
done: () => void;
};
function getSessionFor(agentID: AgentID): SessionHandle {
let done!: () => void;
const donePromise = new Promise<void>((resolve) => {
done = resolve;
});
let resolveSession: (sessionID: SessionID) => void;
const sessionPromise = new Promise<SessionID>((resolve) => {
resolveSession = resolve;
});
(async function () {
for (let idx = 0; idx < 100; idx++) {
// To work better around StrictMode
for (let retry = 0; retry < 2; retry++) {
console.log("Trying to get lock", agentID + "_" + idx);
const sessionFinishedOrNoLock = await navigator.locks.request(
agentID + "_" + idx,
{ ifAvailable: true },
async (lock) => {
if (!lock) return "noLock";
const sessionID =
localStorage[agentID + "_" + idx] ||
cojsonInternals.newRandomSessionID(agentID);
localStorage[agentID + "_" + idx] = sessionID;
console.log("Got lock", agentID + "_" + idx, sessionID);
resolveSession(sessionID);
await donePromise;
console.log(
"Done with lock",
agentID + "_" + idx,
sessionID
);
return "sessionFinished";
}
);
if (sessionFinishedOrNoLock === "sessionFinished") {
return;
}
}
}
throw new Error("Couldn't get lock on session after 100x2 tries");
})();
return {
session: sessionPromise,
done,
};
}
export function useJazz() {
const context = React.useContext(JazzContext);
if (!context) {
throw new Error("useJazz must be used within a WithJazz provider");
}
return context;
}
export function useTelepathicState<T extends ContentType>(id: CoID<T>) {
const [state, setState] = useState<T>();
const { localNode } = useJazz();
useEffect(() => {
let unsubscribe: (() => void) | undefined = undefined;
let done = false;
localNode.load(id).then((state) => {
if (done) return;
unsubscribe = state.subscribe((newState) => {
console.log(
"Got update",
id,
newState.toJSON(),
newState.coValue.sessions
);
setState(newState as T);
});
});
return () => {
done = true;
unsubscribe && unsubscribe();
};
}, [localNode, id]);
return state;
}
function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
return new ReadableStream<T>({
start(controller) {
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ping") {
console.debug(
"Got ping from",
msg.dc,
"latency",
Date.now() - msg.time,
"ms"
);
return;
}
controller.enqueue(msg);
};
ws.onclose = () => controller.close();
ws.onerror = () =>
controller.error(new Error("The WebSocket errored!"));
},
cancel() {
ws.close();
},
});
}
function websocketWritableStream<T>(ws: WebSocket) {
return new WritableStream<T>({
start(controller) {
ws.onerror = () => {
controller.error(new Error("The WebSocket errored!"));
ws.onclose = null;
};
ws.onclose = () =>
controller.error(
new Error("The server closed the connection unexpectedly!")
);
return new Promise((resolve) => (ws.onopen = resolve));
},
write(chunk) {
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.
},
close() {
return closeWS(1000);
},
abort(reason) {
return closeWS(4000, reason && reason.message);
},
});
function closeWS(code: number, reasonString?: string) {
return new Promise<void>((resolve, reject) => {
ws.onclose = (e) => {
if (e.wasClean) {
resolve();
} else {
reject(new Error("The connection was not closed cleanly"));
}
};
ws.close(code, reasonString);
});
}
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noEmit": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},
"include": ["./src/**/*"],
}

75
yarn.lock Normal file
View File

@@ -0,0 +1,75 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@noble/ciphers@^0.1.3":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0"
integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ==
"@noble/curves@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==
dependencies:
"@noble/hashes" "1.3.1"
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
"@scure/base@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
"@types/prop-types@*":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
"@types/react@^18.2.19":
version "18.2.19"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.19.tgz#f77cb2c8307368e624d464a25b9675fa35f95a8b"
integrity sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.3"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
cojson@^0.0.14:
version "0.0.14"
resolved "https://registry.yarnpkg.com/cojson/-/cojson-0.0.14.tgz#e7b190ade1efc20d6f0fa12411d7208cdc0f19f7"
integrity sha512-TFenIGswEEhnZlCmq+B1NZPztjovZ72AjK1YkkZca54ZFbB1lAHdPt2hqqu/QBO24C9+6DtuoS2ixm6gbSBWCg==
dependencies:
"@noble/ciphers" "^0.1.3"
"@noble/curves" "^1.1.0"
"@noble/hashes" "^1.3.1"
"@scure/base" "^1.1.1"
fast-json-stable-stringify "^2.1.0"
isomorphic-streams "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
csstype@^3.0.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
"isomorphic-streams@git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae":
version "1.0.3"
resolved "git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
typescript@^5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==