Compare commits

...

25 Commits

Author SHA1 Message Date
Anselm Eickhoff
feed34b1cf Merge pull request #122 from gardencmp/react-advanced
Performance improvements & Twit example improvements
2023-10-19 10:57:37 +01:00
Anselm
662c980cf2 First changeset 2023-10-19 00:52:47 +01:00
Anselm
f5ae530890 Add and use mapDefered to ResolvedCoList 2023-10-19 00:38:35 +01:00
Anselm
46bf7dd3ce A ton of performance and twit example improvements 2023-10-18 23:16:39 +01:00
Anselm
5d4eb38204 A bunch of perf improvements and sync fixes 2023-10-18 00:37:41 +01:00
Anselm
66da658075 Twit example improvements & initial stress test 2023-10-17 21:22:04 +01:00
Anselm
3477b74573 Lots of sync improvements, basic peer priority 2023-10-17 21:21:39 +01:00
Anselm
f3de4906b7 Prepare stress test and fix #83 2023-10-17 16:34:17 +01:00
Anselm
caded3f189 Fix unknown signer bug for incoming transactions 2023-10-17 14:39:13 +01:00
Anselm
5196395495 Wording 2023-10-17 12:03:48 +01:00
Anselm
8089a7ed9f Add report to parent frame again 2023-10-17 11:51:41 +01:00
Anselm
99230d31d2 Add plausible script 2023-10-17 11:49:30 +01:00
Anselm Eickhoff
94bca03f59 Merge pull request #121 from gardencmp/new-hp
New homepage PR 2
2023-10-17 11:39:04 +01:00
Anselm
49719b6e6d Fix example deploy 2023-10-17 11:28:10 +01:00
Anselm
1bdb781452 Fix iframe and metadata 2023-10-17 11:19:00 +01:00
Anselm
c336f69a6b Build and deploy homepage 2023-10-17 11:03:59 +01:00
Anselm
c8cb1ce208 Rename font 2023-10-17 09:55:45 +01:00
Anselm
814a6a80cd Lots of homepage improvements 2023-10-16 23:47:51 +01:00
Anselm Eickhoff
5fdfe18b32 Merge pull request #119 from gardencmp/new-hp
Chat demo & start of new homepage
2023-10-13 11:44:40 +01:00
Anselm
7b7a74778b Reduce number of chat example deployments 2023-10-13 11:40:36 +01:00
Anselm
39dbd46556 Publish
- jazz-example-chat@0.0.45
 - jazz-example-file-drop@0.0.62
 - jazz-example-pets@0.0.62
 - jazz-example-todo@0.0.62
 - jazz-example-twit@0.0.62
 - hash-slash@0.1.3
 - jazz-browser@0.4.15
 - jazz-browser-auth-local@0.4.15
 - jazz-browser-media-images@0.4.15
 - jazz-react@0.4.15
 - jazz-react-auth-local@0.4.15
2023-10-13 11:36:13 +01:00
Anselm
1db4a14be4 Update docs 2023-10-13 11:35:53 +01:00
Anselm
4a4ea4e196 Fix issues with packages 2023-10-13 11:35:35 +01:00
Anselm
e0724441eb Deploy chat example 2023-10-13 11:28:08 +01:00
Anselm
5d47895515 Rename hashroute to hash-slash 2023-10-13 11:25:46 +01:00
131 changed files with 6850 additions and 6825 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

11
.changeset/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -7,11 +7,11 @@ on:
branches: [ "main" ]
jobs:
build:
build-examples:
runs-on: ubuntu-latest
strategy:
matrix:
example: ["todo", "pets", "twit", "file-drop"]
example: ["chat", "todo", "pets", "twit", "file-drop"]
steps:
- uses: actions/checkout@v3
@@ -53,12 +53,39 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
build-homepage:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: gardencmp
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Build & Push
uses: docker/build-push-action@v4
with:
context: ./homepage/homepage-jazz
push: true
tags: ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-examples:
runs-on: ubuntu-latest
needs: build-examples
strategy:
matrix:
example: ["todo", "pets", "twit", "file-drop"]
example: ["chat", "todo", "pets", "twit", "file-drop"]
steps:
- uses: actions/checkout@v3
@@ -87,4 +114,37 @@ jobs:
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
working-directory: ./examples/${{ matrix.example }}
working-directory: ./examples/${{ matrix.example }}
deploy-homepage:
runs-on: ubuntu-latest
needs: build-homepage
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: gacts/install-nomad@v1
- name: Tailscale
uses: tailscale/github-action@v1
with:
authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
- name: Deploy on Nomad
run: |
if [ "${{github.ref_name}}" == "main" ]; then
export BRANCH_SUFFIX="";
export BRANCH_SUBDOMAIN="";
else
export BRANCH_SUFFIX=-${{github.head_ref || github.ref_name}};
export BRANCH_SUBDOMAIN=${{github.head_ref || github.ref_name}}.;
fi
export DOCKER_USER=gardencmp;
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
working-directory: ./homepage/homepage-jazz

235
DOCS.md
View File

@@ -44,7 +44,7 @@
<!-- AUTOGENERATED DOCS AFTER THIS POINT -->
# jazz-react
## `<WithJazz/>`
## `WithJazz(props)`
<sup>(function in `jazz-react`)</sup>
@@ -1742,6 +1742,26 @@ TODO: document
<details>
<summary><b><code>.mapDeferred</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class ResolvedCoList<L> {
mapDeferred: (mapper: (item: {
loaded: boolean,
id: L["_item"] extends CoID<CoValue> ? any[any] : never,
value((): ValueOrResolvedRef<L["_item"]>,
}, idx: number) => O) => O[]
}
```
TODO: document
</details>
<details>
<summary><b><code>.length</code></b> <sub><sup>from <code>Array</code></sup></sub> </summary>
@@ -2733,7 +2753,7 @@ class LocalNode {
load<T extends CoValue>(
id: CoID<T>,
onProgress?: (progress: number) => void
): Promise<T> {...}
): Promise<"unavailable" | T> {...}
}
```
@@ -2762,7 +2782,7 @@ class LocalNode {
subscribe<T extends CoValue>(
id: CoID<T>,
callback: (update: T) => void
callback: (update: "unavailable" | T) => void
): () => void {...}
}
@@ -2824,6 +2844,32 @@ class LocalNode {
<details>
<summary><b><code>.resolveAccountAgentAsync(id, expectation?)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class LocalNode {
resolveAccountAgentAsync(
id: AgentID | AccountID,
expectation?: string
): Promise<AgentID> {...}
}
```
TODO: document
### Parameters:
| name | description |
| ----: | ---- |
| `id` | TODO: document |
| `expectation?` | TODO: document |
</details>
<details>
<summary><b><code>.createGroup()</code></b> </summary>
@@ -4996,6 +5042,10 @@ class CoList<Item, Meta> {
----
## `MutableCoList`
@@ -5399,6 +5449,10 @@ The `Group` this `CoValue` belongs to (determining permissions)
----
## `CoStream`
@@ -8916,13 +8970,12 @@ TODO: document
### `CoValueCore`: Accessors
<details>
<summary><b><code>.sessions</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>.sessionLogs</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class CoValueCore {
get sessions(): Readonly<{
[key: SessionID]: SessionLog }> {...}
get sessionLogs(): Map<SessionID, SessionLog> {...}
}
```
@@ -8959,8 +9012,7 @@ class CoValueCore {
constructor(
header: CoValueHeader,
node: LocalNode,
internalInitSessions?: {
[key: SessionID]: SessionLog } = {}
internalInitSessions?: Map<SessionID, SessionLog> = ...
): CoValueCore {...}
}
@@ -8973,7 +9025,7 @@ TODO: document
| ----: | ---- |
| `header` | TODO: document |
| `node` | TODO: document |
| `internalInitSessions?` | TODO: document |
</details>
@@ -9023,6 +9075,8 @@ undefined</details>
<details>
<summary><b><code>.nextTransactionID()</code></b> <sub><sup>(undocumented)</sup></sub></summary>
@@ -9437,6 +9491,8 @@ TODO: document
undefined</details>
<br/>
### `CoValueCore`: Properties
@@ -9490,13 +9546,12 @@ TODO: document
<details>
<summary><b><code>._sessions</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>._sessionLogs</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class CoValueCore {
_sessions: {
[key: SessionID]: SessionLog }
_sessionLogs: Map<SessionID, SessionLog>
}
```
@@ -9555,6 +9610,70 @@ TODO: document
<details>
<summary><b><code>.currentlyAsyncApplyingTxDone</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class CoValueCore {
currentlyAsyncApplyingTxDone: Promise<void>
}
```
TODO: document
</details>
<details>
<summary><b><code>._cachedKnownState</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class CoValueCore {
_cachedKnownState: CoValueKnownState
}
```
TODO: document
</details>
<details>
<summary><b><code>._cachedDependentOn</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class CoValueCore {
_cachedDependentOn: `co_z${string}`[]
}
```
TODO: document
</details>
<details>
<summary><b><code>._cachedNewContentSinceEmpty</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class CoValueCore {
_cachedNewContentSinceEmpty: NewContentMessage[]
}
```
TODO: document
</details>
----
## `Media`
@@ -9857,6 +9976,22 @@ TODO: document
<details>
<summary><b><code>.priority</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
interface Peer {
priority: number
}
```
TODO: document
</details>
----
## `Value`
@@ -11645,6 +11780,26 @@ TODO: document
<details>
<summary><b><code>.mapDeferred</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class ResolvedCoList<L> {
mapDeferred: (mapper: (item: {
loaded: boolean,
id: L["_item"] extends CoID<CoValue> ? any[any] : never,
value((): ValueOrResolvedRef<L["_item"]>,
}, idx: number) => O) => O[]
}
```
TODO: document
</details>
<details>
<summary><b><code>.length</code></b> <sub><sup>from <code>Array</code></sup></sub> </summary>
@@ -12650,14 +12805,15 @@ TODO: document
### `AutoSubContext`: Methods
<details>
<summary><b><code>.autoSub(valueID, alsoRender)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>.autoSub(valueID, alsoRender, _path)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class AutoSubContext {
autoSub<T extends CoValue>(
valueID: CoID<T>,
alsoRender: CoID<CoValue>[]
alsoRender: CoID<CoValue>[],
_path: string
): undefined | Resolved<T> {...}
}
@@ -12670,20 +12826,22 @@ TODO: document
| ----: | ---- |
| `valueID` | TODO: document |
| `alsoRender` | TODO: document |
| `_path` | TODO: document |
</details>
<details>
<summary><b><code>.subscribeIfCoID(value, alsoRender)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>.subscribeIfCoID(value, alsoRender, path)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class AutoSubContext {
subscribeIfCoID<T extends undefined | JsonValue>(
value: T,
alsoRender: CoID<CoValue>[]
alsoRender: CoID<CoValue>[],
path: string
): T extends CoID<C> ? undefined | Resolved<C> : T {...}
}
@@ -12696,20 +12854,22 @@ TODO: document
| ----: | ---- |
| `value` | TODO: document |
| `alsoRender` | TODO: document |
| `path` | TODO: document |
</details>
<details>
<summary><b><code>.valueOrResolvedRefPropertyDescriptor(value, alsoRender)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>.valueOrResolvedRefPropertyDescriptor(value, alsoRender, path)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class AutoSubContext {
valueOrResolvedRefPropertyDescriptor<T extends undefined | JsonValue>(
value: T,
alsoRender: CoID<CoValue>[]
alsoRender: CoID<CoValue>[],
path: string
): T extends CoID<C>
? {
get((): undefined | Resolved<C>,
@@ -12728,6 +12888,7 @@ TODO: document
| ----: | ---- |
| `value` | TODO: document |
| `alsoRender` | TODO: document |
| `path` | TODO: document |
</details>
@@ -14483,6 +14644,26 @@ TODO: document
<details>
<summary><b><code>.mapDeferred</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class ResolvedCoList<L> {
mapDeferred: (mapper: (item: {
loaded: boolean,
id: L["_item"] extends CoID<CoValue> ? any[any] : never,
value((): ValueOrResolvedRef<L["_item"]>,
}, idx: number) => O) => O[]
}
```
TODO: document
</details>
<details>
<summary><b><code>.length</code></b> <sub><sup>from <code>Array</code></sup></sub> </summary>
@@ -15488,14 +15669,15 @@ TODO: document
### `AutoSubContext`: Methods
<details>
<summary><b><code>.autoSub(valueID, alsoRender)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>.autoSub(valueID, alsoRender, _path)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class AutoSubContext {
autoSub<T extends CoValue>(
valueID: CoID<T>,
alsoRender: CoID<CoValue>[]
alsoRender: CoID<CoValue>[],
_path: string
): undefined | Resolved<T> {...}
}
@@ -15508,20 +15690,22 @@ TODO: document
| ----: | ---- |
| `valueID` | TODO: document |
| `alsoRender` | TODO: document |
| `_path` | TODO: document |
</details>
<details>
<summary><b><code>.subscribeIfCoID(value, alsoRender)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>.subscribeIfCoID(value, alsoRender, path)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class AutoSubContext {
subscribeIfCoID<T extends undefined | JsonValue>(
value: T,
alsoRender: CoID<CoValue>[]
alsoRender: CoID<CoValue>[],
path: string
): T extends CoID<C> ? undefined | Resolved<C> : T {...}
}
@@ -15534,20 +15718,22 @@ TODO: document
| ----: | ---- |
| `value` | TODO: document |
| `alsoRender` | TODO: document |
| `path` | TODO: document |
</details>
<details>
<summary><b><code>.valueOrResolvedRefPropertyDescriptor(value, alsoRender)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
<summary><b><code>.valueOrResolvedRefPropertyDescriptor(value, alsoRender, path)</code></b> <sub><sup>(undocumented)</sup></sub></summary>
```typescript
class AutoSubContext {
valueOrResolvedRefPropertyDescriptor<T extends undefined | JsonValue>(
value: T,
alsoRender: CoID<CoValue>[]
alsoRender: CoID<CoValue>[],
path: string
): T extends CoID<C>
? {
get((): undefined | Resolved<C>,
@@ -15566,6 +15752,7 @@ TODO: document
| ----: | ---- |
| `value` | TODO: document |
| `alsoRender` | TODO: document |
| `path` | TODO: document |
</details>

View File

@@ -0,0 +1,9 @@
# jazz-example-chat
## 0.0.46
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -3,7 +3,7 @@ job "chat$BRANCH_SUFFIX" {
datacenters = ["*"]
group "static" {
count = 8
count = 4
network {
port "http" {

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-chat",
"private": true,
"version": "0.0.44",
"version": "0.0.46",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,9 +16,9 @@
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"hashroute": "^0.1.2",
"jazz-react": "^0.4.14",
"jazz-react-auth-local": "^0.4.14",
"hash-slash": "^0.1.3",
"jazz-react": "^0.5.0",
"jazz-react-auth-local": "^0.4.16",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",

View File

@@ -1,6 +1,6 @@
import { WithJazz, useJazz, DemoAuth } from 'jazz-react';
import ReactDOM from 'react-dom/client';
import { HashRoute } from 'hashroute';
import { HashRoute } from 'hash-slash';
import { ChatWindow } from './chatWindow.tsx';
import { Chat } from './dataModel.ts';
@@ -18,7 +18,7 @@ function App() {
{HashRoute({
'/': <Home />,
'/chat/:id': (id) => <ChatWindow chatId={id as Chat['id']} />,
})}
}, { reportToParentFrame: true })}
</div>
}

View File

@@ -0,0 +1,9 @@
# jazz-example-file-drop
## 0.0.63
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-file-drop",
"private": true,
"version": "0.0.61",
"version": "0.0.63",
"type": "module",
"scripts": {
"dev": "vite --port 6610",
@@ -16,8 +16,8 @@
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "^0.4.14",
"jazz-react-auth-local": "^0.4.14",
"jazz-react": "^0.5.0",
"jazz-react-auth-local": "^0.4.16",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",

View File

@@ -0,0 +1,10 @@
# jazz-example-pets
## 0.0.63
### Patch Changes
- Updated dependencies
- jazz-browser-media-images@0.5.0
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.61",
"version": "0.0.63",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,9 +16,9 @@
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-browser-media-images": "^0.4.14",
"jazz-react": "^0.4.14",
"jazz-react-auth-local": "^0.4.14",
"jazz-browser-media-images": "^0.5.0",
"jazz-react": "^0.5.0",
"jazz-react-auth-local": "^0.4.16",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",

View File

@@ -0,0 +1,9 @@
# jazz-example-todo
## 0.0.63
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.61",
"version": "0.0.63",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,8 +16,8 @@
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "^0.4.14",
"jazz-react-auth-local": "^0.4.14",
"jazz-react": "^0.5.0",
"jazz-react-auth-local": "^0.4.16",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",

24
examples/twit-stresstest/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,13 @@
# twit-stresstest
## 0.1.0
### Minor Changes
- Adding a lot of performance improvements to cojson, add a stresstest for the twit example and make that run smoother in a lot of ways.
### Patch Changes
- Updated dependencies
- cojson-transport-nodejs-ws@0.5.0
- cojson@0.5.0

View File

@@ -0,0 +1,120 @@
import { LocalNode, cojsonReady, ControlledAccount, AccountID } from "cojson";
import {
ALL_TWEETS_LIST_ID,
LikeStream,
ListOfTwits,
ReplyStream,
Twit,
TwitAccountRoot,
TwitProfile,
migration,
} from "../twit/src/1_dataModel";
import {
websocketReadableStream,
websocketWritableStream,
} from "cojson-transport-nodejs-ws";
import { WebSocket } from "ws";
import { autoSub } from "jazz-autosub";
await cojsonReady;
async function runner() {
const { node } = await LocalNode.withNewlyCreatedAccount({
name: "Bot_" + Math.random().toString(36).slice(2),
migration,
});
const ws = new WebSocket("ws://localhost:4200");
node.syncManager.addPeer({
id: "globalMesh",
role: "server",
incoming: websocketReadableStream(ws),
outgoing: websocketWritableStream(ws),
});
console.log(
"profile",
node.expectProfileLoaded(node.account.id as AccountID).id
);
await new Promise((resolve) => setTimeout(resolve, 10_000));
const loadedAllTwits = await node.load(ALL_TWEETS_LIST_ID);
if (loadedAllTwits === "unavailable") {
throw new Error("allTweets is unavailable");
}
let allTwits = loadedAllTwits;
let startedPosting = false;
autoSub(
(node.account as ControlledAccount<TwitProfile, TwitAccountRoot>).id,
node,
async (me) => {
if (
!me?.root?.peopleWhoCanSeeMyContent ||
!me.root.peopleWhoCanInteractWithMe
)
return;
if (startedPosting) return;
startedPosting = true;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 120000)
// setTimeout(resolve, Math.random() * 5000)
);
const audience = me.root.peopleWhoCanSeeMyContent;
const interactors = me.root.peopleWhoCanInteractWithMe;
if (!audience || !interactors) return;
console.log("Posting twit ", i);
const twit = audience.createMap<Twit>({
text: "Hello world " + i,
likes: interactors.createStream<LikeStream>().id,
replies: interactors.createStream<ReplyStream>().id,
});
me.profile?.twits?.prepend(twit?.id as Twit["id"]);
allTwits = allTwits?.prepend(twit.id);
}
}
);
let blackHole = 0;
let lastUpdate = Date.now()
autoSub(ALL_TWEETS_LIST_ID, node, (allTwits) => {
if (Date.now() - lastUpdate < 33) return;
lastUpdate = Date.now();
// console.log("All twits updated", new Date());
// console.log(allTwits
// ?.slice(0, 20)
// .map(
// (twit) =>
// twit?.text +
// "/" +
// twit?.meta.edits.text?.by?.profile?.name
// )
// .length, allTwits?.length);
blackHole +=
allTwits
?.slice(0, 20)
.map(
(twit) =>
twit?.text +
"/" +
twit?.meta.edits.text?.by?.profile?.name
).length || 0;
});
}
for (let i = 0; i < 50; i++) {
runner();
}

View File

@@ -0,0 +1,32 @@
import { ControlledAccount, LocalNode, cojsonReady } from "cojson";
import {
ListOfTwits,
migration,
} from "../twit/src/1_dataModel";
import {
websocketReadableStream,
websocketWritableStream,
} from "cojson-transport-nodejs-ws";
import { WebSocket } from "ws";
await cojsonReady;
const { node } = await LocalNode.withNewlyCreatedAccount({
name: "Bot_" + Math.random().toString(36).slice(2),
migration,
});
const ws = new WebSocket("ws://localhost:4200");
const allTweetsGroup = (node.account as ControlledAccount).createGroup();
allTweetsGroup.addMember('everyone', 'writer');
const allTweets = allTweetsGroup.createList<ListOfTwits>();
console.log("allTweets", allTweets.id);
node.syncManager.addPeer({
id: "globalMesh",
role: "server",
incoming: websocketReadableStream(ws),
outgoing: websocketWritableStream(ws),
});

View File

@@ -0,0 +1,15 @@
{
"name": "twit-stresstest",
"version": "0.1.0",
"main": "index.js",
"license": "MIT",
"private": true,
"dependencies": {
"cojson": "^0.5.0",
"cojson-transport-nodejs-ws": "^0.5.0"
},
"scripts": {
"stress4": "npx concurrently \"bun --inspect index.ts\" \"bun index.ts\" \"bun index.ts\" \"bun index.ts\"",
"stress8": "npx concurrently \"bun --inspect index.ts\" \"bun index.ts\" \"bun index.ts\" \"bun index.ts\" \"bun index.ts\" \"bun index.ts\" \"bun index.ts\" \"bun index.ts\""
}
}

View File

@@ -0,0 +1,14 @@
# jazz-example-twit
## 0.1.0
### Minor Changes
- Adding a lot of performance improvements to cojson, add a stresstest for the twit example and make that run smoother in a lot of ways.
### Patch Changes
- Updated dependencies
- jazz-browser-media-images@0.5.0
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-twit",
"private": true,
"version": "0.0.61",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,13 +18,14 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"javascript-time-ago": "^2.5.9",
"jazz-browser-media-images": "^0.4.14",
"jazz-react": "^0.4.14",
"jazz-react-auth-local": "^0.4.14",
"jazz-browser-media-images": "^0.5.0",
"jazz-react": "^0.5.0",
"jazz-react-auth-local": "^0.4.16",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-intersection-observer": "^9.5.2",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-time-ago": "^7.2.1",

View File

@@ -29,36 +29,30 @@ export type TwitProfile = Profile<
>;
export type TwitAccountRoot = CoMap<{
peopleWhoCanSeeMyTwits: Group['id'];
peopleWhoCanSeeMyFollows: Group['id'];
peopleWhoCanFollowMe: Group['id'];
peopleWhoCanSeeMyContent: Group['id'];
peopleWhoCanInteractWithMe: Group['id'];
}>;
export const ALL_TWEETS_LIST_ID = "co_zAaZZBUGKhkxLuk3Wq1r9q16FSN" as ListOfTwits['id'];
export const migration: AccountMigration<TwitProfile, TwitAccountRoot> = (account, profile) => {
if (!account.get('root')) {
const peopleWhoCanSeeMyTwits = account.createGroup();
const peopleWhoCanSeeMyFollows = account.createGroup();
const peopleWhoCanFollowMe = account.createGroup();
const peopleWhoCanSeeMyContent = account.createGroup();
const peopleWhoCanInteractWithMe = account.createGroup();
peopleWhoCanFollowMe?.addMember(EVERYONE, 'writer');
peopleWhoCanSeeMyTwits?.addMember(EVERYONE, 'reader');
peopleWhoCanSeeMyFollows?.addMember(EVERYONE, 'reader');
peopleWhoCanSeeMyContent?.addMember(EVERYONE, 'reader');
peopleWhoCanInteractWithMe?.addMember(EVERYONE, 'writer');
const root = account.createMap<TwitAccountRoot>({
peopleWhoCanSeeMyTwits: peopleWhoCanSeeMyTwits.id,
peopleWhoCanSeeMyFollows: peopleWhoCanSeeMyFollows.id,
peopleWhoCanFollowMe: peopleWhoCanFollowMe.id,
peopleWhoCanSeeMyContent: peopleWhoCanSeeMyContent.id,
peopleWhoCanInteractWithMe: peopleWhoCanInteractWithMe.id
});
account.set('root', root.id);
profile.set('twits', peopleWhoCanSeeMyTwits.createList<ListOfTwits>().id, 'trusting');
profile.set('following', peopleWhoCanSeeMyFollows.createList<ListOfProfiles>().id, 'trusting');
profile.set('followers', peopleWhoCanFollowMe.createStream<StreamOfFollowers>().id, 'trusting');
profile.set('twits', peopleWhoCanSeeMyContent.createList<ListOfTwits>().id, 'trusting');
profile.set('following', peopleWhoCanSeeMyContent.createList<ListOfProfiles>().id, 'trusting');
profile.set('followers', peopleWhoCanInteractWithMe.createStream<StreamOfFollowers>().id, 'trusting');
console.log('MIGRATION SUCCESSFUL!');
}
};

View File

@@ -1,4 +1,3 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider, createHashRouter } from 'react-router-dom';
import './index.css';
@@ -11,7 +10,7 @@ import { Button, ThemeProvider, TitleAndLogo } from './basicComponents/index.tsx
import { PrettyAuthUI } from './components/Auth.tsx';
import { migration } from './1_dataModel.ts';
import { ChronoFeed } from './3_ChronoFeed.tsx';
import { AllTwitsFeed, FollowingFeed } from './3_ChronoFeed.tsx';
import { ProfilePage } from './5_ProfilePage.tsx';
const appName = 'Jazz Twit Example';
@@ -22,7 +21,7 @@ const auth = LocalAuth({
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
// <React.StrictMode>
<ThemeProvider>
<TitleAndLogo name={appName} />
<div className="flex flex-col h-full items-stretch justify-start gap-10 pt-10 pb-10 px-5 w-full max-w-xl mx-auto">
@@ -31,7 +30,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</WithJazz>
</div>
</ThemeProvider>
</React.StrictMode>
// </React.StrictMode>
);
function App() {
@@ -40,7 +39,11 @@ function App() {
const router = createHashRouter([
{
path: '/',
element: <ChronoFeed />
element: <AllTwitsFeed />
},
{
path: '/following',
element: <FollowingFeed />
},
{
path: '/:profileId',
@@ -58,6 +61,9 @@ function App() {
<Button onClick={() => router.navigate('/')} variant="link" className="-ml-3">
Home
</Button>
<Button onClick={() => router.navigate('/following')} variant="link" className="-ml-3">
Following
</Button>
<Button onClick={() => router.navigate('/me')} variant="link" className="ml-auto">
My Profile
</Button>

View File

@@ -1,11 +1,38 @@
import { useMemo } from 'react';
import { useJazz } from 'jazz-react';
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { useEffect, useMemo, useState } from 'react';
import { useAutoSub, useJazz } from 'jazz-react';
import { ALL_TWEETS_LIST_ID, TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
import { TwitComponent } from './4_TwitComponent.tsx';
import { MainH1 } from './basicComponents/index.tsx';
import { LazyLoadRow, MainH1 } from './basicComponents/index.tsx';
export function ChronoFeed() {
export function AllTwitsFeed() {
const allTwits = useAutoSub(ALL_TWEETS_LIST_ID);
const [animate, setAnimate] = useState(false);
useEffect(() => {
if (!animate && allTwits?.length) {
setTimeout(() => setAnimate(true), 1000);
}
}, [allTwits, animate])
return (
<div className="flex flex-col items-stretch">
<CreateTwitForm className="mb-10" />
<MainH1>
All {allTwits?.length} Twits{' '}
<span className="text-sm">
{allTwits?.mapDeferred(({ loaded }) => loaded).filter(l => l).length || 0} loaded
</span>
</MainH1>
{allTwits?.mapDeferred(twit => (
<LazyLoadRow key={twit.id} animate={animate}>{() => <TwitComponent twit={twit.value()} />}</LazyLoadRow>
))}
</div>
);
}
export function FollowingFeed() {
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
const myTwits = me.profile?.twits;

View File

@@ -13,6 +13,7 @@ import {
TwitHeader,
TwitBody,
TwitText,
Placeholder,
} from './basicComponents/index.tsx';
import { Twit, TwitProfile } from './1_dataModel.ts';
import { BrowserImage } from 'jazz-browser-media-images';
@@ -32,49 +33,53 @@ export function TwitComponent({
const posterProfile = twit?.meta.edits.text?.by?.profile as Resolved<TwitProfile> | undefined;
const isTopLevel = !twit?.isReplyTo || alreadyInReplies;
const loadReactions = !!posterProfile?.name;
return (
<TwitWithRepliesContainer isTopLevel={isTopLevel}>
<TwitContainer>
<ProfilePicImg
src={posterProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
linkTo={'/' + posterProfile?.id}
linkTo={posterProfile?.id && ('/' + posterProfile.id)}
initial={posterProfile?.name[0]}
size={twit?.isReplyTo && "sm"}
/>
<TwitBody>
<TwitHeader>
<Link to={'/' + posterProfile?.id} className="font-bold hover:underline">
{posterProfile?.name}
</Link>
{posterProfile ? <Link to={'/' + posterProfile.id} className="font-bold hover:underline">
{posterProfile.name}
</Link> : <Placeholder/>}
{/* <div className='ml-2 text-xs text-neutral-200 dark:text-neutral-800'>{twit?.id}</div> */}
<SubtleRelativeTimeAgo dateTime={twit?.meta.edits.text?.at} />
</TwitHeader>
<TwitText style={posterProfile?.twitStyle}>
{/* This is where the tweet text goes */}
{twit?.text}
{twit?.text || <Placeholder/>}
</TwitText>
{twit?.images && (
<TwitImgGallery>
{twit.images.map(image => (
<TwitImg src={image?.as(BrowserImage)?.highestResSrcOrPlaceholder} key={image?.id} />
{twit.images.map((image, idx) => (
<TwitImg src={image?.as(BrowserImage)?.highestResSrcOrPlaceholder} key={image?.id || idx} />
))}
</TwitImgGallery>
)}
<ReactionsContainer>
<ButtonWithCount
active={twit?.likes?.me?.last === '❤️'}
active={loadReactions && (twit?.likes?.me?.last === '❤️')}
onClick={() => twit?.likes?.push(twit?.likes?.me?.last ? null : '❤️')}
count={twit?.likes?.perAccount.filter(([, liked]) => liked.last === '❤️').length || 0}
count={loadReactions && (twit?.likes?.perAccount.filter(([, liked]) => liked.last === '❤️').length) || 0}
icon={<HeartIcon size="18" />}
activeIcon={<HeartIcon color="red" size="18" fill="red" />}
disabled={!loadReactions || !twit?.likes?.perAccount}
/>
<ButtonWithCount
onClick={() => setShowReplyForm(s => !s)}
count={twit?.replies?.perAccount.flatMap(([, byAccount]) => byAccount.all).length || 0}
count={loadReactions && (twit?.replies?.perAccount.flatMap(([, byAccount]) => byAccount.all).length) || 0}
icon={<MessagesSquareIcon size="18" />}
disabled={!loadReactions || !twit?.replies?.perAccount}
/>
</ReactionsContainer>
</TwitBody>
@@ -89,7 +94,7 @@ export function TwitComponent({
/>
)}
{twit?.replies?.perAccount
{loadReactions && twit?.replies?.perAccount
.flatMap(([, byAccount]) => byAccount.all)
.sort((a, b) => b.at.getTime() - a.at.getTime())
.map(replyEntry => (

View File

@@ -64,8 +64,8 @@ export function ProfilePage() {
{isMe && (
<ChooseProfilePicInput
onChange={(file: File) =>
me.root?.peopleWhoCanSeeMyTwits &&
createImage(file, me.root.peopleWhoCanSeeMyTwits, 256).then(image => {
me.root?.peopleWhoCanSeeMyContent &&
createImage(file, me.root.peopleWhoCanSeeMyContent, 256).then(image => {
me.profile?.set({ avatar: image.id }, 'trusting');
})
}

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect } from 'react';
import { Resolved, useJazz } from 'jazz-react';
import { Resolved, useJazz, useSyncedValue } from 'jazz-react';
import { AddTwitPicsInput, TwitImg, TwitTextInput } from './basicComponents/index.tsx';
import { LikeStream, ListOfImages, ReplyStream, Twit, TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { ALL_TWEETS_LIST_ID, LikeStream, ListOfImages, ReplyStream, Twit, TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { createImage } from 'jazz-browser-media-images';
export function CreateTwitForm(
@@ -12,12 +12,13 @@ export function CreateTwitForm(
} = {}
) {
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
const allTwits = useSyncedValue(ALL_TWEETS_LIST_ID);
const [pics, setPics] = React.useState<File[]>([]);
const onSubmit = useCallback(
(twitText: string) => {
const audience = me.root?.peopleWhoCanSeeMyTwits;
const audience = me.root?.peopleWhoCanSeeMyContent;
const interactors = me.root?.peopleWhoCanInteractWithMe;
if (!audience || !interactors) return;
@@ -29,19 +30,25 @@ export function CreateTwitForm(
me.profile?.twits?.prepend(twit?.id as Twit['id']);
if (!props.inReplyTo) {
allTwits?.prepend(twit.id);
}
if (props.inReplyTo) {
props.inReplyTo.replies?.push(twit.id);
twit.set({ isReplyTo: props.inReplyTo.id });
}
Promise.all(pics.map(pic => createImage(pic, twit.group, 1024))).then(createdPics => {
twit.set({ images: audience.createList<ListOfImages>(createdPics.map(pic => pic.id)).id });
});
if (pics.length > 0) {
Promise.all(pics.map(pic => createImage(pic, twit.group, 1024))).then(createdPics => {
twit.set({ images: audience.createList<ListOfImages>(createdPics.map(pic => pic.id)).id });
});
}
setPics([]);
props.onSubmit?.();
},
[me.profile?.twits, me.root?.peopleWhoCanSeeMyTwits, me.root?.peopleWhoCanInteractWithMe, props, pics]
[me.profile?.twits, me.root?.peopleWhoCanSeeMyContent, me.root?.peopleWhoCanInteractWithMe, props, pics, allTwits]
);
const [picPreviews, setPicPreviews] = React.useState<string[]>([]);

View File

@@ -25,7 +25,7 @@ export function FollowButton({ profile }: { profile?: Resolved<TwitProfile> }) {
return profile?.id === me.profile?.id ? (
<div className="ml-auto text-neutral-500">That's you!</div>
) : (
<Button onClick={followOrUnfollow} className="ml-auto" variant={alreadyFollowing ? 'ghost' : 'default'}>
<Button onClick={followOrUnfollow} className="ml-auto" disabled={!profile?.followers || !me.profile?.following} variant={alreadyFollowing ? 'ghost' : 'default'}>
{alreadyFollowing ? 'Unfollow' : theyFollowMe ? 'Follow Back' : 'Follow'}
</Button>
);

View File

@@ -17,6 +17,8 @@ export { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import TimeAgo from 'javascript-time-ago';
import en from 'javascript-time-ago/locale/en.json';
import { useInView } from 'react-intersection-observer';
import { useEffect, useState } from 'react';
TimeAgo.addDefaultLocale(en);
export function BioInput(props: { value?: string; onChange: (value: string) => void }) {
@@ -53,7 +55,7 @@ export function ChooseProfilePicInput(props: { onChange: (file: File) => void })
Choose Pic
<Input
type="file"
accept="image/*"
accept="image/jpg,image/jpeg,image/png,image/gif"
onChange={e => {
e.target.files?.[0] && props.onChange(e.target.files[0]);
e.target.value = '';
@@ -72,14 +74,17 @@ export function ProfilePicImg(props: { src?: string; size?: 'sm' | 'xxl'; linkTo
<img
src={props.src}
className={
'bg-neutral-200 rounded-full mr-2 object-cover shrink-0' +
'bg-neutral-200 dark:bg-neutral-800 rounded-full mr-2 object-cover shrink-0' +
(props.size === 'sm' ? ' w-8 h-8' : props.size === 'xxl' ? ' w-20 h-20' : ' w-10 h-10')
}
/>
) : (
<div
className={
'bg-neutral-200 rounded-full mr-2 object-cover shrink-0 flex items-center justify-center text-neutral-700 ' +
'rounded-full mr-2 object-cover shrink-0 flex items-center justify-center text-neutral-700 dark:text-neutral-300 ' +
(props.initial
? 'bg-neutral-200 dark:bg-neutral-800 '
: 'animate-pulse bg-neutral-100 dark:bg-neutral-900 ') +
(props.size === 'sm'
? ' w-8 h-8 text-[1.5rem]'
: props.size === 'xxl'
@@ -97,13 +102,17 @@ export function ProfilePicImg(props: { src?: string; size?: 'sm' | 'xxl'; linkTo
export function SubtleRelativeTimeAgo(props: { dateTime?: Date }) {
return (
<div className="ml-auto text-neutral-300 text-xs whitespace-nowrap">
<ReactTimeAgo date={props.dateTime || 0} />
{props.dateTime ? <ReactTimeAgo date={props.dateTime} timeStyle="round"/> : <Placeholder />}
</div>
);
}
export function TwitImg(props: { src?: string }) {
return <img src={props.src} className="h-40 rounded object-cover" />;
return props.src ? (
<img src={props.src} className="h-40 rounded object-cover" />
) : (
<div className="h-40 w-30 rounded bg-neutral-100" />
);
}
export function ReactionsContainer(props: { children: React.ReactNode }) {
@@ -120,18 +129,20 @@ export function ButtonWithCount(props: {
active?: boolean;
icon: React.ReactNode;
activeIcon?: React.ReactNode;
disabled?: boolean;
}) {
return (
<div className="flex items-center">
<Button
className="w-10 h-7 p-1 mr-1"
className={"w-10 h-7 p-1 mr-1 " + (props.disabled ? "text-neutral-200 dark:text-neutral-800" : "")}
variant={props.active ? 'secondary' : 'outline'}
onClick={props.onClick}
size="icon"
disabled={props.disabled}
>
{props.active ? props.activeIcon : props.icon}
</Button>{' '}
<span className="tabular-nums">{props.count}</span>
<span className={"tabular-nums " + (props.disabled ? "text-neutral-200 dark:text-neutral-800" : "")}>{props.count}</span>
</div>
);
}
@@ -174,7 +185,7 @@ export function AddTwitPicsInput(props: { onChange: (files: File[]) => void }) {
props.onChange(Array.from(e.target.files || []));
}}
className="hidden"
accept="image/*"
accept="image/jpg,image/jpeg,image/png,image/gif"
multiple
/>
</label>
@@ -203,7 +214,7 @@ export function TwitHeader(props: { children: React.ReactNode }) {
}
export function TwitImgGallery(props: { children: React.ReactNode }) {
return <div className="flex gap-2 mt-2 max-w-full overflow-auto">{props.children}</div>;
return <div className="flex gap-2 mt-2 max-w-full overflow-auto">{props.children || <TwitImg />}</div>;
}
export function TwitText(props: { children: React.ReactNode; style?: React.CSSProperties }) {
@@ -215,14 +226,40 @@ export function QuoteContainer(props: { children: React.ReactNode }) {
}
export function MainH1(props: { children: React.ReactNode }) {
return <h1 className="text-2xl mb-4">{props.children}</h1>;
return <h1 className="text-2xl mb-4 sticky top-0 p-4 -mx-4 bg-white dark:bg-black z-20">{props.children}</h1>;
}
export function SmallInlineButton(props: { children: React.ReactNode } & ButtonProps) {
const {children, ...rest} = props
const { children, ...rest } = props;
return (
<Button variant={'ghost'} className="h-6 px-1 -mx-1" {...rest}>
{children}
</Button>
);
}
export function Placeholder() {
return (
<span className="bg-neutral-100 dark:bg-neutral-900 rounded animate-pulse text-transparent">
Loading, loading...
</span>
);
}
export function LazyLoadRow(props: { children: () => React.ReactNode, animate?: boolean }) {
const { ref, inView } = useInView({
// triggerOnce: true,
delay: 100,
});
const [height, setHeight] = useState(props.animate ? "0": "500px");
useEffect(() => {
setHeight("500px")
},[])
return (
<div ref={ref} style={{
maxHeight: height,
overflowX: "scroll",
transition: 'max-height 1s ease-in-out',
}}>{inView ? props.children() : <div className="mb-[1px] h-28 bg-neutral-50 dark:bg-neutral-950" />}</div>
);
}

View File

@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

View File

@@ -1,35 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,3 +0,0 @@
{
"tabWidth": 2
}

View File

@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -1,49 +0,0 @@
"use client";
import { useLayoutEffect, useState, useRef, IframeHTMLAttributes } from "react";
export function ResponsiveIframe(
props: IframeHTMLAttributes<HTMLIFrameElement>
) {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [url, setUrl] = useState<string | undefined>(props.src);
useLayoutEffect(() => {
const listener = (e: MessageEvent) => {
console.log(e);
if (e.data.type === "navigate" && props.src?.startsWith(e.origin)) {
setUrl(e.data.url);
}
};
window.addEventListener("message", listener);
return () => {
window.removeEventListener("message", listener);
};
}, [props.src]);
useLayoutEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
if (!containerRef.current) return;
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
});
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, [containerRef]);
return (
<div className={"w-full h-full flex flex-col " + props.className} >
<input className="text-xs p-2" value={url} readOnly/>
<div className="flex-grow" ref={containerRef}>
<iframe {...props} className="" {...dimensions} allowFullScreen/>
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
export function Slogan(props: { children: React.ReactNode, small?: boolean }) {
return (
<div className={"leading-snug mb-5 max-w-3xl text-neutral-700 dark:text-neutral-200 " + (props.small ? "text-lg mt-2" : "text-2xl mt-5")}>
{props.children}
</div>
);
}
export function Grid(props: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-10 items-stretch">
{props.children}
</div>
);
}
export function GridItem(props: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={
(props.className || "") +
" [&>.nextra-code-block]:h-full [&>.nextra-code-block>pre]:h-full [&>.nextra-code-block>pre]:mb-0"
}
>
{props.children}
</div>
);
}
export function GridCard(props: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={
"border border-stone-200 dark:border-stone-500 rounded-xl p-4 [&>h4]:mt-0 [&>h3]:mt-0 " +
props.className
}
>
{props.children}
</div>
);
}
export function GoogleLogo() {
return <svg className="w-3 h-3 inline align-baseline" viewBox="0 0 950 950" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M915.2 448l-4.2-17.8H524V594h231.2c-24 114-135.4 174-226.4 174-66.2 0-136-27.8-182.2-72.6-47.4-46-77.6-113.8-77.6-183.6 0-69 31-138 76.2-183.4 45-45.2 113.2-70.8 181-70.8 77.6 0 133.2 41.2 154 60l116.4-115.8c-34.2-30-128-105.6-274.2-105.6-112.8 0-221 43.2-300 122C144.4 295.8 104 408 104 512s38.2 210.8 113.8 289c80.8 83.4 195.2 127 313 127 107.2 0 208.8-42 281.2-118.2 71.2-75 108-178.8 108-287.6 0-45.8-4.6-73-4.8-74.2z" fill="currentColor" /></svg>
}

View File

@@ -1,9 +0,0 @@
const withNextra = require('nextra')({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
mdxOptions: {
}
})
module.exports = withNextra()

View File

@@ -1,29 +0,0 @@
{
"name": "homepage-jazz",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^13.5.3",
"nextra": "^2.13.1",
"nextra-theme-docs": "^2.13.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"autoprefixer": "latest",
"eslint": "latest",
"eslint-config-next": "latest",
"postcss": "latest",
"tailwindcss": "latest",
"typescript": "latest"
}
}

View File

@@ -1,15 +0,0 @@
import './globals.css'
import { Manrope } from 'next/font/google'
import { Inter } from 'next/font/google'
import localFont from 'next/font/local'
// If loading a variable font, you don't need to specify the font weight
const manrope = Manrope({ subsets: ['latin'], variable: '--font-manrope', })
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', })
const pragmata = localFont({src: "../fonts/PragmataProR_0829.woff2", subsets: ['latin'], variable: '--font-pragmata'})
// This default export is required in a new `pages/_app.js` file.
export default function MyApp({ Component, pageProps }) {
return <div className={manrope.variable + " " + pragmata.variable + " " + inter.className + " font-[450]"}><Component {...pageProps} /></div>
}

View File

@@ -1,41 +0,0 @@
{
"index": {
"title": "Introduction",
"theme": {
"typesetting": "article",
"layout": "full"
},
"type": "page"
},
"examples": {
"title": "Example Gallery",
"theme": {
"typesetting": "article",
"layout": "full"
},
"type": "page"
},
"mesh": {
"title": "Global Mesh & Pricing",
"theme": {
"typesetting": "article",
"layout": "full"
},
"type": "page"
},
"guides": {
"title": "Guides",
"type": "page",
"theme": {
"breadcrumb": true,
"footer": true,
"sidebar": true,
"toc": true,
"pagination": true
}
},
"docs": {
"title": "API Docs",
"type": "page"
}
}

View File

@@ -1 +0,0 @@
# API docs

View File

@@ -1 +0,0 @@
# Something

View File

@@ -1 +0,0 @@
# Something else

View File

@@ -1,162 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body article.nextra-content h1 {
font-family: var(--font-manrope);
}
body article.nx-w-full h1 {
font-family: var(--font-manrope);
text-align: left;
@apply tracking-tight;
@apply text-6xl;
@apply font-medium;
}
body article.nx-w-full h1:first-of-type {
@apply mt-20;
}
body article.nx-w-full h2 {
font-family: var(--font-manrope);
text-align: left;
@apply tracking-tight;
@apply text-3xl;
@apply font-[600];
}
body article.nx-w-full p {
@apply max-w-3xl;
}
body pre code.nx-text-\[\.9em\] {
font-size: 0.8rem;
line-height: 1.35;
}
[style="color:var(--shiki-token-keyword)"]+[style="color:var(--shiki-token-string-expression)"]:after {
content: "⋯";
text-indent: 0;
display: block;
line-height: initial;
letter-spacing: normal;
outline: 1px solid var(--shiki-token-string-expression);
border-radius: 0.25rem;
margin-left: 0.1rem;
margin-right: 0.1rem;
}
[style="color:var(--shiki-token-keyword)"]+[style="color:var(--shiki-token-string-expression)"] {
position: relative;
opacity: 1;
display: inline-block;
text-indent: -9999px;
line-height: 0;
opacity: 0.5;
transition: opacity 0.2s;
}
[style="color:var(--shiki-token-keyword)"]+[style="color:var(--shiki-token-string-expression)"]:hover {
text-indent: 0;
line-height: initial;
opacity: 1;
}
[style="color:var(--shiki-token-keyword)"]+[style="color:var(--shiki-token-string-expression)"]:hover:after {
display: none;
}
body .nextra-card svg {
color: black;
opacity: 0.2;
transition: opacity 0.2s ease;
}
body .nextra-card:hover svg {
color: black;
opacity: 0.8;
}
.dark body .nextra-card svg {
color: white;
opacity: 0.4;
}
.dark body .nextra-card:hover svg {
color: white;
opacity: 0.8;
}
/* @media screen and (min-width: 80rem) {
body article.nx-w-full {
@apply -mx-32;
}
} */
:root {
--nextra-primary-hue: 30deg;
--nextra-primary-saturation: 15%;
}
/* .nextra-nav-container nav {
justify-content: flex-start;
max-width: 70rem;
} */
.nextra-nav-container nav :first-child {
margin-right: 0;
}
.nextra-nav-container nav a:not(:first-child) {
padding-left: 1rem;
padding-right: 1rem;
}
.nextra-search+* {
margin-left: auto;
}
body code, body kbd, body samp, body pre {
font-family: var(--font-pragmata);
}
body code[data-line-numbers]>.line {
padding-left: 0.25rem;
}
body code[data-line-numbers]>.line:before {
--tw-text-opacity: 0.3;
min-width: 1.5rem;
font-size: 0.7rem;
padding-right: 0.5rem;
position: relative;
top: 0.07rem;
}
body {
--shiki-color-text: #606060;
--shiki-color-background: transparent;
--shiki-token-constant: #00a5a5;
--shiki-token-string: #1aa245;
--shiki-token-comment: #aaa;
--shiki-token-keyword: #7b8bff;
--shiki-token-parameter: #ff9800;
--shiki-token-function: #445dd7;
--shiki-token-string-expression: #1aa245;
--shiki-token-punctuation: #969696;
--shiki-token-link: #1aa245;
}
.dark body {
--shiki-color-text: #d1d1d1;
--shiki-token-constant: #2DC9C9;
--shiki-token-string: #ffab70;
--shiki-token-comment: #6b737c;
--shiki-token-keyword: #7b8bff;
--shiki-token-parameter: #ff9800;
--shiki-token-function: #9BABFF;
--shiki-token-string-expression: #42BB69;
--shiki-token-punctuation: #bbb;
--shiki-token-link: #ffab70;
}

View File

@@ -1 +0,0 @@
# Guides

View File

@@ -1,3 +0,0 @@
{
"gettingStarted": "Getting Started"
}

View File

@@ -1 +0,0 @@
# Getting started

View File

@@ -1,237 +0,0 @@
import { Tabs, Cards, Card } from "nextra/components";
import { Slogan, Grid, GridItem, GridCard, GoogleLogo } from "../components";
import { ResponsiveIframe } from "../components/ResponsiveIframe";
import {
ArrowUpDownIcon,
UploadCloudIcon,
PlaneIcon,
MonitorSmartphoneIcon,
TextCursorIcon,
MousePointer2Icon,
GaugeIcon,
HandIcon
} from "lucide-react";
# Instant sync.
<Slogan>Go beyond request/response &mdash; ship modern apps with sync.</Slogan>
Jazz is an open-source toolkit for building apps with **sync** and **secure collaborative data.**
<h2 className="mt-24">Hard things are easy now.</h2>
Jazz takes what *backends* + *databases* + *CDNs* + *real-time infrastructure* do, generalizes the problem and solves it in a completely new way. (How? Keep reading.)
Because of that, with Jazz, you only build what makes your app *your app:*<br/>1. **Define your data model.** -> 2. **Add role-based permissions.** -> 3. **Build your UI.**
And you get **built-in capabilities** that took the &ldquo;big ones&rdquo; <small>(GDocs,&nbsp;Figma,&nbsp;Notion,&nbsp;Linear,&nbsp;&hellip;)</small> *years* to build:
<Cards>
<Card href="#" title="Cross-device sync" icon={<MonitorSmartphoneIcon />} />
<Card
href="#"
title="Real-time multiplayer"
icon={
<div className="w-6 h-6 flex flex-col">
<TextCursorIcon
size="10"
absoluteStrokeWidth
className="-scale-x-100 self-start -ml-1"
/>
<MousePointer2Icon size="15" absoluteStrokeWidth className="-mt-1 -mx-1.5 self-end"/>
<HandIcon size="15" absoluteStrokeWidth className="-mt-2 -mx-1.5 self-start"/>
</div>
}
/>
<Card href="#" title="Automatic granular data-fetching" icon={<ArrowUpDownIcon />} />
<Card href="#" title="Cloud persistence & Local storage" icon={<UploadCloudIcon />} />
<Card
href="#"
title="Offline support & sync-when-possible"
icon={<PlaneIcon />}
/>
<Card href="#" title="Fluid UI perf & 90% less loading" icon={<GaugeIcon />}/>
</Cards>
## First impressions&hellip;
<Slogan small>A chat app in 86 lines of code.</Slogan>
<Grid>
<GridItem>
```tsx filename="dataModel.ts" showLineNumbers
import { CoMap, CoList } from 'cojson';
export type Chat = CoList<Message['id']>;
export type Message = CoMap<{ text: string }>;
```
</GridItem>
<GridItem className="col-start-1">
```tsx filename="app.tsx" showLineNumbers
import { WithJazz, useJazz, DemoAuth } from 'jazz-react';
import ReactDOM from 'react-dom/client';
import { HashRoute } from 'hashroute';
import { ChatWindow } from './chatWindow.tsx';
import { Chat } from './dataModel.ts';
ReactDOM.createRoot(document.getElementById('root')!).render(
<WithJazz auth={DemoAuth({ appName: 'Chat' })}>
<App />
</WithJazz>,
);
function App() {
return <div className='flex flex-col items-center justify-between w-screen h-screen p-2 dark:bg-black dark:text-white'>
<button onClick={useJazz().logOut} className='rounded mb-5 px-2 py-1 bg-stone-200 dark:bg-stone-800 dark:text-white self-end'>
Log Out
</button>
{HashRoute({
'/': <Home />,
'/:id': (id) => <ChatWindow chatId={id as Chat['id']} />,
}, { reportToParentFrame: true })}
</div>
}
function Home() {
const { me } = useJazz();
// Groups determine access rights to values they own.
const createChat = () => {
const group = me.createGroup().addMember('everyone', 'writer');
const chat = group.createList<Chat>();
location.hash = '/' + chat.id;
};
return <button onClick={createChat} className='rounded py-2 px-4 bg-stone-200 dark:bg-stone-800 dark:text-white my-auto'>
Create New Chat
</button>
}
````
</GridItem>
<GridItem className="col-start-2 row-start-1 row-span-2">
```tsx filename="chatWindow.tsx" showLineNumbers
import { useAutoSub } from 'jazz-react';
import { Chat, Message } from './dataModel.ts';
export function ChatWindow({ chatId }: { chatId: Chat['id'] }) {
const chat = useAutoSub(chatId);
return chat ? <div className='w-full max-w-xl h-full flex flex-col items-stretch'>
{
chat.map((msg, i) => (
<ChatBubble key={msg?.id}
text={msg?.text}
by={chat.meta.edits[i].by?.profile?.name}
byMe={chat.meta.edits[i].by?.isMe}
time={chat.meta.edits[i].at} />
))
}
<ChatInput onSubmit={(text) => {
const msg = chat.meta.group.createMap<Message>({ text });
chat.append(msg.id);
}}/>
</div> : <div>Loading...</div>;
}
function ChatBubble({ text, by, time: t, byMe }:
{ text?: string, by?: string, time?: Date, byMe?: boolean }
) {
return <div className={`items-${byMe ? 'end' : 'start'} flex flex-col`}>
<div className='rounded-xl bg-stone-100 dark:bg-stone-700 dark:text-white py-2 px-4 mt-2 min-w-[5rem]'>
{ text }
</div>
<div className='text-xs text-neutral-500 ml-2'>
{ by } { t?.getHours() }:{ t?.getMinutes() }
</div>
</div>;
}
function ChatInput({ onSubmit }: { onSubmit: (text: string) => void }) {
return <input className='rounded p-2 border mt-auto dark:bg-black dark:text-white dark:border-stone-700'
placeholder='Type a message and press Enter'
onKeyDown={({ key, currentTarget: input }) => {
if (key !== 'Enter' || !input.value) return;
onSubmit(input.value);
input.value = '';
}}/>
}
````
</GridItem>
<ResponsiveIframe src="http://localhost:9999/" className="col-start-3 row-start-1 row-span-2 rounded-xl overflow-hidden border dark:border-stone-700 min-h-[50vh]"/>
</Grid>
## How does it work?
<Slogan small>Introducing: Secure collaborative data.</Slogan>
Jazz is built around **CoJSON,** a new abstraction that implements **multi-device co-editing,** **user identities & permissions** and **sync & persistence** in a standardized way with a high-level API.
This makes collaboration and secure access control feel like **inherent properties of your data** &mdash;&nbsp;so&nbsp;we're calling it &ldquo;secure collaborative data.&rdquo;
### Collaborative Values
<Slogan small>Your new building blocks.</Slogan>
- Data that multiple users can co-edit in real time or async with smart conflict resolution
<Grid>
<GridCard>
#### `CoMap`s - Key-value maps
</GridCard>
<GridCard>
#### `CoList`s - Ordered lists
</GridCard>
<GridCard>
#### `CoString`s - Plain-text
</GridCard>
<GridCard>
#### `CoText`s - Rich-text
- Generic collaborative markup format that prevents most editing conflicts
</GridCard>
<GridCard>
#### `CoStream`s - Per-user value streams
- Enforce per-user separation for user presence, social reactions, polls, replies etc.
</GridCard>
<GridCard>
#### `BinaryCoStream`s - file/media streams
- Create, reference and load even huge binary blobs or create live-streams without needing external services
</GridCard>
</Grid>
### Accounts & Groups
<Slogan small>First-class user identities & secure permissions.</Slogan>
- Simple API to define groups of users, their roles
- Verifiably enforced by encryption and signatures
## Jazz: batteries included.
<Grid>
<GridCard>
### Auto-sub
<Slogan small>Let your UI drive data-syncing</Slogan>
</GridCard>
<GridCard>
### Auth providers
</GridCard>
<GridCard>
### Two-way sync to your existing database
</GridCard>
</Grid>
## Global Mesh

View File

@@ -1,7 +0,0 @@
import { Slogan } from './index.mdx'
# Jazz Global Mesh
<Slogan>Serverless sync and storage for Jazz apps.</Slogan>
Real-time syncing infrastructure that scales up to millions of users. Pricing that scales down to zero.

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,15 +0,0 @@
module.exports = {
darkMode: 'class',
content: [
'./pages/**/*.{js,jsx,ts,tsx,md,mdx}',
'./components/**/*.{js,jsx,ts,tsx,md,mdx}',
'./theme.config.jsx'
],
theme: {
extend: {
display: ['var(--font-manrope)'],
mono: ['var(--font-pragmata)'],
}
},
plugins: []
}

View File

@@ -1,62 +0,0 @@
import Link from "next/link";
export default {
logo: (
<svg
width={386 / 4}
height={146 / 4}
viewBox="0 0 386 146"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M176.725 33.865H188.275V22.7H176.725V33.865ZM164.9 129.4H172.875C182.72 129.4 188.275 123.9 188.275 114.22V43.6H176.725V109.545C176.725 115.65 173.975 118.51 167.925 118.51H164.9V129.4ZM245.298 53.28C241.613 45.47 233.363 41.95 222.748 41.95C208.998 41.95 200.748 48.44 197.888 58.615L208.613 61.915C210.648 55.315 216.368 52.565 222.638 52.565C231.933 52.565 235.673 56.415 236.058 64.61C226.433 65.93 216.643 67.195 209.768 69.23C200.583 72.145 195.743 77.865 195.743 86.83C195.743 96.51 202.673 104.65 215.818 104.65C225.443 104.65 232.318 101.35 237.213 94.365V103H247.388V66.425C247.388 61.475 247.168 57.185 245.298 53.28ZM217.853 95.245C210.483 95.245 207.128 91.34 207.128 86.72C207.128 82.045 210.593 79.515 215.323 77.92C220.328 76.435 226.983 75.5 235.948 74.18C235.893 76.93 235.673 80.725 234.738 83.475C233.418 89.25 227.643 95.245 217.853 95.245ZM251.22 103H301.545V92.715H269.535L303.195 45.47V43.6H254.3V53.885H284.935L251.22 101.185V103ZM304.815 103H355.14V92.715H323.13L356.79 45.47V43.6H307.895V53.885H338.53L304.815 101.185V103Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M136.179 44.8277C136.179 44.8277 136.179 44.8277 136.179 44.8276V21.168C117.931 28.5527 97.9854 32.6192 77.0897 32.6192C65.1466 32.6192 53.5138 31.2908 42.331 28.7737V51.4076C42.331 51.4076 42.331 51.4076 42.331 51.4076V81.1508C41.2955 80.4385 40.1568 79.8458 38.9405 79.3915C36.1732 78.358 33.128 78.0876 30.1902 78.6145C27.2524 79.1414 24.5539 80.4419 22.4358 82.3516C20.3178 84.2613 18.8754 86.6944 18.291 89.3433C17.7066 91.9921 18.0066 94.7377 19.1528 97.2329C20.2991 99.728 22.2403 101.861 24.7308 103.361C27.2214 104.862 30.1495 105.662 33.1448 105.662H33.1455C33.6061 105.662 33.8365 105.662 34.0314 105.659C44.5583 105.449 53.042 96.9656 53.2513 86.4386C53.2534 86.3306 53.2544 86.2116 53.2548 86.0486H53.2552V85.7149L53.2552 85.5521V82.0762L53.2552 53.1993C61.0533 54.2324 69.0092 54.7656 77.0897 54.7656C77.6696 54.7656 78.2489 54.7629 78.8276 54.7574V110.696C77.792 109.983 76.6533 109.391 75.437 108.936C72.6697 107.903 69.6246 107.632 66.6867 108.159C63.7489 108.686 61.0504 109.987 58.9323 111.896C56.8143 113.806 55.3719 116.239 54.7875 118.888C54.2032 121.537 54.5031 124.283 55.6494 126.778C56.7956 129.273 58.7368 131.405 61.2273 132.906C63.7179 134.406 66.646 135.207 69.6414 135.207C70.1024 135.207 70.3329 135.207 70.5279 135.203C81.0548 134.994 89.5385 126.51 89.7478 115.983C89.7517 115.788 89.7517 115.558 89.7517 115.097V111.621L89.7517 54.3266C101.962 53.4768 113.837 51.4075 125.255 48.2397V80.9017C124.219 80.1894 123.081 79.5966 121.864 79.1424C119.097 78.1089 116.052 77.8384 113.114 78.3653C110.176 78.8922 107.478 80.1927 105.36 82.1025C103.242 84.0122 101.799 86.4453 101.215 89.0941C100.631 91.743 100.931 94.4886 102.077 96.9837C103.223 99.4789 105.164 101.612 107.655 103.112C110.145 104.612 113.073 105.413 116.069 105.413C116.53 105.413 116.76 105.413 116.955 105.409C127.482 105.2 135.966 96.7164 136.175 86.1895C136.179 85.9945 136.179 85.764 136.179 85.3029V81.8271L136.179 44.8277Z"
fill="#3313F7"
/>
</svg>
),
project: {
link: "https://github.com/gardencmp/jazz",
},
docsRepositoryBase:
"https://github.com/gardencmp/jazz/tree/main/homepage/homepage-jazz",
chat: { link: "https://discord.gg/utDMjHYg42" },
navbar: {
extraContent: (
<Link
className="nx-p-2 nx-text-current"
href={"https://twitter.com/jazz_tools"}
target="_blank"
>
<svg width="24" height="24" viewBox="0 0 248 204">
<path
fill="currentColor"
d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07a50.338 50.338 0 0 0 22.8-.87C27.8 117.2 10.85 96.5 10.85 72.46v-.64a50.18 50.18 0 0 0 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71a143.333 143.333 0 0 0 104.08 52.76 50.532 50.532 0 0 1 14.61-48.25c20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26a50.69 50.69 0 0 1-22.2 27.93c10.01-1.18 19.79-3.86 29-7.95a102.594 102.594 0 0 1-25.2 26.16z"
/>
</svg>
</Link>
),
},
useNextSeoProps() {
return {
titleTemplate: "jazz %s",
};
},
footer: {
text: (
<span>
MIT {new Date().getFullYear()} ©{" "}
<a href="https://gcmp.io" target="_blank">
Garden Computing, Inc
</a>
.
</span>
),
}
};

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

View File

@@ -0,0 +1,65 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -75,6 +75,9 @@
body {
@apply bg-background text-foreground;
}
.overlay-close {
background-color: "black";
}
}
pre.shiki {

View File

@@ -8,19 +8,20 @@ import localFont from "next/font/local";
import { GcmpLogo, JazzLogo } from "@/components/logos";
import { SiGithub, SiDiscord, SiTwitter } from "@icons-pack/react-simple-icons";
import { Nav } from "@/components/nav";
import { Nav, NavLink, Newsletter, NewsletterButton } from "@/components/nav";
import { MailIcon } from "lucide-react";
// If loading a variable font, you don't need to specify the font weight
const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope" });
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const pragmata = localFont({
src: "../fonts/PragmataProR_0829.woff2",
variable: "--font-pragmata",
src: "../fonts/ppr_0829.woff2",
variable: "--font-ppr",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "jazz - Instant sync",
description: "Go beyond request/response - ship modern apps with sync.",
};
export default function RootLayout({
@@ -49,33 +50,43 @@ export default function RootLayout({
items={[
{ title: "Toolkit", href: "/" },
{ title: "Global Mesh", href: "/mesh" },
{ title: "Docs & Guides", href: "/docs" },
{
title: "Docs & Guides",
href: "https://github.com/gardencmp/jazz/blob/main/DOCS.md",
newTab: true,
},
{
title: "Blog",
href: "https://gcmp.io/news",
firstOnRight: true,
newTab: true,
},
{
title: "Releases",
href: "https://github.com/gardencmp/jazz/releases",
newTab: true,
},
{
title: "Roadmap",
href: "https://github.com/orgs/gardencmp/projects/4/views/3",
newTab: true,
},
{
title: "GitHub",
href: "https://github.com/gardencmp/jazz",
newTab: true,
icon: <SiGithub className="w-5" />,
},
{
title: "Discord",
href: "https://discord.gg/utDMjHYg42",
newTab: true,
icon: <SiDiscord className="w-5" />,
},
{
title: "X",
href: "https://x.com/jazz_tools",
newTab: true,
icon: <SiTwitter className="w-5" />,
},
]}
@@ -90,34 +101,105 @@ export default function RootLayout({
"prose-h2:text-2xl lg:prose-h2:text-3xl prose-h2:font-medium prose-h2:tracking-tight",
"prose-p:max-w-3xl prose-p:leading-snug",
"prose-strong:font-medium",
"prose-code:leading-tight prose-code:before:content-none prose-code:after:content-none prose-code:bg-stone-100 prose-code:dark:bg-stone-900 prose-code:p-1 prose-code:-my-1 prose-code:rounded",
"prose-code:font-normal prose-code:leading-tight prose-code:before:content-none prose-code:after:content-none prose-code:bg-stone-100 prose-code:dark:bg-stone-900 prose-code:p-1 prose-code:-my-1 prose-code:rounded",
].join(" ")}
>
{children}
</article>
</main>
<footer className="flex mt-10 min-h-[15rem] -mb-20 bg-stone-100 dark:bg-stone-900 text-stone-600 dark:text-stone-400 w-full justify-center">
<div className="p-8 max-w-[80rem] w-full flex gap-4">
<div className="flex-1 flex flex-col gap-2 text-sm">
<div className="p-8 max-w-[80rem] w-full grid grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-8 max-sm:mb-12">
<div className="col-span-full md:col-span-1 sm:row-start-4 md:row-start-auto lg:col-span-2 md:row-span-2 md:flex-1 flex flex-row md:flex-col max-sm:mt-4 justify-between max-sm:items-start gap-2 text-sm min-w-[10rem]">
<GcmpLogo monochrome className="w-32" />
<p className="mt-auto">
<p className="max-sm:text-right">
© 2023
<br />
Garden Computing, Inc.
</p>
</div>
<div className="flex-1 flex flex-col gap-2 text-sm">
{/* <h1 className="font-medium">Resources</h1> */}
<div className="flex flex-col gap-2 text-sm">
<h1 className="font-medium">Resources</h1>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="/"
>
Toolkit
</NavLink>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="/mesh"
>
Global Mesh
</NavLink>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="https://github.com/gardencmp/jazz/blob/main/DOCS.md"
newTab
>
Docs & Guides
</NavLink>
</div>
<div className="flex-1 flex flex-col gap-2 text-sm">
{/* <h1 className="font-medium">Legal</h1> */}
{/* <div className="flex flex-col gap-2 text-sm">
<h1 className="font-medium">Legal</h1>
</div> */}
<div className="flex flex-col gap-2 text-sm">
<h1 className="font-medium">Community</h1>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="https://github.com/gardencmp/jazz"
newTab
>
GitHub
</NavLink>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="https://discord.gg/utDMjHYg42"
newTab
>
Discord
</NavLink>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="https://x.com/jazz_tools"
newTab
>
Twitter
</NavLink>
</div>
<div className="flex-1 flex flex-col gap-2 text-sm">
{/* <h1 className="font-medium">Newsletter</h1> */}
<div className="flex flex-col gap-2 text-sm">
<h1 className="font-medium">News</h1>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="https://gcmp.io/news"
newTab
>
Blog
</NavLink>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="https://github.com/gardencmp/jazz/releases"
newTab
>
Releases
</NavLink>
<NavLink
className="py-0.5 max-sm:px-0 md:px-0 lg:px-0"
href="https://github.com/orgs/gardencmp/projects/4/views/3"
newTab
>
Roadmap
</NavLink>
</div>
<div className="col-span-3 md:col-start-2 lg:col-start-auto flex flex-col gap-2 text-sm">
Sign up for updates:
<Newsletter/>
</div>
</div>
</footer>
</ThemeProvider>
<script defer data-api="/api/event" data-domain="jazz.tools" src="/js/script.js"></script>
</body>
</html>
);

View File

@@ -1,5 +1,10 @@
import { Slogan, Grid, GridCard } from '@/components/forMdx';
import { Pricing } from '@/components/pricing';
import { Slogan, Grid, GridCard, GridItem, ComingSoonBadge } from '@/components/forMdx';
import { pricePer1MtxSyncedOut, pricePerTxSyncedOut, pricePer1MtxStored, pricePerTxStored } from '@/components/pricing';
export const metadata = {
title: "jazz - Global Mesh",
description: "Serverless sync & storage for Jazz apps.",
};
# Jazz Global Mesh
@@ -10,7 +15,7 @@ Pricing that scales down to zero.
## The first Collaboration Delivery Network
<Slogan small>Build demanding apps with write-heavy distributed state, backed by a new kind of cloud.</Slogan>
<Slogan small>Build demanding apps with distributed state, backed by a new kind of cloud.</Slogan>
<Grid>
<GridCard>
@@ -26,6 +31,7 @@ Give users instant load times, with their latest data state always cached close
<GridCard>
#### Blob storage & media streaming.
Store files and media streams as idiomatic `CoValues` without S3.
</GridCard>
</Grid>
@@ -33,18 +39,120 @@ Give users instant load times, with their latest data state always cached close
<Slogan small></Slogan>
<Pricing />
### Free Tier
<span className="text-lg font-medium bg-emerald-200 dark:bg-emerald-800 px-2 py-1 rounded">Until we implement billing all usage of Global Mesh is free!</span>
<p className="text-sm">Later, any usage under $1/mo will be free.</p>
### Transactions explained
<Grid>
<GridItem className="md:col-span-2">
### Unlimited <ComingSoonBadge/>
<div className="lg:text-2xl border rounded-lg px-1 py-3 text-center">${pricePer1MtxSyncedOut} <small>per 1M TXs synced out</small> + ${pricePer1MtxStored}<small>/mo per 1M TXs stored</small></div>
<p><small>$6/mo minimum usage</small></p>
A TX (transaction) represents an **individual user action**, or **up to 100KB of binary data**.
</GridItem>
<GridItem className="col-start-1">
#### Transactions synced out:
<div className="text-sm">
- Transactions sent out from Global Mesh, each counted once for every device it is synced out to.
- Depending on cache behavior each transaction should only be synced out once per connection, ideally once per device requesting it.
</div>
</GridItem>
<GridItem>
#### Transactions stored:
<div className="text-sm">
- Transactions that are continuously persisted.
- Counted per second.
- Includes backups, hot storage and edge caches.
</div>
</GridItem>
</Grid>
**Examples:**
The number of transactions generated is highly app-specific and depends on user behaviour, but here are some examples:
<div className="text-sm">
- 4 users co-editing 10 pages of text, typing them out as individual character inserts:
- 3,000 inserts/page &times; 10 pages = 30,000 transactions
- 30,000 transactions stored = ${30000 * pricePerTxStored} / mo
- 3 &times; 30,000 transactions synced out = ${3 * 30000 * pricePerTxSyncedOut} one-time
- 4 users collaborating on a canvas, moving shapes around at 10 FPS for 10s/min for 2h/day for a month
- 4 users &times; 10 FPS &times; 10s/min &times; 60min/h * 2h/day &times; 30days = 1.44M transactions
- 1.44M transactions stored = ${1440000 * pricePerTxStored} / mo = ${1440000 * pricePerTxStored / 4} / mo / user
- 3 &times; 1.44M transactions synced out = ${(3 * 1440000 * pricePerTxSyncedOut).toLocaleString("en-US", { maximumSignificantDigits: 3, })} one-time = ${(3 * 1440000 * pricePerTxSyncedOut / 4).toLocaleString("en-US", { maximumSignificantDigits: 3, })} one-time / user
- A livestreamer streaming video (1GB total) to 100 viewers (combined live & on-demand)
- 1GB = 10,000 transactions (100KB each)
- 10,000 transactions stored = ${10000 * pricePerTxStored} / mo (= ${10000 * 1000 * pricePerTxStored} per 1TB stored)
- 100 &times; 10,000 transaction synced out = ${100 * 10000 * pricePerTxSyncedOut} one-time (= ${10000 * 1000 * pricePerTxSyncedOut} per 1TB egress)
</div>
## Global Footprint
Currently we are running endpoints in the following locations:
We're rapidly expanding our network of sync & storage nodes. This is our current best-effort coverage:
- Los Angeles
- New Jersey
<Grid className="grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<GridItem>
<div className="text-sm">
**Under 50ms RTT**
- Frankfurt
- New York
- Newark
- North California
- North Virginia
- San Francisco
- Singapore
- Toronto
</div>
</GridItem>
<GridItem>
<div className="text-sm">
**Under 100ms RTT**
- Amsterdam
- Atlanta
- London
- Ohio
- Paris
</div>
</GridItem>
<GridItem>
<div className="text-sm">
**Under 200ms RTT**
- Bangalore
- Dallas
- Mumbai
- Oregon
**Under 300ms RTT**
- Seoul
- Tokyo
</div>
</GridItem>
<GridItem>
<div className="text-sm">
**Under 400ms RTT**
- Sao Paulo
- Sydney
**Under 500ms RTT**
- Cape Town
</div>
</GridItem>
</Grid>
### Enterprise
Custom deployment in the cloud, your private cloud, on-premises or hybrids?
SLAs and dedicated support? White-glove integration services?
Let's talk: <a href="mailto:hello@gcmp.io">hello@gcmp.io</a>
## Custom Deployment Scenarios

View File

@@ -9,7 +9,10 @@ import {
ComingSoonBadge
} from "@/components/forMdx";
import {
ListTreeIcon,
JazzLogo
} from "@/components/logos";
import {
WorkflowIcon,
UploadCloudIcon,
PlaneIcon,
MonitorSmartphoneIcon,
@@ -20,30 +23,33 @@ import {
App_tsx,
ChatWindow_tsx,
} from "@/codeSamples/examples/chat/src";
import Link from "next/link";
# Instant sync.
# Instant sync
<Slogan>Go beyond request/response &mdash; ship modern apps with sync.</Slogan>
Jazz is an open-source toolkit for building apps with **sync** & **secure collaborative data.**
<h2 className="md:mt-24">Hard things are easy now.</h2>
<h2 className="md:mt-24">Hard things are easy now</h2>
Jazz replaces APIs, DBs and message queues with **a single new abstraction: CoJSON**.
And you get **built-in capabilities** that took best-in-class apps years to build:
This means you get **built-in capabilities** that took best-in-class apps years to build:
<Grid className="-mt-2">
<Grid className="-mt-2 gap-[1px] border rounded-xl overflow-hidden border-stone-200 dark:border-stone-800 shadow-sm bg-stone-200 dark:bg-stone-800 [&>*]:rounded-none [&>*]:border-none [&>*]:bg-stone-50 [&>*]:dark:bg-stone-950">
<GridFeature icon={<MonitorSmartphoneIcon />}>Cross-device sync</GridFeature>
<GridFeature icon={<MultiplayerIcon/>}>Real-time multiplayer</GridFeature>
<GridFeature icon={<ListTreeIcon />}>Automatic granular datafetching</GridFeature>
<GridFeature icon={<UploadCloudIcon />}>Cloud persistence<br/>& local storage</GridFeature>
<GridFeature icon={<PlaneIcon />}>Offline support<br/>& sync-when-possible</GridFeature>
<GridFeature icon={<GaugeIcon />}>Fluid UI performance<br/>& 90% less loading</GridFeature>
<GridFeature icon={<WorkflowIcon />}>Automatic granular datafetching</GridFeature>
<GridFeature icon={<UploadCloudIcon />}>Local & cloud persistence</GridFeature>
<GridFeature icon={<PlaneIcon />}>Offline support & Quick reconnect</GridFeature>
<GridFeature icon={<GaugeIcon />}>Instant UI updates & quick loads</GridFeature>
</Grid>
<div className="-mx-[calc(min(0,(100vw-95rem)/2))]">
### First impressions: A chat app in 82 lines of code.
### First impressions
<Slogan small>A chat app in 82 lines of code.</Slogan>
<Grid className="mt-0">
<GridItem>
@@ -61,15 +67,22 @@ And you get **built-in capabilities** that took best-in-class apps years to buil
<ChatWindow_tsx/>
</GridItem>
<ResponsiveIframe src="http://localhost:9999/" className="lg:col-start-3 lg:row-start-1 lg:row-span-2 rounded-xl overflow-hidden min-h-[50vh]"/>
<ResponsiveIframe src="https://chat.jazz.tools" className="lg:col-start-3 lg:row-start-1 lg:row-span-2 rounded-xl overflow-hidden min-h-[50vh]"/>
</Grid>
</div>
## A new standard for secure collaborative data.
## CoJSON
<Slogan small>The collaborative core.</Slogan>
Jazz is built around **CoJSON,** a new abstraction that implements **multi-device co-editing,** **user identities,** **permissions,** **sync** and **persistence** in a standardized way.
Jazz is built around **CoJSON,** a new abstraction for **sync** & **secure collaborative data.** And while it does all the heavy lifting...
CoJSON makes collaboration and secure access control feel like **inherent properties of your data**.
- **multi-device co-editing**
- **user identities & accounts**
- **permissions** & **roles**
- **sync** & **caching**
- **persistence**
...its API couldn't be simpler: CoJSON makes collaboration and secure access control feel like **inherent properties of your data**.
### Collaborative Values
@@ -77,7 +90,7 @@ CoJSON makes collaboration and secure access control feel like **inherent proper
Collaborative Values (CoValues) **can be edited as if they were simple local data,** but they're **automatically encrypted, signed** and **synced** between participants.
CoValues also **retain their full edit history,** including author metadata and potential editing conflicts. This makes it **super simple to build collaborative and social features.**
CoValues also **keep their full edit history,** including author metadata and potential editing conflicts. This makes it **super simple to build collaborative and social features.**
<Grid className="lg:gap-y-8">
@@ -97,28 +110,28 @@ CoValues also **retain their full edit history,** including author metadata and
- Immutable JSON & IDs of other CoValues
</div>
</GridCard>
<GridItem className="col-span-full lg:col-span-1 mb-10 lg:ml-4 [&>p]:m-0">
<GridItem className="col-span-full lg:col-span-1 mb-10 lg:ml-4 [&>p]:m-0 pt-4">
The bread and butter of datastructures, with collaboration built-in. You can build whole apps with just these.
</GridItem>
<GridCard>
### `CoString`
### `CoString` <ComingSoonBadge/>
<div className="text-sm">
- Collaborative plain-text
- Implemented as a CoList of unicode graphemes
- Supports concurrent inserts and deletes well
</div>
</GridCard>
<GridCard>
### `CoText`
### `CoText` <ComingSoonBadge/>
<div className="text-sm">
- Collaborative rich-text & generic markup format
- Based on CoString + collaborative markup ranges
- Collaborative rich-text based on `CoString` and a `CoMap` of collaborative markup ranges
- Gracefully prevents most editing conflicts
- Rendered as markdown, HTML, JSX, etc.
</div>
</GridCard>
<GridItem className="col-span-full lg:col-span-1 mb-10 lg:ml-4 [&>p]:m-0">
<GridItem className="col-span-full lg:col-span-1 mb-10 lg:ml-4 [&>p]:m-0 pt-4">
A shocking amount of UI is text editing. CoJSON offers correct, versatile primitives.
</GridItem>
<GridCard>
@@ -127,49 +140,142 @@ A shocking amount of UI is text editing. CoJSON offers correct, versatile primit
<div className="text-sm">
- Collection of independent per-user items streams:
- Immutable JSON & IDs of other CoValues
- Can be used for user presence, social reactions, polls, replies etc.
- Great for presence, reactions, polls, replies etc.
</div>
</GridCard>
<GridCard>
### `BinaryCoStream`
<div className="text-sm">
- File/media stream
- Create, reference and load binary blobs or do live-streams without external services
- A `CoStream` of binary data chunks
- Use for files and media streams
- Create, load, sync and store binary blobs or live-streams as just another kind of object
</div>
</GridCard>
<GridItem className="col-span-full lg:col-span-1 mb-10 lg:ml-4 [&>p]:m-0">
The secret weapons of
<GridItem className="col-span-full lg:col-span-1 mb-10 lg:ml-4 [&>p]:m-0 pt-4">
Two extra tools that let you do everything you need in your app without having to integrate additional external services.
</GridItem>
</Grid>
### Accounts & Groups
### Groups & Accounts
<Slogan small>First-class user identities & secure permissions.</Slogan>
- Simple API to define groups of users, their roles
- Verifiably enforced by encryption and signatures
<Grid>
<GridCard>
### `Group`
<div className="text-sm">
- A scope where specified accounts have roles (`reader`/`writer`/`admin`).
- A `Group` owns `CoValues`, with access right determined by group roles.
- Accounts can be added to groups directly or using shareable invite secrets.
</div>
</GridCard>
<GridCard>
### `Account`
<div className="text-sm">
- Represents a single user and their signing/encryption keys.
- Has a private account root and a public profile
- Can contain arbitrary app-specific data
</div>
</GridCard>
<GridItem className="col-span-full lg:col-span-1 mb-10 lg:ml-4 [&>p]:m-0 pt-4">
A simple API to define access control from anywhere, verifiably enforced by encryption and signatures.
</GridItem>
</Grid>
## Jazz: batteries included.
## The Jazz Toolkit
<Slogan small>Idiomatic bindings for CoJSON, with batteries included.</Slogan>
Supported environments:
<div className="text-sm">
- Browser (sync via WebSockets, IndexedDB persistence)
- React
- Vanilla JS / framework agnostic base
- React Native <ComingSoonBadge/>
- NodeJS (sync via WebSockets, SQLite persistence) <ComingSoonBadge/>
- Swift, Kotlin, Rust <ComingSoonBadge when="later"/>
</div>
<Grid>
<GridCard>
### Auto-sub
<Slogan small>Let your UI drive data-syncing</Slogan>
<Slogan small>Let your UI drive data-syncing.</Slogan>
<div className="text-sm">
- Load and auto-subscribe to deeply nested `CoValues` with a reactive hook (or callback).
- Access properties & metadata as plain JSON.
- Make granular changes with simple mutators.
- No queries needed, everything loads on-demand: <br/>
`profile?.tweets?.map(tweet => tweet?.text)`
</div>
</GridCard>
<GridCard>
### Auth providers <ComingSoonBadge/>
### Cursors & carets
<Slogan small>Ready-made spatial presence.</Slogan>
<div className="text-sm">
- 2D canvas cursors <ComingSoonBadge/>
- Text carets <ComingSoonBadge/>
- Element-based focus-presence <ComingSoonBadge/>
- Scroll-based / out-of-bounds helpers <ComingSoonBadge/>
</div>
</GridCard>
<GridCard>
### Auth providers
<Slogan small>Plug and play different kinds of auth.</Slogan>
<div className="text-sm">
- DemoAuth (for quick multi-user demos)
- WebAuthN (TouchID/FaceID)
- Auth0, Clerk & Okta <ComingSoonBadge/>
- NextAuth <ComingSoonBadge/>
</div>
</GridCard>
<GridCard>
### Two-way sync to your DB <ComingSoonBadge/>
### Two-way sync to your DB
<Slogan small>Add Jazz to an existing app.</Slogan>
<div className="text-sm">
- Prisma <ComingSoonBadge/>
- Drizzle <ComingSoonBadge/>
- PostgreSQL introspection <ComingSoonBadge/>
</div>
</GridCard>
<Slogan small>Migrate to Jazz feature-by-feature.</Slogan>
<GridCard>
### File upload & download
<Slogan small>Just use `<input type="file"/>`.</Slogan>
<div className="text-sm">
- Easily convert from and to Browser `Blob`s
- Super simple progressive image loading
</div>
</GridCard>
<GridCard>
### Video presence & calls
<Slogan small>Stream and record audio & video.</Slogan>
<div className="text-sm">
- Automatic WebRTC connections between `Group` members <ComingSoonBadge/>
- Audio/video recording into `BinaryCoStreams` <ComingSoonBadge/>
</div>
</GridCard>
</Grid>
## Global Mesh
<Slogan small>Serverless sync & storage for Jazz apps</Slogan>
To give you sync & secure collaborative data instantly on a global scale, we're running Global Mesh. It works with any CoJSON-based app, requires no setup and has straightforward, scale-to-zero pricing.
Global Mesh is currently free &mdash; and it's set up as the default sync & storage peer in Jazz, letting you start building multi-user apps with persistence right away, no backend needed.
<Link href="/mesh" target="_blank">Learn more about Global Mesh</Link>
## Get Started
- See the <Link href="https://github.com/gardencmp/jazz#todo-list" target="_blank">Todo List Example Walkthrough</Link>
- <Link href="https://github.com/gardencmp/jazz/blob/main/DOCS.md" target="_blank">Read the docs</Link>
- <Link href="https://discord.gg/utDMjHYg42" target="_blank">Join our Discord</Link>

File diff suppressed because one or more lines are too long

View File

@@ -71,33 +71,27 @@ export function GridCard(props: { children: ReactNode; className?: string }) {
export function MultiplayerIcon() {
return (
<div className="w-8 h-8 -my-1 -mr-2 relative">
<TextCursorIcon
size="12"
<div className="w-8 h-8 -my-1 -mr-2 relative z-0">
<MousePointer2Icon
size="20"
absoluteStrokeWidth
strokeWidth={1.8}
className="absolute top-0 left-0.5 -z-10"
strokeWidth={2}
className="absolute top-1 right-0"
/>
<MousePointer2Icon
size="16"
absoluteStrokeWidth
strokeWidth={1.8}
className="absolute top-1.5 right-1 -z-10"
/>
<HandIcon
size="16"
absoluteStrokeWidth
strokeWidth={1.8}
className="absolute bottom-0 left-0 -z-10"
strokeWidth={2}
className="absolute bottom-1 left-0 -scale-x-100"
/>
</div>
);
}
export function ComingSoonBadge() {
export function ComingSoonBadge({when = "soon"}: {when?: string}) {
return (
<span className="bg-stone-100 dark:bg-stone-900 text-stone-500 dark:text-stone-400 border border-stone-300 dark:border-stone-700 text-[0.6rem] px-1 py-0.5 rounded-xl align-text-top">
Coming&nbsp;soon
Coming&nbsp;{when}
</span>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { MenuIcon, SearchIcon, XIcon } from "lucide-react";
import { MailIcon, MenuIcon, SearchIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ReactNode, useLayoutEffect, useRef, useState } from "react";
@@ -16,6 +16,7 @@ export function Nav({
icon?: ReactNode;
title: string;
firstOnRight?: boolean;
newTab?: boolean;
}[];
}) {
const [menuOpen, setMenuOpen] = useState(false);
@@ -44,13 +45,18 @@ export function Nav({
</div>
{items.map((item, i) =>
"icon" in item ? (
<NavLinkLogo key={i} href={item.href}>
<NavLinkLogo
key={i}
href={item.href}
newTab={item.newTab}
>
{item.icon}
</NavLinkLogo>
) : (
<NavLink
key={i}
href={item.href}
newTab={item.newTab}
className={cn(
"max-sm:w-full",
item.firstOnRight ? "md:ml-auto" : ""
@@ -63,11 +69,7 @@ export function Nav({
</div>
</nav>
<div className="md:hidden px-4 flex items-center self-stretch dark:text-white">
<NavLinkLogo
prominent
href="/"
className="mr-auto"
>
<NavLinkLogo prominent href="/" className="mr-auto">
{mainLogo}
</NavLinkLogo>
<button
@@ -87,12 +89,12 @@ export function Nav({
}}
className={cn(
menuOpen || searchOpen ? "block" : "hidden",
"fixed top-0 bottom-0 left-0 right-0 bg-stone-200/80 dark:bg-black/80 w-full h-full"
"fixed top-0 bottom-0 left-0 right-0 bg-stone-200/80 dark:bg-black/80 w-full h-full z-10"
)}
></div>
<nav
className={cn(
"md:hidden fixed flex flex-col items-end bottom-4 right-4",
"md:hidden fixed flex flex-col items-end bottom-4 right-4 z-20",
"bg-stone-50 dark:bg-stone-925 dark:text-white border border-stone-100 dark:border-stone-900 dark:outline dark:outline-1 dark:outline-black/60 rounded-lg shadow-lg",
menuOpen || searchOpen ? "left-4" : ""
)}
@@ -115,7 +117,11 @@ export function Nav({
{items
.filter((item) => "icon" in item)
.map((item, i) => (
<NavLinkLogo key={i} href={item.href}>
<NavLinkLogo
key={i}
href={item.href}
newTab={item.newTab}
>
{item.icon}
</NavLinkLogo>
))}
@@ -127,6 +133,7 @@ export function Nav({
key={i}
href={item.href}
onClick={() => setMenuOpen(false)}
newTab={item.newTab}
className={cn(
"max-sm:w-full border-b border-stone-100 dark:border-stone-900",
item.firstOnRight ? "md:ml-auto" : ""
@@ -137,7 +144,7 @@ export function Nav({
))}
</div>
<div className="flex items-center self-stretch justify-end">
<input
{/* <input
type="text"
className={cn(
menuOpen || searchOpen ? "" : "hidden",
@@ -145,8 +152,8 @@ export function Nav({
)}
placeholder="Search docs..."
ref={searchRef}
/>
<button
/> */}
{/* <button
className="flex p-3 rounded-xl"
onClick={() => {
setSearchOpen(true);
@@ -158,7 +165,7 @@ export function Nav({
}}
>
<SearchIcon className="" />
</button>
</button> */}
<button
className="flex p-3 rounded-xl"
onMouseDown={() => {
@@ -166,7 +173,11 @@ export function Nav({
setSearchOpen(false);
}}
>
{(menuOpen || searchOpen) ? <XIcon/>: <MenuIcon className="" />}
{menuOpen || searchOpen ? (
<XIcon />
) : (
<MenuIcon className="" />
)}
</button>
</div>
</nav>
@@ -179,27 +190,37 @@ export function NavLink({
className,
children,
onClick,
newTab,
}: {
href: string;
className?: string;
children: ReactNode;
onClick?: () => void;
newTab?: boolean;
}) {
const path = usePathname();
return (
<Link
href={href}
className={[
className={cn(
"px-2 lg:px-4 py-3 text-sm",
className,
path === href
? "font-medium text-black dark:text-white cursor-default"
: "text-stone-600 dark:text-stone-400 hover:text-black dark:hover:text-white transition-colors hover:transition-none",
].join(" ")}
: "text-stone-600 dark:text-stone-400 hover:text-black dark:hover:text-white transition-colors hover:transition-none"
)}
onClick={onClick}
target={newTab ? "_blank" : undefined}
>
{children}
{newTab ? (
<span className="text-stone-300 dark:text-stone-700 relative -top-0.5 -left-0.5">
</span>
) : (
""
)}
</Link>
);
}
@@ -210,19 +231,21 @@ export function NavLinkLogo({
children,
prominent,
onClick,
newTab,
}: {
href: string;
className?: string;
children: ReactNode;
prominent?: boolean;
onClick?: () => void;
newTab?: boolean;
}) {
const path = usePathname();
return (
<Link
href={href}
className={[
className={cn(
"max-sm:px-4 px-2 lg:px-3 py-3 transition-opacity hover:transition-none",
path === href
? "cursor-default"
@@ -230,11 +253,126 @@ export function NavLinkLogo({
? "hover:opacity-50"
: "opacity-60 hover:opacity-100",
"text-black dark:text-white",
className,
].join(" ")}
className
)}
onClick={onClick}
target={newTab ? "_blank" : undefined}
>
{children}
</Link>
);
}
export function NewsletterButton() {
return (
<button
onClick={() =>
(window as any).ml_account(
"webforms",
"5744530",
"p5o0j8",
"show"
)
}
className="flex px-2 py-1 rounded gap-2 items-center bg-stone-300 hover:bg-stone-200 dark:bg-stone-950 dark:hover:bg-stone-800 text-black dark:text-white"
>
<MailIcon className="" size="14" /> Subscribe
</button>
);
}
{
/* <input
type="email"
autoComplete="email"
placeholder="you@example.com"
className="max-w-[14rem] border border-stone-200 dark:border-stone-900 px-2 py-1 rounded w-full"
/> */
}
export function Newsletter() {
return (
<>
<div
id="mlb2-5744530"
className="ml-form-embedContainer ml-subscribe-form ml-subscribe-form-5744530"
>
<form
className="flex gap-2"
action="https://static.mailerlite.com/webforms/submit/p5o0j8"
data-code="p5o0j8"
method="post"
target="_blank"
>
<input
aria-label="email"
aria-required="true"
type="email"
className="text-base form-control max-w-[18rem] border border-stone-300 dark:border-transparent shadow-sm dark:bg-stone-925 px-2 py-1 rounded w-full"
data-inputmask=""
name="fields[email]"
placeholder="Email"
autoComplete="email"
/>
<input
type="checkbox"
className="hidden"
name="groups[]"
value="112132481"
checked
/>
<input
type="checkbox"
className="hidden"
name="groups[]"
value="111453104"
/>
<input type="hidden" name="ml-submit" value="1" />
<button
type="submit"
className="flex px-3 py-1 rounded gap-2 items-center shadow-sm bg-stone-925 dark:bg-black hover:bg-stone-800 text-white"
>
<MailIcon className="" size="14" /> Subscribe
</button>
<input type="hidden" name="anticsrf" value="true" />
</form>
<div
className="ml-form-successBody row-success"
style={{ display: "none" }}
>
<div className="ml-form-successContent">
<p>You&apos;re subscribed 🎉</p>
</div>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
function ml_webform_success_5744530(){var r=ml_jQuery||jQuery;r(".ml-subscribe-form-5744530 .row-success").show(),r(".ml-subscribe-form-5744530 .row-form").hide()}
`,
}}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://track.mailerlite.com/webforms/o/5744530/p5o0j8?v1697487427"
width="1"
height="1"
style={{
maxWidth: "1px",
maxHeight: "1px",
visibility: "hidden",
padding: 0,
margin: 0,
display: "block",
}}
alt="."
/>
<script
src="https://static.mailerlite.com/js/w/webforms.min.js?vd4de52e171e8eb9c47c0c20caf367ddf"
type="text/javascript"
defer
></script>
</>
);
}

View File

@@ -1,11 +1,12 @@
import { ComingSoonBadge, Grid, GridCard, GridItem } from "./forMdx";
export function Pricing() {
const pricePer1MtxSyncedOut = 2;
const pricePer1MtxStored = 4;
export const pricePer1MtxSyncedOut = 1;
export const pricePer1MtxStored = 2;
const pricePerTxSyncedOut = pricePer1MtxSyncedOut / 1_000_000;
const pricePerTxStored = pricePer1MtxStored / 1_000_000;
export const pricePerTxSyncedOut = pricePer1MtxSyncedOut / 1_000_000;
export const pricePerTxStored = pricePer1MtxStored / 1_000_000;
export function Pricing() {
const worstCaseBytesPerTx = 200_000;
const avgCaseBytesPerTx = 10_000;
@@ -39,12 +40,12 @@ export function Pricing() {
<Grid>
<GridCard>
<h3>Free Tier</h3>
<p className="text-lg line-through">Any usage under $2/mo is free!</p>
<p className="text-lg font-medium bg-amber-200 dark:bg-amber-800 px-2 py-1 rounded">Until we implement API keys and billing all usage of Global Mesh is free!</p>
<p className="text-lg font-medium bg-indigo-200 dark:bg-indigo-800 px-2 py-1 rounded">Until we implement billing all usage of Global Mesh is free!</p>
<p className="text-sm">Later, any usage under $2/mo will be free.</p>
</GridCard>
<GridCard>
<h3>Unlimited <ComingSoonBadge/></h3>
<p className="text-lg line-through">
<p className="text-lg">
{fmt$(pricePer1MtxSyncedOut)} per 1,000,000 transactions
synced out
{/* <br />
@@ -59,7 +60,7 @@ export function Pricing() {
<br />
Worst cost: {fmt$(worstCaseCostPerTxStored * 1_000_000)} */}
</p>
<p className="text-sm">Transactions usually represent individual user actions, or up to 100KB of binary data.</p>
<p className="text-sm">See below for how transactions are defined.</p>
</GridCard>
<GridCard>
<h3>Enterprise</h3>

View File

@@ -0,0 +1,56 @@
job "homepage-jazz$BRANCH_SUFFIX" {
region = "global"
datacenters = ["*"]
group "static" {
count = 4
network {
port "http" {
to = 3000
}
}
constraint {
attribute = "${node.class}"
operator = "="
value = "mesh"
}
spread {
attribute = "${node.datacenter}"
weight = 100
}
constraint {
distinct_hosts = true
}
task "server" {
driver = "docker"
config {
image = "$DOCKER_TAG"
ports = ["http"]
auth = {
username = "$DOCKER_USER"
password = "$DOCKER_PASSWORD"
}
}
service {
tags = ["public"]
name = "homepage-jazz$BRANCH_SUFFIX"
port = "http"
provider = "consul"
}
resources {
cpu = 100 # MHz
memory = 100 # MB
}
}
}
}
# deploy bump 4

View File

@@ -18,4 +18,9 @@ const withMDX = createMDX({
},
});
export default withMDX(nextConfig);
const config = {
...withMDX(nextConfig),
output: 'standalone'
};
export default config;

View File

@@ -31,7 +31,7 @@ const config: Config = {
extend: {
fontFamily: {
display: ['var(--font-manrope)'],
mono: ['var(--font-pragmata)'],
mono: ['var(--font-ppr)'],
},
// shadcn-ui
colors: {

View File

@@ -13,9 +13,9 @@
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
"@babel/runtime@^7.20.7":
version "7.23.1"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.1.tgz#72741dc4d413338a91dcb044a86f3c0bc402646d"
integrity sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
dependencies:
regenerator-runtime "^0.14.0"
@@ -29,10 +29,10 @@
resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-1.1.4.tgz#70bf4c5b379cdc256d3936bf4a21e3a3454a3d68"
integrity sha512-ZV1TSmToiNcQL1P3hfzlzZzA02mmVkVmXGaUDUqpYUG84PmLhVSZpKX+KfxAuOcK7de04UXSQPBrAvaya6iiGg==
"@csstools/css-color-parser@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-1.3.3.tgz#ccae33e97f196cd97b0e471b89b04735f27c9e80"
integrity sha512-8GHvh0jopx++NLfYg6e7Bb1snI+CrGdHxUdzjX6zERyjCRsL53dX0ZqE5i4z7thAHCaLRlQrAMIWgNI0EQkx7w==
"@csstools/css-color-parser@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-1.4.0.tgz#c8517457dcb6ad080848b1583aa029ab61221ce8"
integrity sha512-SlGd8E6ron24JYQPQAIzu5tvmWi1H4sDKTdA7UDnwF45oJv7AVESbOlOO1YjfBhrQFuvLWUgKiOY9DwGoAxwTA==
dependencies:
"@csstools/color-helpers" "^3.0.2"
"@csstools/css-calc" "^1.1.4"
@@ -48,19 +48,19 @@
integrity sha512-Zmsf2f/CaEPWEVgw29odOj+WEVoiJy9s9NOv5GgNY9mZ1CZ7394By6wONrONrTsnNDv6F9hR02nvFihrGVGHBg==
"@csstools/postcss-oklab-function@^3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.6.tgz#24494aec15c2f27051e9ed42660aa29998ccf47d"
integrity sha512-p//JBeyk57OsNT1y9snWqunJ5g39JXjJUVlOcUUNavKxwQiRcXx2otONy7fRj6y3XKHLvp8wcV7kn93rooNaYA==
version "3.0.7"
resolved "https://registry.yarnpkg.com/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.7.tgz#4daff9e85b7f68ea744f2898f73e81d6fe47c0d7"
integrity sha512-vBFTQD3CARB3u/XIGO44wWbcO7xG/4GsYqJlcPuUGRSK8mtxes6n4vvNFlIByyAZy2k4d4RY63nyvTbMpeNTaQ==
dependencies:
"@csstools/css-color-parser" "^1.3.3"
"@csstools/css-color-parser" "^1.4.0"
"@csstools/css-parser-algorithms" "^2.3.2"
"@csstools/css-tokenizer" "^2.2.1"
"@csstools/postcss-progressive-custom-properties" "^3.0.1"
"@csstools/postcss-progressive-custom-properties" "^3.0.2"
"@csstools/postcss-progressive-custom-properties@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-3.0.1.tgz#15251d880d60850df42deeb7702aab6c50ab74e7"
integrity sha512-yfdEk8o3CWPTusoInmGpOVCcMg1FikcKZyYB5ApULg9mES4FTGNuHK3MESscmm64yladcLNkPlz26O7tk3LMbA==
"@csstools/postcss-progressive-custom-properties@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-3.0.2.tgz#0c18152160a425950cb69a12a9add55af4f688e7"
integrity sha512-YEvTozk1SxnV/PGL5DllBVDuLQ+jiQhyCSQiZJ6CwBMU5JQ9hFde3i1qqzZHuclZfptjrU0JjlX4ePsOhxNzHw==
dependencies:
postcss-value-parser "^4.2.0"
@@ -91,15 +91,15 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@8.50.0":
version "8.50.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.50.0.tgz#9e93b850f0f3fa35f5fa59adfd03adae8488e484"
integrity sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==
"@eslint/js@8.51.0":
version "8.51.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.51.0.tgz#6d419c240cfb2b66da37df230f7e7eef801c32fa"
integrity sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==
"@evilmartians/harmony@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@evilmartians/harmony/-/harmony-1.0.0.tgz#b9a7a96121a1c620b1010c521b5c6df9b7c96b71"
integrity sha512-t2ukm+BHANWFzjpfXBv4pNpZH29iOMVfZ0OVf1RKbr+XrrIMRHJu85rDW449zOr3U2XO+yWHKUHX1bVNfkREHQ==
version "1.1.0"
resolved "https://registry.yarnpkg.com/@evilmartians/harmony/-/harmony-1.1.0.tgz#393b8daa55e7fd78011cdf9ed972f1044ce76876"
integrity sha512-yci5pFdR26Boi5hElDkWME8Egp4xrhlKrlA1R1xknuvZV8QOjpIWIlZk/dvRZsY3QZulRVBthBMpBiDyq5kBCQ==
"@humanwhocodes/config-array@^0.11.11":
version "0.11.11"
@@ -209,9 +209,9 @@
glob "7.1.7"
"@next/mdx@^13.5.4":
version "13.5.4"
resolved "https://registry.yarnpkg.com/@next/mdx/-/mdx-13.5.4.tgz#3b79c6f9d96669c4b852236b62fc94fdb0e1e518"
integrity sha512-WYdWeDZUvX9h0BnjDtwyFy2We4ko8ox5EuglN27rCoYz1xj8fQ8KAn7reZgXwT2RX2hxUOl4eTNbXBfsrw7Gew==
version "13.5.5"
resolved "https://registry.yarnpkg.com/@next/mdx/-/mdx-13.5.5.tgz#ce1ff29ab424f2335a02ac6a662f56938c275b5d"
integrity sha512-vIUvyICrCHiCAYU4HY/h0DgeFyEZBdRTPR9TTU79Q2u01M9DxrciE5hBwXKIJkzUTa4Ia5nszLt0o9GxpBIgMQ==
dependencies:
source-map "^0.7.0"
@@ -359,9 +359,11 @@
integrity sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g==
"@types/node@^20":
version "20.8.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4"
integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==
version "20.8.6"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.6.tgz#0dbd4ebcc82ad0128df05d0e6f57e05359ee47fa"
integrity sha512-eWO4K2Ji70QzKUqRy6oyJWUeB7+g2cRagT3T/nxYibYcT4y2BDL8lqolRXjTHmkZCdJfIPaY73KbJAZmcryxTQ==
dependencies:
undici-types "~5.25.1"
"@types/prop-types@*":
version "15.7.8"
@@ -369,16 +371,16 @@
integrity sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==
"@types/react-dom@^18":
version "18.2.10"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.10.tgz#06247cb600e39b63a0a385f6a5014c44bab296f2"
integrity sha512-5VEC5RgXIk1HHdyN1pHlg0cOqnxHzvPGpMMyGAP5qSaDRmyZNDaQ0kkVAkK6NYlDhP6YBID3llaXlmAS/mdgCA==
version "18.2.13"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.13.tgz#89cd7f9ec8b28c8b6f0392b9591671fb4a9e96b7"
integrity sha512-eJIUv7rPP+EC45uNYp/ThhSpE16k22VJUknt5OLoH9tbXoi8bMhwLf5xRuWMywamNbWzhrSmU7IBJfPup1+3fw==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@>=16", "@types/react@^18":
version "18.2.25"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.25.tgz#99fa44154132979e870ff409dc5b6e67f06f0199"
integrity sha512-24xqse6+VByVLIr+xWaQ9muX1B4bXJKXBbjszbld/UEDslGLY53+ZucF44HCmLbMPejTzGG9XgR+3m2/Wqu1kw==
version "18.2.28"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.28.tgz#86877465c0fcf751659a36c769ecedfcfacee332"
integrity sha512-ad4aa/RaaJS3hyGz0BGegdnSRXQBkd1CCYDCdNjBPg90UUpLgo+WlJqb9fMYUxtehmzF3PJaTWqRZjko6BRzBg==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
@@ -395,48 +397,48 @@
integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw==
"@typescript-eslint/parser@^5.4.2 || ^6.0.0":
version "6.7.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.7.4.tgz#23d1dd4fe5d295c7fa2ab651f5406cd9ad0bd435"
integrity sha512-I5zVZFY+cw4IMZUeNCU7Sh2PO5O57F7Lr0uyhgCJmhN/BuTlnc55KxPonR4+EM3GBdfiCyGZye6DgMjtubQkmA==
version "6.8.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.8.0.tgz#bb2a969d583db242f1ee64467542f8b05c2e28cb"
integrity sha512-5tNs6Bw0j6BdWuP8Fx+VH4G9fEPDxnVI7yH1IAPkQH5RUtvKwRoqdecAPdQXv4rSOADAaz1LFBZvZG7VbXivSg==
dependencies:
"@typescript-eslint/scope-manager" "6.7.4"
"@typescript-eslint/types" "6.7.4"
"@typescript-eslint/typescript-estree" "6.7.4"
"@typescript-eslint/visitor-keys" "6.7.4"
"@typescript-eslint/scope-manager" "6.8.0"
"@typescript-eslint/types" "6.8.0"
"@typescript-eslint/typescript-estree" "6.8.0"
"@typescript-eslint/visitor-keys" "6.8.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@6.7.4":
version "6.7.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.7.4.tgz#a484a17aa219e96044db40813429eb7214d7b386"
integrity sha512-SdGqSLUPTXAXi7c3Ob7peAGVnmMoGzZ361VswK2Mqf8UOYcODiYvs8rs5ILqEdfvX1lE7wEZbLyELCW+Yrql1A==
"@typescript-eslint/scope-manager@6.8.0":
version "6.8.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.8.0.tgz#5cac7977385cde068ab30686889dd59879811efd"
integrity sha512-xe0HNBVwCph7rak+ZHcFD6A+q50SMsFwcmfdjs9Kz4qDh5hWhaPhFjRs/SODEhroBI5Ruyvyz9LfwUJ624O40g==
dependencies:
"@typescript-eslint/types" "6.7.4"
"@typescript-eslint/visitor-keys" "6.7.4"
"@typescript-eslint/types" "6.8.0"
"@typescript-eslint/visitor-keys" "6.8.0"
"@typescript-eslint/types@6.7.4":
version "6.7.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.7.4.tgz#5d358484d2be986980c039de68e9f1eb62ea7897"
integrity sha512-o9XWK2FLW6eSS/0r/tgjAGsYasLAnOWg7hvZ/dGYSSNjCh+49k5ocPN8OmG5aZcSJ8pclSOyVKP2x03Sj+RrCA==
"@typescript-eslint/types@6.8.0":
version "6.8.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.8.0.tgz#1ab5d4fe1d613e3f65f6684026ade6b94f7e3ded"
integrity sha512-p5qOxSum7W3k+llc7owEStXlGmSl8FcGvhYt8Vjy7FqEnmkCVlM3P57XQEGj58oqaBWDQXbJDZxwUWMS/EAPNQ==
"@typescript-eslint/typescript-estree@6.7.4":
version "6.7.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.4.tgz#f2baece09f7bb1df9296e32638b2e1130014ef1a"
integrity sha512-ty8b5qHKatlNYd9vmpHooQz3Vki3gG+3PchmtsA4TgrZBKWHNjWfkQid7K7xQogBqqc7/BhGazxMD5vr6Ha+iQ==
"@typescript-eslint/typescript-estree@6.8.0":
version "6.8.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.8.0.tgz#9565f15e0cd12f55cf5aa0dfb130a6cb0d436ba1"
integrity sha512-ISgV0lQ8XgW+mvv5My/+iTUdRmGspducmQcDw5JxznasXNnZn3SKNrTRuMsEXv+V/O+Lw9AGcQCfVaOPCAk/Zg==
dependencies:
"@typescript-eslint/types" "6.7.4"
"@typescript-eslint/visitor-keys" "6.7.4"
"@typescript-eslint/types" "6.8.0"
"@typescript-eslint/visitor-keys" "6.8.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/visitor-keys@6.7.4":
version "6.7.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.4.tgz#80dfecf820fc67574012375859085f91a4dff043"
integrity sha512-pOW37DUhlTZbvph50x5zZCkFn3xzwkGtNoJHzIM3svpiSkJzwOYr/kVBaXmf+RAQiUDs1AHEZVNPg6UJCJpwRA==
"@typescript-eslint/visitor-keys@6.8.0":
version "6.8.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.8.0.tgz#cffebed56ae99c45eba901c378a6447b06be58b8"
integrity sha512-oqAnbA7c+pgOhW2OhGvxm0t1BULX5peQI/rLsNDpGM78EebV3C9IGbX5HNZabuZ6UQrYveCLjKo8Iy/lLlBkkg==
dependencies:
"@typescript-eslint/types" "6.7.4"
"@typescript-eslint/types" "6.8.0"
eslint-visitor-keys "^3.4.1"
"@typescript/twoslash@3.1.0":
@@ -715,9 +717,9 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001538, caniuse-lite@^1.0.30001541:
version "1.0.30001546"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001546.tgz#10fdad03436cfe3cc632d3af7a99a0fb497407f0"
integrity sha512-zvtSJwuQFpewSyRrI3AsftF6rM0X80mZkChIt1spBGEvRglCrjTniXvinc8JKRoqTwXAgvqTImaN9igfSMtUBw==
version "1.0.30001549"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001549.tgz#7d1a3dce7ea78c06ed72c32c2743ea364b3615aa"
integrity sha512-qRp48dPYSCYaP+KurZLhDYdVE+yEyht/3NlmcJgVQ2VMGt6JL36ndQ/7rgspdZsJuxDPFIo/OzBT2+GmIJ53BA==
ccount@^2.0.0:
version "2.0.1"
@@ -862,9 +864,9 @@ deep-is@^0.1.3:
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
define-data-property@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.0.tgz#0db13540704e1d8d479a0656cf781267531b9451"
integrity sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==
version "1.1.1"
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3"
integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==
dependencies:
get-intrinsic "^1.2.1"
gopd "^1.0.1"
@@ -921,9 +923,9 @@ doctrine@^3.0.0:
esutils "^2.0.2"
electron-to-chromium@^1.4.535:
version "1.4.542"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.542.tgz#9bfe19d8ddafc2591e4a17d04e60a5f5acc54965"
integrity sha512-6+cpa00G09N3sfh2joln4VUXHquWrOFx3FLZqiVQvl45+zS9DskDBTPvob+BhvFRmTBkyDSk0vvLMMRo/qc6mQ==
version "1.4.556"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.556.tgz#97385917eb6ea3ac6a3378cf87bb39ee1db96e76"
integrity sha512-6RPN0hHfzDU8D56E72YkDvnLw5Cj2NMXZGg3UkgyoHxjVhG99KZpsKgBWMmTy0Ei89xwan+rbRsVB9yzATmYzQ==
emoji-regex@^9.2.2:
version "9.2.2"
@@ -1168,14 +1170,14 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint@^8:
version "8.50.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.50.0.tgz#2ae6015fee0240fcd3f83e1e25df0287f487d6b2"
integrity sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==
version "8.51.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.51.0.tgz#4a82dae60d209ac89a5cff1604fea978ba4950f3"
integrity sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==
dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@eslint-community/regexpp" "^4.6.1"
"@eslint/eslintrc" "^2.1.2"
"@eslint/js" "8.50.0"
"@eslint/js" "8.51.0"
"@humanwhocodes/config-array" "^0.11.11"
"@humanwhocodes/module-importer" "^1.0.1"
"@nodelib/fs.walk" "^1.2.8"
@@ -1354,15 +1356,15 @@ find-up@^5.0.0:
path-exists "^4.0.0"
flat-cache@^3.0.4:
version "3.1.0"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.0.tgz#0e54ab4a1a60fe87e2946b6b00657f1c99e1af3f"
integrity sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==
version "3.1.1"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.1.tgz#a02a15fdec25a8f844ff7cc658f03dd99eb4609b"
integrity sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==
dependencies:
flatted "^3.2.7"
flatted "^3.2.9"
keyv "^4.5.3"
rimraf "^3.0.2"
flatted@^3.2.7:
flatted@^3.2.9:
version "3.2.9"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf"
integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==
@@ -1375,9 +1377,9 @@ for-each@^0.3.3:
is-callable "^1.1.3"
fraction.js@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.6.tgz#e9e3acec6c9a28cf7bc36cbe35eea4ceb2c5c92d"
integrity sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==
version "4.3.7"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
fs.realpath@^1.0.0:
version "1.0.0"
@@ -1390,9 +1392,9 @@ fsevents@~2.3.2:
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
version "1.1.2"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
function.prototype.name@^1.1.5, function.prototype.name@^1.1.6:
version "1.1.6"
@@ -1490,9 +1492,9 @@ glob@^7.1.3:
path-is-absolute "^1.0.0"
globals@^13.19.0:
version "13.22.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-13.22.0.tgz#0c9fcb9c48a2494fbb5edbfee644285543eba9d8"
integrity sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==
version "13.23.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-13.23.0.tgz#ef31673c926a0976e1f61dab4dca57e0c0a8af02"
integrity sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==
dependencies:
type-fest "^0.20.2"
@@ -1658,7 +1660,7 @@ is-alphanumerical@^2.0.0:
is-array-buffer@^3.0.1, is-array-buffer@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe"
integrity sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzstonetCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==
integrity sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==
dependencies:
call-bind "^1.0.2"
get-intrinsic "^1.2.0"
@@ -1703,7 +1705,7 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.9.0:
is-core-module@^2.11.0, is-core-module@^2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
@@ -1929,9 +1931,9 @@ jsonc-parser@^3.0.0:
object.values "^1.1.6"
keyv@^4.5.3:
version "4.5.3"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.3.tgz#00873d2b046df737963157bd04f294ca818c9c25"
integrity sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
dependencies:
json-buffer "3.0.1"
@@ -2548,9 +2550,9 @@ object-hash@^3.0.0:
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
object-inspect@^1.12.3, object-inspect@^1.9.0:
version "1.12.3"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
version "1.13.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.0.tgz#42695d3879e1cd5bda6df5062164d80c996e23e2"
integrity sha512-HQ4J+ic8hKrgIt3mqk6cVOVrW2ozL4KdvHlqpBv9vDYWx9ysAgENAdvy4FoGF+KFdhR7nQTNm5J0ctAeOwn+3g==
object-keys@^1.1.1:
version "1.1.1"
@@ -2908,20 +2910,20 @@ resolve-pkg-maps@^1.0.0:
integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
resolve@^1.1.7, resolve@^1.22.2, resolve@^1.22.4:
version "1.22.6"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==
version "1.22.8"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
dependencies:
is-core-module "^2.13.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^2.0.0-next.4:
version "2.0.0-next.4"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.4.tgz#3d37a113d6429f496ec4752d2a2e58efb1fd4660"
integrity sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==
version "2.0.0-next.5"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c"
integrity sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==
dependencies:
is-core-module "^2.9.0"
is-core-module "^2.13.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
@@ -3131,9 +3133,9 @@ strip-json-comments@^3.1.1:
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
style-to-object@^0.4.1:
version "0.4.2"
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.2.tgz#a8247057111dea8bd3b8a1a66d2d0c9cf9218a54"
integrity sha512-1JGpfPB3lo42ZX8cuPrheZbfQ6kqPPnPHlKMyeRYtfKD+0jG+QsXgXN57O/dvJlzlB2elI6dGmrPnl5VPQFPaA==
version "0.4.4"
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.4.tgz#266e3dfd56391a7eefb7770423612d043c3f33ec"
integrity sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==
dependencies:
inline-style-parser "0.1.1"
@@ -3339,6 +3341,11 @@ unbox-primitive@^1.0.2:
has-symbols "^1.0.3"
which-boxed-primitive "^1.0.2"
undici-types@~5.25.1:
version "5.25.3"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.25.3.tgz#e044115914c85f0bcbb229f346ab739f064998c3"
integrity sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==
unified@^10.0.0:
version "10.1.2"
resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df"
@@ -3544,9 +3551,9 @@ yallist@^4.0.0:
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml@^2.1.1:
version "2.3.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.2.tgz#f522db4313c671a0ca963a75670f1c12ea909144"
integrity sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==
version "2.3.3"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.3.tgz#01f6d18ef036446340007db8e016810e5d64aad9"
integrity sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==
yocto-queue@^0.1.0:
version "0.1.0"

View File

@@ -5,7 +5,9 @@
"packages/*",
"examples/*"
],
"dependencies": {},
"dependencies": {
"@changesets/cli": "^2.26.2"
},
"devDependencies": {
"lerna": "^7.1.5",
"ts-node": "^10.9.1",

View File

@@ -0,0 +1,13 @@
# cojson-simple-sync
## 0.5.0
### Minor Changes
- Adding a lot of performance improvements to cojson, add a stresstest for the twit example and make that run smoother in a lot of ways.
### Patch Changes
- Updated dependencies
- cojson-storage-sqlite@0.5.0
- cojson@0.5.0

View File

@@ -4,7 +4,7 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.4.13",
"version": "0.5.0",
"devDependencies": {
"@types/jest": "^29.5.3",
"@types/ws": "^8.5.5",
@@ -16,14 +16,16 @@
"typescript": "5.0.2"
},
"dependencies": {
"cojson": "^0.4.13",
"cojson-storage-sqlite": "^0.4.13",
"cojson": "^0.5.0",
"cojson-storage-sqlite": "^0.5.0",
"ws": "^8.13.0"
},
"scripts": {
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist && npm run add-shebang && chmod +x ./dist/index.js",
"add-shebang": "echo \"#!/usr/bin/env node\" | cat - ./dist/index.js > /tmp/out && mv /tmp/out ./dist/index.js",
"start": "node dist/index.js",
"inspect": "node --inspect dist/index.js",
"inspect-brk": "node --inspect-brk dist/index.js",
"test": "jest",
"prepublishOnly": "npm run build"
},

View File

@@ -1,4 +1,4 @@
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
import { AnonymousControlledAccount, LocalNode, cojsonInternals, cojsonReady } from "cojson";
import { WebSocketServer } from "ws";
import { SQLiteStorage } from "cojson-storage-sqlite";
import { websocketReadableStream, websocketWritableStream } from "./websocketStreams.js";
@@ -7,6 +7,10 @@ const wss = new WebSocketServer({ port: 4200 });
console.log("COJSON sync server listening on port " + wss.options.port);
await cojsonReady;
import { webcrypto } from 'node:crypto'
(globalThis as any).crypto = webcrypto
const agentSecret = cojsonInternals.newRandomAgentSecret();
const agentID = cojsonInternals.getAgentID(agentSecret);
@@ -39,7 +43,7 @@ wss.on("connection", function connection(ws, req) {
?.split(",")[0]
?.trim() || req.socket.remoteAddress;
const clientId = clientAddress + "@" + new Date().toISOString();
const clientId = clientAddress + "@" + new Date().toISOString() + Math.random().toString(36).slice(2);
localNode.syncManager.addPeer({
id: clientId,

View File

@@ -1,5 +1,81 @@
import { WebSocket } from "ws";
import { WritableStream, ReadableStream } from "isomorphic-streams";
import { SyncMessage } from "cojson";
let msgsInThisInterval = 0;
let msgsOutThisInterval = 0;
let txsInThisInterval = 0;
let txsOutThisInterval = 0;
let msgsInLastInterval = 0;
let msgsOutLastInterval = 0;
let txsInLastInterval = 0;
let txsOutLastInterval = 0;
let maxMsgsInPerS = 0;
let maxMsgsOutPerS = 0;
let maxTxsInPerS = 0;
let maxTxsOutPerS = 0;
let lastInterval = Date.now();
const interval = 1_000;
setInterval(() => {
const dt = (Date.now() - lastInterval) / 1000;
maxMsgsInPerS = Math.max(
maxMsgsInPerS,
Math.round(msgsInThisInterval / dt)
);
maxMsgsOutPerS = Math.max(
maxMsgsOutPerS,
Math.round(msgsOutThisInterval / dt)
);
maxTxsInPerS = Math.max(maxTxsInPerS, Math.round(txsInThisInterval / dt));
maxTxsOutPerS = Math.max(
maxTxsOutPerS,
Math.round(txsOutThisInterval / dt)
);
if (
msgsInThisInterval ||
msgsOutThisInterval ||
txsInThisInterval ||
txsOutThisInterval
) {
console.log("++++++++++++++++++++++++++++++");
console.log(
"DT",
dt,
"Msgs in:",
msgsInThisInterval,
"out:",
msgsOutThisInterval,
"txs in:",
txsInThisInterval,
"out:",
txsOutThisInterval
);
console.log(
"MAX/s",
"Msgs in:",
maxMsgsInPerS,
"out:",
maxMsgsOutPerS,
"txs in:",
maxTxsInPerS,
"out:",
maxTxsOutPerS
);
}
msgsInLastInterval = msgsInThisInterval;
msgsOutLastInterval = msgsOutThisInterval;
txsInLastInterval = txsInThisInterval;
txsOutLastInterval = txsOutThisInterval;
msgsInThisInterval = 0;
msgsOutThisInterval = 0;
txsInThisInterval = 0;
txsOutThisInterval = 0;
lastInterval = Date.now();
}, interval);
export function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
@@ -13,6 +89,24 @@ export function websocketReadableStream<T>(ws: WebSocket) {
event.data
);
const msg = JSON.parse(event.data);
msgsInThisInterval++;
const syncMsg = msg as SyncMessage;
if (syncMsg.action === "content") {
txsInThisInterval +=
(syncMsg.header ? 1 : 0) +
Object.values(syncMsg.new).reduce(
(sum, sess) => sess.newTransactions.length + sum,
0
);
}
if (txsInLastInterval > 500 || txsOutLastInterval > 1_000) {
ws.pause();
const waitTime = Math.min(500, Math.max(txsOutLastInterval / 20, txsInLastInterval / 10));
// console.log("Throttling", waitTime);
setTimeout(() => ws.resume(), waitTime);
}
if (msg.type === "ping") {
// console.debug(
// "Got ping from",
@@ -57,6 +151,16 @@ export function websocketWritableStream<T>(ws: WebSocket) {
},
write(chunk) {
msgsOutThisInterval++;
const syncMsg = chunk as SyncMessage;
if (syncMsg.action === "content") {
txsOutThisInterval +=
(syncMsg.header ? 1 : 0) +
Object.values(syncMsg.new).reduce(
(sum, sess) => sess.newTransactions.length + sum,
0
);
}
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.

View File

@@ -0,0 +1,12 @@
# cojson-storage-indexeddb
## 0.5.0
### Minor Changes
- Adding a lot of performance improvements to cojson, add a stresstest for the twit example and make that run smoother in a lot of ways.
### Patch Changes
- Updated dependencies
- cojson@0.5.0

View File

@@ -1,11 +1,11 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.4.13",
"version": "0.5.0",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.4.13",
"cojson": "^0.5.0",
"typescript": "^5.1.6"
},
"devDependencies": {

View File

@@ -61,6 +61,9 @@ test("Should be able to sync data to database and then load that from a new node
);
const map2 = await node2.load(map.id);
if (map2 === "unavailable") {
throw new Error("Map is unavailable");
}
expect(map2.get("hello")).toBe("world");
});

View File

@@ -65,6 +65,11 @@ export class IDBStorage {
if (result.value) {
await this.handleSyncMessage(result.value);
// console.log(
// "IDB: handling msg",
// result.value.id,
// result.value.action
// );
}
}
})();
@@ -89,7 +94,7 @@ export class IDBStorage {
localNodeAsPeer.outgoing
);
return storageAsPeer;
return { ...storageAsPeer, priority: 100 };
}
static async open(

View File

@@ -0,0 +1,12 @@
# cojson-storage-sqlite
## 0.5.0
### Minor Changes
- Adding a lot of performance improvements to cojson, add a stresstest for the twit example and make that run smoother in a lot of ways.
### Patch Changes
- Updated dependencies
- cojson@0.5.0

View File

@@ -1,13 +1,13 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.4.13",
"version": "0.5.0",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.5.2",
"cojson": "^0.4.13",
"cojson": "^0.5.0",
"typescript": "^5.1.6"
},
"scripts": {

View File

@@ -93,7 +93,7 @@ export class SQLiteStorage {
localNodeAsPeer.outgoing
);
return storageAsPeer;
return {...storageAsPeer, priority: 100};
}
static async open(

View File

@@ -0,0 +1,17 @@
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",
},
};

View File

@@ -0,0 +1,12 @@
# cojson-transport-nodejs-ws
## 0.5.0
### Minor Changes
- Adding a lot of performance improvements to cojson, add a stresstest for the twit example and make that run smoother in a lot of ways.
### Patch Changes
- Updated dependencies
- cojson@0.5.0

View File

@@ -0,0 +1,18 @@
{
"name": "cojson-transport-nodejs-ws",
"type": "module",
"version": "0.5.0",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.5.0",
"typescript": "^5.1.6",
"ws": "^8.14.2"
},
"scripts": {
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
}
}

View File

@@ -0,0 +1,88 @@
import { WebSocket } from "ws";
import { WritableStream, ReadableStream } from "isomorphic-streams";
export function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
return new ReadableStream<T>({
start(controller) {
ws.addEventListener("message", (event) => {
if (typeof event.data !== "string")
return console.warn(
"Got non-string message from client",
event.data
);
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.addEventListener("close", () => controller.close());
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
},
cancel() {
ws.close();
},
});
}
export function websocketWritableStream<T>(ws: WebSocket) {
return new WritableStream<T>({
start(controller) {
ws.addEventListener("close", () =>
controller.error(
new Error("The WebSocket closed unexpectedly!")
)
);
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
if (ws.readyState === WebSocket.OPEN) {
return;
}
return new Promise((resolve) =>
ws.addEventListener("open", resolve, { once: true })
);
},
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);
});
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "ES2020",
"moduleResolution": "bundler",
"moduleDetection": "force",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},
"include": ["./src/**/*"],
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
# cojson
## 0.5.0
### Minor Changes
- Adding a lot of performance improvements to cojson, add a stresstest for the twit example and make that run smoother in a lot of ways.

View File

@@ -5,8 +5,9 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.4.13",
"version": "0.5.0",
"devDependencies": {
"@noble/curves": "^1.2.0",
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
@@ -17,8 +18,8 @@
"typescript": "5.0.2"
},
"dependencies": {
"@hazae41/berith": "^1.2.6",
"@noble/ciphers": "^0.1.3",
"@noble/curves": "^1.1.0",
"@scure/base": "^1.1.1",
"hash-wasm": "^4.9.0",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"

View File

@@ -1,4 +1,3 @@
import { randomBytes } from "@noble/hashes/utils";
import { AnyCoValue, CoValue } from "./coValue.js";
import {
Encrypted,
@@ -28,10 +27,7 @@ import { Group } from "./coValues/group.js";
import { LocalNode } from "./localNode.js";
import { CoValueKnownState, NewContentMessage } from "./sync.js";
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
import {
AccountID,
GeneralizedControlledAccount,
} from "./coValues/account.js";
import { AccountID, GeneralizedControlledAccount } from "./coValues/account.js";
import { Stringified, parseJSON, stableStringify } from "./jsonStringify.js";
import { coreToCoValue } from "./coreToCoValue.js";
import { expectGroup } from "./typeUtils/expectGroup.js";
@@ -54,7 +50,10 @@ export function idforHeader(header: CoValueHeader): RawCoID {
}
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return `${accountID}_session_z${base58.encode(
(globalThis as any).crypto.getRandomValues(new Uint8Array(8))
)}`;
}
type SessionLog = {
@@ -95,23 +94,25 @@ export class CoValueCore {
id: RawCoID;
node: LocalNode;
header: CoValueHeader;
_sessions: { [key: SessionID]: SessionLog };
_sessionLogs: Map<SessionID, SessionLog>;
_cachedContent?: CoValue;
listeners: Set<(content?: CoValue) => void> = new Set();
_decryptionCache: {
[key: Encrypted<JsonValue[], JsonValue>]:
| JsonValue[]
| undefined;
[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined;
} = {};
currentlyAsyncApplyingTxDone?: Promise<void>;
_cachedKnownState?: CoValueKnownState;
_cachedDependentOn?: RawCoID[];
_cachedNewContentSinceEmpty?: NewContentMessage[] | undefined;
constructor(
header: CoValueHeader,
node: LocalNode,
internalInitSessions: { [key: SessionID]: SessionLog } = {}
internalInitSessions: Map<SessionID, SessionLog> = new Map()
) {
this.id = idforHeader(header);
this.header = header;
this._sessions = internalInitSessions;
this._sessionLogs = internalInitSessions;
this.node = node;
if (header.ruleset.type == "ownedByGroup") {
@@ -127,8 +128,8 @@ export class CoValueCore {
}
}
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
return this._sessions;
get sessionLogs(): Map<SessionID, SessionLog> {
return this._sessionLogs;
}
testWithDifferentAccount(
@@ -144,11 +145,22 @@ export class CoValueCore {
}
knownState(): CoValueKnownState {
if (this._cachedKnownState) {
return this._cachedKnownState;
} else {
const knownState = this.knownStateUncached();
this._cachedKnownState = knownState;
return knownState;
}
}
/** @internal */
knownStateUncached(): CoValueKnownState {
return {
id: this.id,
header: true,
sessions: Object.fromEntries(
Object.entries(this.sessions).map(([k, v]) => [
[...this.sessionLogs.entries()].map(([k, v]) => [
k,
v.transactions.length,
])
@@ -172,7 +184,7 @@ export class CoValueCore {
return {
sessionID,
txIndex: this.sessions[sessionID]?.transactions.length || 0,
txIndex: this.sessionLogs.get(sessionID)?.transactions.length || 0,
};
}
@@ -250,8 +262,17 @@ export class CoValueCore {
givenExpectedNewHash: Hash | undefined,
newSignature: Signature
): Promise<boolean> {
if (this.currentlyAsyncApplyingTxDone) {
await this.currentlyAsyncApplyingTxDone;
}
let resolveDone!: () => void;
this.currentlyAsyncApplyingTxDone = new Promise((resolve) => {
resolveDone = resolve;
});
const signerID = getAgentSignerID(
this.node.resolveAccountAgent(
await this.node.resolveAccountAgentAsync(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signer of transaction"
)
@@ -262,10 +283,11 @@ export class CoValueCore {
"Unknown agent",
accountOrAgentIDfromSessionID(sessionID)
);
resolveDone();
return false;
}
const nTxBefore = this.sessions[sessionID]?.transactions.length ?? 0;
const nTxBefore = this.sessionLogs.get(sessionID)?.transactions.length ?? 0;
// const beforeHash = performance.now();
const { expectedNewHash, newStreamingHash } =
@@ -276,7 +298,7 @@ export class CoValueCore {
// afterHash - beforeHash
// );
const nTxAfter = this.sessions[sessionID]?.transactions.length ?? 0;
const nTxAfter = this.sessionLogs.get(sessionID)?.transactions.length ?? 0;
if (nTxAfter !== nTxBefore) {
const newTransactionLengthBefore = newTransactions.length;
@@ -294,6 +316,7 @@ export class CoValueCore {
expectedNewHash,
givenExpectedNewHash,
});
resolveDone();
return false;
}
@@ -306,6 +329,7 @@ export class CoValueCore {
expectedNewHash,
signerID
);
resolveDone();
return false;
}
// const afterVerify = performance.now();
@@ -322,6 +346,7 @@ export class CoValueCore {
newStreamingHash
);
resolveDone();
return true;
}
@@ -332,10 +357,10 @@ export class CoValueCore {
expectedNewHash: Hash,
newStreamingHash: StreamingHash
) {
const transactions = this.sessions[sessionID]?.transactions ?? [];
const transactions = this.sessionLogs.get(sessionID)?.transactions ?? [];
transactions.push(...newTransactions);
const signatureAfter = this.sessions[sessionID]?.signatureAfter ?? {};
const signatureAfter = this.sessionLogs.get(sessionID)?.signatureAfter ?? {};
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
@@ -363,15 +388,18 @@ export class CoValueCore {
signatureAfter[transactions.length - 1] = newSignature;
}
this._sessions[sessionID] = {
this._sessionLogs.set(sessionID, {
transactions,
lastHash: expectedNewHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
signatureAfter: signatureAfter,
};
});
this._cachedContent = undefined;
this._cachedKnownState = undefined;
this._cachedDependentOn = undefined;
this._cachedNewContentSinceEmpty = undefined;
if (this.listeners.size > 0) {
const content = this.getCurrentContent();
@@ -395,7 +423,7 @@ export class CoValueCore {
newTransactions: Transaction[]
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
const streamingHash =
this.sessions[sessionID]?.streamingHash.clone() ??
this.sessionLogs.get(sessionID)?.streamingHash.clone() ??
new StreamingHash();
for (const transaction of newTransactions) {
streamingHash.update(transaction);
@@ -414,7 +442,7 @@ export class CoValueCore {
newTransactions: Transaction[]
): Promise<{ expectedNewHash: Hash; newStreamingHash: StreamingHash }> {
const streamingHash =
this.sessions[sessionID]?.streamingHash.clone() ??
this.sessionLogs.get(sessionID)?.streamingHash.clone() ??
new StreamingHash();
let before = performance.now();
for (const transaction of newTransactions) {
@@ -552,8 +580,9 @@ export class CoValueCore {
in: this.id,
tx: txID,
}
)
decrytedChanges = decryptedString && parseJSON(decryptedString);
);
decrytedChanges =
decryptedString && parseJSON(decryptedString);
this._decryptionCache[tx.encryptedChanges] =
decrytedChanges;
}
@@ -725,12 +754,18 @@ export class CoValueCore {
}
getTx(txID: TransactionID): Transaction | undefined {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
return this.sessionLogs.get(txID.sessionID)?.transactions[txID.txIndex];
}
newContentSince(
knownState: CoValueKnownState | undefined
): NewContentMessage[] | undefined {
const isKnownStateEmpty = !knownState?.header && !knownState?.sessions;
if (isKnownStateEmpty && this._cachedNewContentSinceEmpty) {
return this._cachedNewContentSinceEmpty;
}
let currentPiece: NewContentMessage = {
action: "content",
id: this.id,
@@ -740,44 +775,55 @@ export class CoValueCore {
const pieces = [currentPiece];
const sentState: CoValueKnownState["sessions"] = {
...knownState?.sessions,
};
const sentState: CoValueKnownState["sessions"] = {};
let newTxsWereAdded = true;
let pieceSize = 0;
while (newTxsWereAdded) {
newTxsWereAdded = false;
for (const [sessionID, log] of Object.entries(this.sessions) as [
SessionID,
SessionLog
][]) {
const nextKnownSignatureIdx = Object.keys(log.signatureAfter)
.map(Number)
.sort((a, b) => a - b)
.find((idx) => idx >= (sentState[sessionID] ?? -1));
let sessionsTodoAgain: Set<SessionID> | undefined | "first" = "first";
const txsToAdd = log.transactions.slice(
sentState[sessionID] ?? 0,
nextKnownSignatureIdx === undefined
? undefined
: nextKnownSignatureIdx + 1
while (sessionsTodoAgain === "first" || (sessionsTodoAgain?.size || 0 > 0)) {
if (sessionsTodoAgain === "first") {
sessionsTodoAgain = undefined;
}
const sessionsTodo = sessionsTodoAgain ?? this.sessionLogs.keys();
for (const sessionIDKey of sessionsTodo) {
const sessionID = sessionIDKey as SessionID;
const log = this.sessionLogs.get(sessionID)!;
const knownStateForSessionID = knownState?.sessions[sessionID];
const sentStateForSessionID = sentState[sessionID];
const nextKnownSignatureIdx = getNextKnownSignatureIdx(
log,
knownStateForSessionID,
sentStateForSessionID
);
if (txsToAdd.length === 0) continue;
const firstNewTxIdx = sentStateForSessionID ?? knownStateForSessionID ?? 0;
const afterLastNewTxIdx = nextKnownSignatureIdx === undefined
? log.transactions.length
: nextKnownSignatureIdx + 1;
newTxsWereAdded = true;
const nNewTx = Math.max(0, afterLastNewTxIdx - firstNewTxIdx);
if (nNewTx === 0) {
sessionsTodoAgain?.delete(sessionID);
continue;
}
if (afterLastNewTxIdx < log.transactions.length) {
if (!sessionsTodoAgain) {
sessionsTodoAgain = new Set();
}
sessionsTodoAgain.add(sessionID);
}
const oldPieceSize = pieceSize;
pieceSize += txsToAdd.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0
);
for (let txIdx = firstNewTxIdx; txIdx < afterLastNewTxIdx; txIdx++) {
const tx = log.transactions[txIdx]!;
pieceSize += (tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length);
}
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
currentPiece = {
@@ -793,21 +839,26 @@ export class CoValueCore {
let sessionEntry = currentPiece.new[sessionID];
if (!sessionEntry) {
sessionEntry = {
after: sentState[sessionID] ?? 0,
after: sentStateForSessionID ?? knownStateForSessionID ?? 0,
newTransactions: [],
lastSignature: "WILL_BE_REPLACED" as Signature,
};
currentPiece.new[sessionID] = sessionEntry;
}
sessionEntry.newTransactions.push(...txsToAdd);
for (let txIdx = firstNewTxIdx; txIdx < afterLastNewTxIdx; txIdx++) {
const tx = log.transactions[txIdx]!;
sessionEntry.newTransactions.push(tx);
}
sessionEntry.lastSignature =
nextKnownSignatureIdx === undefined
? log.lastSignature!
: log.signatureAfter[nextKnownSignatureIdx]!;
sentState[sessionID] =
(sentState[sessionID] || 0) + txsToAdd.length;
(sentStateForSessionID ?? knownStateForSessionID ?? 0) + nNewTx;
}
}
@@ -819,10 +870,25 @@ export class CoValueCore {
return undefined;
}
if (isKnownStateEmpty) {
this._cachedNewContentSinceEmpty = piecesWithContent;
}
return piecesWithContent;
}
getDependedOnCoValues(): RawCoID[] {
if (this._cachedDependentOn) {
return this._cachedDependentOn;
} else {
const dependentOn = this.getDependedOnCoValuesUncached();
this._cachedDependentOn = dependentOn;
return dependentOn;
}
}
/** @internal */
getDependedOnCoValuesUncached(): RawCoID[] {
return this.header.ruleset.type === "group"
? expectGroup(this.getCurrentContent())
.keys()
@@ -831,7 +897,7 @@ export class CoValueCore {
? [
this.header.ruleset.group,
...new Set(
Object.keys(this._sessions)
[...this.sessionLogs.keys()]
.map((sessionID) =>
accountOrAgentIDfromSessionID(
sessionID as SessionID
@@ -846,3 +912,14 @@ export class CoValueCore {
: [];
}
}
function getNextKnownSignatureIdx(
log: SessionLog,
knownStateForSessionID?: number,
sentStateForSessionID?: number,
) {
return Object.keys(log.signatureAfter)
.map(Number)
.sort((a, b) => a - b)
.find((idx) => idx >= (sentStateForSessionID ?? knownStateForSessionID ?? -1));
}

View File

@@ -75,6 +75,13 @@ export class CoListView<
/** @category 6. Meta */
readonly _item!: Item;
/** @internal */
_cachedEntries?: {
value: Item;
madeAt: number;
opID: OpID;
}[];
/** @internal */
constructor(core: CoValueCore) {
this.id = core.id as CoID<this>;
@@ -126,10 +133,11 @@ export class CoListView<
change.before.txIndex
]?.[change.before.changeIdx];
if (!beforeEntry) {
throw new Error(
"Not yet implemented: insertion before missing op " +
change.before
);
// console.error(
// "Insertion before missing op " +
// change.before
// );
continue;
}
beforeEntry.predecessors.splice(0, 0, {
...txID,
@@ -148,10 +156,10 @@ export class CoListView<
change.after.txIndex
]?.[change.after.changeIdx];
if (!afterEntry) {
throw new Error(
"Not yet implemented: insertion after missing op " +
change.after
);
// console.error(
// "Insertion after missing op " + change.after
// );
continue;
}
afterEntry.successors.push({
...txID,
@@ -241,6 +249,20 @@ export class CoListView<
value: Item;
madeAt: number;
opID: OpID;
}[] {
if (this._cachedEntries) {
return this._cachedEntries;
}
const arr = this.entriesUncached();
this._cachedEntries = arr;
return arr;
}
/** @internal */
entriesUncached(): {
value: Item;
madeAt: number;
opID: OpID;
}[] {
const arr: {
value: Item;
@@ -542,6 +564,7 @@ export class MutableCoList<
this.beforeEnd = listAfter.beforeEnd;
this.insertions = listAfter.insertions;
this.deletionsByInsertion = listAfter.deletionsByInsertion;
this._cachedEntries = undefined;
}
/** Prepends `item` before the item currently at index `before`.
@@ -567,6 +590,7 @@ export class MutableCoList<
this.beforeEnd = listAfter.beforeEnd;
this.insertions = listAfter.insertions;
this.deletionsByInsertion = listAfter.deletionsByInsertion;
this._cachedEntries = undefined;
}
/** Deletes the item at index `at` from the list.
@@ -587,5 +611,6 @@ export class MutableCoList<
this.beforeEnd = listAfter.beforeEnd;
this.insertions = listAfter.insertions;
this.deletionsByInsertion = listAfter.deletionsByInsertion;
this._cachedEntries = undefined;
}
}

View File

@@ -1,4 +1,12 @@
import { ed25519, x25519 } from "@noble/curves/ed25519";
import {
initBundledOnce,
Ed25519SigningKey,
Ed25519VerifyingKey,
X25519StaticSecret,
Memory,
Ed25519Signature,
X25519PublicKey,
} from "@hazae41/berith";
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
import { JsonValue } from "./jsonValue.js";
import { base58 } from "@scure/base";
@@ -10,7 +18,13 @@ import { createBLAKE3 } from "hash-wasm";
import { Stringified, parseJSON, stableStringify } from "./jsonStringify.js";
let blake3Instance: Awaited<ReturnType<typeof createBLAKE3>>;
let blake3HashOnce: (data: Uint8Array) => Uint8Array;
let blake3HashOnce: (data: Uint8Array) => Uint8Array = () => {
throw new Error(
"cojson WASM dependencies not yet loaded; Make sure to import `cojsonReady` from `cojson` and await it before using any cojson functionality:\n\n" +
'import { cojsonReady } from "cojson";\n' +
"await cojsonReady;\n\n"
);
};
let blake3HashOnceWithContext: (
data: Uint8Array,
{ context }: { context: Uint8Array }
@@ -21,29 +35,36 @@ let blake3incrementalUpdateSLOW_WITH_DEVTOOLS: (
) => Uint8Array;
let blake3digestForState: (state: Uint8Array) => Uint8Array;
export const cryptoReady = new Promise<void>((resolve) => {
createBLAKE3()
.then((bl3) => {
blake3Instance = bl3;
blake3HashOnce = (data) => {
return bl3.init().update(data).digest("binary");
};
blake3HashOnceWithContext = (data, { context }) => {
return bl3.init().update(context).update(data).digest("binary");
};
blake3incrementalUpdateSLOW_WITH_DEVTOOLS = (state, data) => {
bl3.load(state).update(data);
return bl3.save();
};
blake3digestForState = (state) => {
return bl3.load(state).digest("binary");
};
resolve();
})
.catch((e) =>
console.error("Failed to load cryptography dependencies", e)
);
});
export const cryptoReady = Promise.all([
new Promise<void>((resolve) => {
createBLAKE3()
.then((bl3) => {
blake3Instance = bl3;
blake3HashOnce = (data) => {
return bl3.init().update(data).digest("binary");
};
blake3HashOnceWithContext = (data, { context }) => {
return bl3
.init()
.update(context)
.update(data)
.digest("binary");
};
blake3incrementalUpdateSLOW_WITH_DEVTOOLS = (state, data) => {
bl3.load(state).update(data);
return bl3.save();
};
blake3digestForState = (state) => {
return bl3.load(state).digest("binary");
};
resolve();
})
.catch((e) =>
console.error("Failed to load cryptography dependencies", e)
);
}),
initBundledOnce(),
]);
export type SignerSecret = `signerSecret_z${string}`;
export type SignerID = `signer_z${string}`;
@@ -59,7 +80,9 @@ const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export function newRandomSigner(): SignerSecret {
return `signerSecret_z${base58.encode(ed25519.utils.randomPrivateKey())}`;
return `signerSecret_z${base58.encode(
new Ed25519SigningKey().to_bytes().copyAndDispose()
)}`;
}
export function signerSecretToBytes(secret: SignerSecret): Uint8Array {
@@ -72,17 +95,22 @@ export function signerSecretFromBytes(bytes: Uint8Array): SignerSecret {
export function getSignerID(secret: SignerSecret): SignerID {
return `signer_z${base58.encode(
ed25519.getPublicKey(
base58.decode(secret.substring("signerSecret_z".length))
Ed25519SigningKey.from_bytes(
new Memory(base58.decode(secret.substring("signerSecret_z".length)))
)
.public()
.to_bytes()
.copyAndDispose()
)}`;
}
export function sign(secret: SignerSecret, message: JsonValue): Signature {
const signature = ed25519.sign(
textEncoder.encode(stableStringify(message)),
base58.decode(secret.substring("signerSecret_z".length))
);
const signature = Ed25519SigningKey.from_bytes(
new Memory(base58.decode(secret.substring("signerSecret_z".length)))
)
.sign(new Memory(textEncoder.encode(stableStringify(message))))
.to_bytes()
.copyAndDispose();
return `signature_z${base58.encode(signature)}`;
}
@@ -91,15 +119,20 @@ export function verify(
message: JsonValue,
id: SignerID
): boolean {
return ed25519.verify(
base58.decode(signature.substring("signature_z".length)),
textEncoder.encode(stableStringify(message)),
base58.decode(id.substring("signer_z".length))
return new Ed25519VerifyingKey(
new Memory(base58.decode(id.substring("signer_z".length)))
).verify(
new Memory(textEncoder.encode(stableStringify(message))),
new Ed25519Signature(
new Memory(base58.decode(signature.substring("signature_z".length)))
)
);
}
export function newRandomSealer(): SealerSecret {
return `sealerSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`;
return `sealerSecret_z${base58.encode(
new X25519StaticSecret().to_bytes().copyAndDispose()
)}`;
}
export function sealerSecretToBytes(secret: SealerSecret): Uint8Array {
@@ -112,9 +145,12 @@ export function sealerSecretFromBytes(bytes: Uint8Array): SealerSecret {
export function getSealerID(secret: SealerSecret): SealerID {
return `sealer_z${base58.encode(
x25519.getPublicKey(
base58.decode(secret.substring("sealerSecret_z".length))
X25519StaticSecret.from_bytes(
new Memory(base58.decode(secret.substring("sealerSecret_z".length)))
)
.to_public()
.to_bytes()
.copyAndDispose()
)}`;
}
@@ -180,7 +216,10 @@ export function seal<T extends JsonValue>({
const plaintext = textEncoder.encode(stableStringify(message));
const sharedSecret = x25519.getSharedSecret(senderPriv, sealerPub);
const sharedSecret = X25519StaticSecret.from_bytes(new Memory(senderPriv))
.diffie_hellman(X25519PublicKey.from_bytes(new Memory(sealerPub)))
.to_bytes()
.copyAndDispose();
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
plaintext
@@ -205,7 +244,10 @@ export function unseal<T extends JsonValue>(
const sealedBytes = base64URLtoBytes(sealed.substring("sealed_U".length));
const sharedSecret = x25519.getSharedSecret(sealerPriv, senderPub);
const sharedSecret = X25519StaticSecret.from_bytes(new Memory(sealerPriv))
.diffie_hellman(X25519PublicKey.from_bytes(new Memory(senderPub)))
.to_bytes()
.copyAndDispose();
const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(
sealedBytes

View File

@@ -19,7 +19,7 @@ import {
Group,
secretSeedFromInviteSecret,
} from "./coValues/group.js";
import { Peer, SyncManager } from "./sync.js";
import { Peer, PeerID, SyncManager } from "./sync.js";
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
import { CoID } from "./coValue.js";
import {
@@ -153,6 +153,11 @@ export class LocalNode {
}
const account = await accountPromise;
if (account === "unavailable") {
throw new Error("Account unavailable from all peers");
}
const controlledAccount = new ControlledAccount(
account.core,
accountSecret
@@ -199,14 +204,36 @@ export class LocalNode {
}
/** @internal */
loadCoValue(id: RawCoID, onProgress?: (progress: number) => void): Promise<CoValueCore> {
async loadCoValueCore(
id: RawCoID,
options: {
dontLoadFrom?: PeerID;
dontWaitFor?: PeerID;
onProgress?: (progress: number) => void;
} = {}
): Promise<CoValueCore | "unavailable"> {
let entry = this.coValues[id];
if (!entry) {
entry = newLoadingState(onProgress);
const peersToWaitFor = new Set(
Object.values(this.syncManager.peers)
.filter((peer) => peer.role === "server")
.map((peer) => peer.id)
);
if (options.dontWaitFor) peersToWaitFor.delete(options.dontWaitFor);
entry = newLoadingState(peersToWaitFor, options.onProgress);
this.coValues[id] = entry;
this.syncManager.loadFromPeers(id);
this.syncManager
.loadFromPeers(id, options.dontLoadFrom)
.catch((e) => {
console.error(
"Error loading from peers",
id,
e
);
});
}
if (entry.state === "loaded") {
return Promise.resolve(entry.coValue);
@@ -221,25 +248,38 @@ export class LocalNode {
*
* @category 3. Low-level
*/
async load<T extends CoValue>(id: CoID<T>, onProgress?: (progress: number) => void): Promise<T> {
return (await this.loadCoValue(id, onProgress)).getCurrentContent() as T;
async load<T extends CoValue>(
id: CoID<T>,
onProgress?: (progress: number) => void
): Promise<T | "unavailable"> {
const core = await this.loadCoValueCore(id, { onProgress });
if (core === "unavailable") {
return "unavailable";
}
return core.getCurrentContent() as T;
}
/** @category 3. Low-level */
subscribe<T extends CoValue>(
id: CoID<T>,
callback: (update: T) => void
callback: (update: T | "unavailable") => void
): () => void {
let stopped = false;
let unsubscribe!: () => void;
console.log("Subscribing to " + id);
// console.log("Subscribing to " + id);
this.load(id)
.then((coValue) => {
if (stopped) {
return;
}
if (coValue === "unavailable") {
callback("unavailable");
return;
}
unsubscribe = coValue.subscribe(callback);
})
.catch((e) => {
@@ -260,6 +300,12 @@ export class LocalNode {
): Promise<void> {
const groupOrOwnedValue = await this.load(groupOrOwnedValueID);
if (groupOrOwnedValue === "unavailable") {
throw new Error(
"Trying to accept invite: Group/owned value unavailable from all peers"
);
}
if (groupOrOwnedValue.core.header.ruleset.type === "ownedByGroup") {
return this.acceptInvite(
groupOrOwnedValue.core.header.ruleset.group as CoID<Group>,
@@ -325,7 +371,7 @@ export class LocalNode {
: "reader"
);
group.core._sessions = groupAsInvite.core.sessions;
group.core._sessionLogs = groupAsInvite.core.sessionLogs;
group.core._cachedContent = undefined;
for (const groupListener of group.core.listeners) {
@@ -400,17 +446,6 @@ export class LocalNode {
},
});
console.log(
"Creating read key",
getAgentSealerSecret(agentSecret),
getAgentSealerID(accountAgentID),
account.id,
account.core.nextTransactionID(),
"in session",
account.core.node.currentSessionID,
"=",
sealed
);
editable.set(
`${readKey.id}_for_${accountAgentID}`,
sealed,
@@ -432,16 +467,13 @@ export class LocalNode {
const accountOnThisNode = this.expectCoValueLoaded(account.id);
accountOnThisNode._sessions = {
...account.core.sessions,
};
accountOnThisNode._sessionLogs = new Map(account.core.sessionLogs);
accountOnThisNode._cachedContent = undefined;
const profileOnThisNode = this.createCoValue(profile.core.header);
profileOnThisNode._sessions = {
...profile.core.sessions,
};
profileOnThisNode._sessionLogs = new Map(profile.core.sessionLogs);
profileOnThisNode._cachedContent = undefined;
return new ControlledAccount(accountOnThisNode, agentSecret);
@@ -475,6 +507,41 @@ export class LocalNode {
return new Account(coValue).getCurrentAgentID();
}
async resolveAccountAgentAsync(
id: AccountID | AgentID,
expectation?: string
): Promise<AgentID> {
if (isAgentID(id)) {
return id;
}
const coValue = await this.loadCoValueCore(id);
if (coValue === "unavailable") {
throw new Error(
`${
expectation ? expectation + ": " : ""
}Account ${id} is unavailable from all peers`
);
}
if (
coValue.header.type !== "comap" ||
coValue.header.ruleset.type !== "group" ||
!coValue.header.meta ||
!("type" in coValue.header.meta) ||
coValue.header.meta.type !== "account"
) {
throw new Error(
`${
expectation ? expectation + ": " : ""
}CoValue ${id} is not an account`
);
}
return new Account(coValue).getCurrentAgentID();
}
/**
* @deprecated use Account.createGroup() instead
*/
@@ -543,7 +610,7 @@ export class LocalNode {
const newCoValue = new CoValueCore(
entry.coValue.header,
newNode,
{ ...entry.coValue.sessions }
new Map(entry.coValue.sessionLogs)
);
newNode.coValues[coValueID as RawCoID] = {
@@ -575,17 +642,34 @@ export class LocalNode {
type CoValueState =
| {
state: "loading";
done: Promise<CoValueCore>;
resolve: (coValue: CoValueCore) => void;
done: Promise<CoValueCore | "unavailable">;
resolve: (coValue: CoValueCore | "unavailable") => void;
onProgress?: (progress: number) => void;
firstPeerState: {
[peerID: string]:
| {
type: "waiting";
done: Promise<void>;
resolve: () => void;
}
| { type: "available" }
| { type: "unavailable" };
};
}
| { state: "loaded"; coValue: CoValueCore; onProgress?: (progress: number) => void; };
| {
state: "loaded";
coValue: CoValueCore;
onProgress?: (progress: number) => void;
};
/** @internal */
export function newLoadingState(onProgress?: (progress: number) => void): CoValueState {
let resolve: (coValue: CoValueCore) => void;
export function newLoadingState(
currentPeerIds: Set<PeerID>,
onProgress?: (progress: number) => void
): CoValueState {
let resolve: (coValue: CoValueCore | "unavailable") => void;
const promise = new Promise<CoValueCore>((r) => {
const promise = new Promise<CoValueCore | "unavailable">((r) => {
resolve = r;
});
@@ -593,6 +677,15 @@ export function newLoadingState(onProgress?: (progress: number) => void): CoValu
state: "loading",
done: promise,
resolve: resolve!,
onProgress
onProgress,
firstPeerState: Object.fromEntries(
[...currentPeerIds].map((id) => {
let resolve: () => void;
const done = new Promise<void>((r) => {
resolve = r;
});
return [id, { type: "waiting", done, resolve: resolve! }];
})
),
};
}

View File

@@ -2,10 +2,7 @@ import { CoID } from "./coValue.js";
import { MapOpPayload } from "./coValues/coMap.js";
import { JsonValue } from "./jsonValue.js";
import { KeyID } from "./crypto.js";
import {
CoValueCore,
Transaction,
} from "./coValueCore.js";
import { CoValueCore, Transaction } from "./coValueCore.js";
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
import { Account, AccountID, Profile } from "./coValues/account.js";
@@ -31,19 +28,19 @@ export function determineValidTransactions(
coValue: CoValueCore
): { txID: TransactionID; tx: Transaction }[] {
if (coValue.header.ruleset.type === "group") {
const allTransactionsSorted = Object.entries(coValue.sessions).flatMap(
([sessionID, sessionLog]) => {
return sessionLog.transactions.map((tx, txIndex) => ({
sessionID,
txIndex,
tx,
})) as {
sessionID: SessionID;
txIndex: number;
tx: Transaction;
}[];
}
);
const allTransactionsSorted = [
...coValue.sessionLogs.entries(),
].flatMap(([sessionID, sessionLog]) => {
return sessionLog.transactions.map((tx, txIndex) => ({
sessionID,
txIndex,
tx,
})) as {
sessionID: SessionID;
txIndex: number;
tx: Transaction;
}[];
});
allTransactionsSorted.sort((a, b) => {
return a.tx.madeAt - b.tx.madeAt;
@@ -242,11 +239,9 @@ export function determineValidTransactions(
throw new Error("Group must be a map");
}
return Object.entries(coValue.sessions).flatMap(
return [...coValue.sessionLogs.entries()].flatMap(
([sessionID, sessionLog]) => {
const transactor = accountOrAgentIDfromSessionID(
sessionID as SessionID
);
const transactor = accountOrAgentIDfromSessionID(sessionID);
return sessionLog.transactions
.filter((tx) => {
@@ -266,16 +261,16 @@ export function determineValidTransactions(
);
})
.map((tx, txIndex) => ({
txID: { sessionID: sessionID as SessionID, txIndex },
txID: { sessionID: sessionID, txIndex },
tx,
}));
}
);
} else if (coValue.header.ruleset.type === "unsafeAllowAll") {
return Object.entries(coValue.sessions).flatMap(
return [...coValue.sessionLogs.entries()].flatMap(
([sessionID, sessionLog]) => {
return sessionLog.transactions.map((tx, txIndex) => ({
txID: { sessionID: sessionID as SessionID, txIndex },
txID: { sessionID: sessionID, txIndex },
tx,
}));
}

View File

@@ -18,11 +18,11 @@ export function connectedPeers(
peer2role?: Peer["role"];
} = {}
): [Peer, Peer] {
const [inRx1, inTx1] = newStreamPair<SyncMessage>();
const [outRx1, outTx1] = newStreamPair<SyncMessage>();
const [inRx1, inTx1] = newStreamPair<SyncMessage>(peer1id + "_in");
const [outRx1, outTx1] = newStreamPair<SyncMessage>(peer1id + "_out");
const [inRx2, inTx2] = newStreamPair<SyncMessage>();
const [outRx2, outTx2] = newStreamPair<SyncMessage>();
const [inRx2, inTx2] = newStreamPair<SyncMessage>(peer2id + "_in");
const [outRx2, outTx2] = newStreamPair<SyncMessage>(peer2id + "_out");
void outRx2
.pipeThrough(
@@ -37,7 +37,7 @@ export function connectedPeers(
JSON.stringify(
chunk,
(k, v) =>
(k === "changes" || k === "encryptedChanges")
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
2
@@ -62,7 +62,7 @@ export function connectedPeers(
JSON.stringify(
chunk,
(k, v) =>
(k === "changes" || k === "encryptedChanges")
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
2
@@ -91,7 +91,10 @@ export function connectedPeers(
return [peer1AsPeer, peer2AsPeer];
}
export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
export function newStreamPair<T>(
pairName?: string
): [ReadableStream<T>, WritableStream<T>] {
let queueLength = 0;
let readerClosed = false;
let resolveEnqueue: (enqueue: (item: T) => void) => void;
@@ -104,6 +107,22 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
resolveClose = resolve;
});
let queueWasOverflowing = false;
function maybeReportQueueLength() {
if (queueLength >= 100) {
queueWasOverflowing = true;
if (queueLength % 100 === 0) {
console.warn(pairName, "overflowing queue length", queueLength);
}
} else {
if (queueWasOverflowing) {
console.debug(pairName, "ok queue length", queueLength);
queueWasOverflowing = false;
}
}
}
const readable = new ReadableStream<T>({
async start(controller) {
resolveEnqueue(controller.enqueue.bind(controller));
@@ -114,12 +133,26 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
console.log("Manually closing reader");
readerClosed = true;
},
});
}).pipeThrough(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new TransformStream<any, any>({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void }
) {
queueLength -= 1;
maybeReportQueueLength();
controller.enqueue(chunk);
},
})
) as ReadableStream<T>;
let lastWritePromise = Promise.resolve();
const writable = new WritableStream<T>({
async write(chunk) {
queueLength += 1;
maybeReportQueueLength();
const enqueue = await enqueuePromise;
if (readerClosed) {
throw new Error("Reader closed");

View File

@@ -2,7 +2,6 @@ import { Signature } from "./crypto.js";
import { CoValueHeader, Transaction } from "./coValueCore.js";
import { CoValueCore } from "./coValueCore.js";
import { LocalNode } from "./localNode.js";
import { newLoadingState } from "./localNode.js";
import {
ReadableStream,
WritableStream,
@@ -67,6 +66,7 @@ export interface Peer {
outgoing: WritableStream<SyncMessage>;
role: "peer" | "server" | "client";
delayOnError?: number;
priority?: number;
}
export interface PeerState {
@@ -77,6 +77,7 @@ export interface PeerState {
outgoing: WritableStreamDefaultWriter<SyncMessage>;
role: "peer" | "server" | "client";
delayOnError?: number;
priority?: number;
}
export function combinedKnownStates(
@@ -107,13 +108,30 @@ export function combinedKnownStates(
export class SyncManager {
peers: { [key: PeerID]: PeerState } = {};
local: LocalNode;
requestedSyncs: { [id: RawCoID]: {done: Promise<void>, nRequestsThisTick: number} | undefined } = {};
constructor(local: LocalNode) {
this.local = local;
}
loadFromPeers(id: RawCoID) {
for (const peer of Object.values(this.peers)) {
peersInPriorityOrder(): PeerState[] {
return Object.values(this.peers).sort((a, b) => {
const aPriority = a.priority || 0;
const bPriority = b.priority || 0;
return bPriority - aPriority;
});
}
async loadFromPeers(id: RawCoID, excludePeer?: PeerID) {
for (const peer of this.peersInPriorityOrder()) {
if (peer.id === excludePeer) {
continue;
}
if (peer.role !== "server") {
continue;
}
// console.log("loading", id, "from", peer.id);
peer.outgoing
.write({
action: "load",
@@ -124,6 +142,44 @@ export class SyncManager {
.catch((e) => {
console.error("Error writing to peer", e);
});
const coValueEntry = this.local.coValues[id];
if (coValueEntry?.state !== "loading") {
continue;
}
const firstStateEntry = coValueEntry.firstPeerState[peer.id];
if (firstStateEntry?.type !== "waiting") {
throw new Error("Expected firstPeerState to be waiting " + id);
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (this.local.coValues[id]?.state === "loading") {
// console.warn(
// "Timeout waiting for peer to load",
// id,
// "from",
// peer.id,
// "and it hasn't loaded from other peers yet"
// );
}
resolve();
}, 1000);
firstStateEntry.done
.then(() => {
clearTimeout(timeout);
resolve();
})
.catch((e) => {
clearTimeout(timeout);
console.error(
"Error waiting for peer to load",
id,
"from",
peer.id,
e
);
resolve();
});
});
}
}
@@ -139,6 +195,7 @@ export class SyncManager {
return await this.handleKnownState(msg, peer);
}
case "content":
await new Promise<void>((resolve) => setTimeout(resolve, 0));
return await this.handleNewContent(msg, peer);
case "done":
return await this.handleUnsubscribe(msg);
@@ -190,13 +247,11 @@ export class SyncManager {
) {
const coValue = this.local.expectCoValueLoaded(id);
for (const dependentCoID of coValue.getDependedOnCoValues()) {
await this.tellUntoldKnownStateIncludingDependencies(
dependentCoID,
peer,
asDependencyOf || id
);
}
await Promise.all(coValue.getDependedOnCoValues().map(dependentCoID => this.tellUntoldKnownStateIncludingDependencies(
dependentCoID,
peer,
asDependencyOf || id
)));
if (!peer.toldKnownState.has(id)) {
await this.trySendToPeer(peer, {
@@ -212,9 +267,7 @@ export class SyncManager {
async sendNewContentIncludingDependencies(id: RawCoID, peer: PeerState) {
const coValue = this.local.expectCoValueLoaded(id);
for (const id of coValue.getDependedOnCoValues()) {
await this.sendNewContentIncludingDependencies(id, peer);
}
await Promise.all(coValue.getDependedOnCoValues().map(id => this.sendNewContentIncludingDependencies(id, peer)));
const newContentPieces = coValue.newContentSince(
peer.optimisticKnownStates[id]
@@ -263,6 +316,7 @@ export class SyncManager {
toldKnownState: new Set(),
role: peer.role,
delayOnError: peer.delayOnError,
priority: peer.priority,
};
this.peers[peer.id] = peerState;
@@ -271,6 +325,7 @@ export class SyncManager {
for (const id of Object.keys(
this.local.coValues
) as RawCoID[]) {
// console.log("subscribing to after peer added", id, peer.id)
await this.subscribeToIncludingDependencies(id, peerState);
peerState.optimisticKnownStates[id] = {
@@ -287,7 +342,19 @@ export class SyncManager {
try {
for await (const msg of peerState.incoming) {
try {
await this.handleSyncMessage(msg, peerState);
// await this.handleSyncMessage(msg, peerState);
this.handleSyncMessage(msg, peerState).catch((e) => {
console.error(
new Date(),
`Error reading from peer ${peer.id}, handling msg`,
JSON.stringify(msg, (k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v
),
e
);
});
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
@@ -321,18 +388,25 @@ export class SyncManager {
}
trySendToPeer(peer: PeerState, msg: SyncMessage) {
if (!this.peers[peer.id]) {
// already disconnected, return to drain potential queue
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const start = Date.now()
const start = Date.now();
peer.outgoing
.write(msg)
.then(() => {
const end = Date.now();
if (end - start > 1000) {
console.error(
new Error(
`Writing to peer "${peer.id}" took ${Math.round((Date.now() - start)/100)/10}s - this should never happen as write should resolve quickly or error`
)
);
// console.error(
// new Error(
// `Writing to peer "${peer.id}" took ${
// Math.round((Date.now() - start) / 100) / 10
// }s - this should never happen as write should resolve quickly or error`
// )
// );
} else {
resolve();
}
@@ -352,42 +426,42 @@ export class SyncManager {
}
async handleLoad(msg: LoadMessage, peer: PeerState) {
const entry = this.local.coValues[msg.id];
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
let entry = this.local.coValues[msg.id];
if (!entry || entry.state === "loading") {
if (!entry) {
await new Promise<void>((resolve) => {
this.local
.loadCoValue(msg.id)
.then(() => resolve())
.catch((e) => {
console.error(
"Error loading coValue in handleLoad",
e
);
resolve();
});
setTimeout(resolve, 1000);
if (!entry) {
// console.log(`Loading ${msg.id} from all peers except ${peer.id}`);
this.local
.loadCoValueCore(msg.id, {
dontLoadFrom: peer.id,
dontWaitFor: peer.id,
})
.catch((e) => {
console.error("Error loading coValue in handleLoad", e);
});
}
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
peer.toldKnownState.add(msg.id);
await this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
});
return;
entry = this.local.coValues[msg.id]!;
}
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
if (entry.state === "loading") {
const loaded = await entry.done;
if (loaded === "unavailable") {
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
peer.toldKnownState.add(msg.id);
await this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
});
return;
}
}
await this.tellUntoldKnownStateIncludingDependencies(msg.id, peer);
await this.sendNewContentIncludingDependencies(msg.id, peer);
}
@@ -402,9 +476,15 @@ export class SyncManager {
if (!entry) {
if (msg.asDependencyOf) {
if (this.local.coValues[msg.asDependencyOf]) {
entry = newLoadingState();
this.local.coValues[msg.id] = entry;
this.local
.loadCoValueCore(msg.id, { dontLoadFrom: peer.id })
.catch((e) => {
console.error(
`Error loading coValue ${msg.id} to create loading state, as dependency of ${msg.asDependencyOf}`,
e
);
});
entry = this.local.coValues[msg.id]!; // must exist after loadCoValueCore
} else {
throw new Error(
"Expected coValue dependency entry to be created, missing subscribe?"
@@ -418,6 +498,29 @@ export class SyncManager {
}
if (entry.state === "loading") {
const availableOnPeer = peer.optimisticKnownStates[msg.id]?.header;
const firstPeerStateEntry = entry.firstPeerState[peer.id];
if (firstPeerStateEntry?.type === "waiting") {
firstPeerStateEntry.resolve();
}
entry.firstPeerState[peer.id] = availableOnPeer
? { type: "available" }
: { type: "unavailable" };
// console.log(
// "Marking",
// msg.id,
// "as",
// entry.firstPeerState[peer.id]?.type,
// "from",
// peer.id
// );
if (
Object.values(entry.firstPeerState).every(
(s) => s.type === "unavailable"
)
) {
entry.resolve("unavailable");
}
return [];
}
@@ -449,6 +552,12 @@ export class SyncManager {
throw new Error("Expected header to be sent in first message");
}
const firstPeerStateEntry = entry.firstPeerState[peer.id];
if (firstPeerStateEntry?.type === "waiting") {
firstPeerStateEntry.resolve();
entry.firstPeerState[peer.id] = { type: "available" };
}
peerOptimisticKnownState.header = true;
const coValue = new CoValueCore(msg.header, this.local);
@@ -458,7 +567,7 @@ export class SyncManager {
entry = {
state: "loaded",
coValue: coValue,
onProgress: entry.onProgress
onProgress: entry.onProgress,
};
this.local.coValues[msg.id] = entry;
@@ -472,7 +581,7 @@ export class SyncManager {
msg.new
) as [SessionID, SessionNewContent][]) {
const ourKnownTxIdx =
coValue.sessions[sessionID]?.transactions.length;
coValue.sessionLogs.get(sessionID)?.transactions.length;
const theirFirstNewTxIdx = newContentForSession.after;
if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) {
@@ -499,7 +608,7 @@ export class SyncManager {
newContentForSession.lastSignature
);
const after = performance.now();
if (after - before > 10) {
if (after - before > 80) {
const totalTxLength = newTransactions
.map((t) =>
t.privacy === "private"
@@ -518,8 +627,13 @@ export class SyncManager {
);
}
const theirTotalnTxs = Object.values(peer.optimisticKnownStates[msg.id]?.sessions || {}).reduce((sum, nTxs) => sum + nTxs, 0);
const ourTotalnTxs = Object.values(coValue.sessions).reduce((sum, session) => sum + session.transactions.length, 0);
const theirTotalnTxs = Object.values(
peer.optimisticKnownStates[msg.id]?.sessions || {}
).reduce((sum, nTxs) => sum + nTxs, 0);
const ourTotalnTxs = [...coValue.sessionLogs.values()].reduce(
(sum, session) => sum + session.transactions.length,
0
);
entry.onProgress?.(ourTotalnTxs / theirTotalnTxs);
@@ -536,9 +650,11 @@ export class SyncManager {
continue;
}
peerOptimisticKnownState.sessions[sessionID] = Math.max(peerOptimisticKnownState.sessions[sessionID] || 0,
peerOptimisticKnownState.sessions[sessionID] = Math.max(
peerOptimisticKnownState.sessions[sessionID] || 0,
newContentForSession.after +
newContentForSession.newTransactions.length);
newContentForSession.newTransactions.length
);
}
if (resolveAfterDone) {
@@ -567,7 +683,38 @@ export class SyncManager {
}
async syncCoValue(coValue: CoValueCore) {
for (const peer of Object.values(this.peers)) {
if (this.requestedSyncs[coValue.id]) {
this.requestedSyncs[coValue.id]!.nRequestsThisTick++;
return this.requestedSyncs[coValue.id]!.done;
} else {
const done = new Promise<void>((resolve) => {
setTimeout(async () => {
delete this.requestedSyncs[coValue.id];
if (entry.nRequestsThisTick >= 2) {
console.log("Syncing", coValue.id, "for", entry.nRequestsThisTick, "requests");
}
await this.actuallySyncCoValue(coValue);
resolve();
}, 0);
});
const entry = {
done,
nRequestsThisTick: 1,
};
this.requestedSyncs[coValue.id] = entry;
return done;
}
}
async actuallySyncCoValue(coValue: CoValueCore) {
let blockingSince = performance.now();
for (const peer of this.peersInPriorityOrder()) {
if (performance.now() - blockingSince > 5) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
blockingSince = performance.now();
}
const optimisticKnownState = peer.optimisticKnownStates[coValue.id];
if (optimisticKnownState) {

Some files were not shown because too many files have changed in this diff Show More