Compare commits

...

81 Commits

Author SHA1 Message Date
Anselm
ceb92438f4 Release 2024-08-07 14:23:03 +01:00
Anselm Eickhoff
9bdd62ed4c Merge pull request #244 from gardencmp/anselm-jazz-187
Remove effectful API for loading/subscribing
2024-08-07 14:20:42 +01:00
Anselm Eickhoff
364060eaa7 Merge pull request #249 from Schniz/schniz/allow-optional-encoder-to-be-empty-upon-creation 2024-08-03 19:59:09 +01:00
Gal Schlezinger
a3ddc3d5e0 allow co.optional.encoded to be empty on creation 2024-08-03 21:53:13 +03:00
Anselm Eickhoff
185f747adb Merge pull request #248 from timolins/new-explorer-ux 2024-08-03 13:51:47 +01:00
Timo Lins
895d281088 Remove legacy inspector to resolve TS errors 2024-08-03 13:09:27 +02:00
Timo Lins
b44e4354f7 Merge branch 'new-explorer-ux' of https://github.com/timolins/jazz into new-explorer-ux 2024-08-03 12:59:18 +02:00
Timo Lins
3fcb0665ec Fix TS errors 2024-08-03 12:59:14 +02:00
Tobias Lins
be49d33ce5 Update co-stream-view.tsx 2024-08-03 12:24:30 +02:00
Timo Lins
c7dae1608b Add new entry point 2024-08-03 10:49:29 +02:00
Timo Lins
b020c5868b Move app to legacy app 2024-08-03 10:48:06 +02:00
Timo Lins
eae42d3afe Revert app.tsx changes 2024-08-03 10:47:51 +02:00
Anselm
a816e2436e Remove effectful API for loading/subscribing 2024-07-31 16:21:26 +01:00
Anselm
b09e35e372 release 2024-07-29 10:40:10 +01:00
Anselm
d2c8121c9c Fix storage option in jazz-react 2024-07-29 10:34:39 +01:00
Anselm
380bb88ffa Mostly complete OPFS implementation (single-tab only) 2024-07-29 10:33:18 +01:00
Timo Lins
e0e3726b3c Update title 2024-07-28 18:02:06 +02:00
Timo Lins
c2253a7979 Improve styling of blob page 2024-07-28 17:40:23 +02:00
Timo Lins
9d244226ec Redesign root page, add group support & costream pages
Co-authored-by: Tobias Lins <me@tobi.sh>
2024-07-28 17:26:34 +02:00
Timo Lins
71df5e3a59 Fix native browser controls 2024-07-28 14:53:26 +02:00
Timo Lins
3a738dad88 Persists path in url 2024-07-28 13:53:39 +02:00
Timo Lins
56d301cfde Refactor the new viewer 2024-07-27 23:16:37 +02:00
Timo Lins
5efec6d5ea Move to a table view 2024-07-27 20:58:55 +02:00
Timo Lins
32769b24f1 Add new inspector 2024-07-27 20:10:24 +02:00
Anselm
6ab53c263d Release 2024-07-26 17:23:02 +01:00
Anselm
e7f3e4e242 Increase disconnect timeout for now 2024-07-26 17:21:06 +01:00
Anselm Eickhoff
8bb5201647 Merge pull request #236 from timolins/patch-1
[Homepage] Make current year dynamic in footer
2024-07-22 15:55:16 +01:00
Timo Lins
a9fc94f53d Make current year dynamic in footer 2024-07-22 10:06:48 +02:00
Anselm Eickhoff
ca7c0510d1 Merge pull request #234 from Schniz/schniz/co-optional 2024-07-21 10:14:41 +01:00
Gal Schlezinger
1bf16f0859 add co.optional syntax 2024-07-21 09:10:44 +03:00
Anselm Eickhoff
21b503c188 Merge pull request #224 from datner/datner/give-it-a-try
cojson-transport-ws: reuse runtime and use fibers instead of setTimeout
2024-07-15 14:35:18 +01:00
Anselm
0053e9796c Release 2024-07-15 11:01:31 +01:00
Anselm
e84941b1b1 Fix another bug in CoMap 'has' proxy trap 2024-07-15 10:59:14 +01:00
Anselm
57f6f8d67e Release 2024-07-14 17:55:47 +01:00
Anselm
5b8e69d973 Fix bug in CoMap 'has' trap 2024-07-14 17:55:05 +01:00
Anselm
7213b1bfa3 Release 2024-07-13 13:32:50 +01:00
Anselm Eickhoff
11f0770f08 Merge pull request #230 from tobiaslins/add-support-for-in
Add support for property existence
2024-07-13 13:30:15 +01:00
Tobias Lins
44e6dc3ae8 Remove useless check 2024-07-13 13:57:37 +02:00
Tobias Lins
b5d20d2488 Better implementation 2024-07-13 13:26:28 +02:00
Tobias Lins
0185545838 Add support for property existence 2024-07-13 12:26:10 +02:00
Yuval Datner
8c8f85859c style: prettier 2024-07-12 15:42:52 +03:00
Yuval Datner
104384409e refactor: change to yieldable error 2024-07-12 15:42:27 +03:00
Yuval Datner
179827ae56 small refactor for readability 2024-07-12 15:13:25 +03:00
Yuval Datner
6645829876 do stream stuff 2024-07-12 15:13:23 +03:00
Gal Schlezinger
68cb302722 store jazzPings on global 2024-07-12 15:13:22 +03:00
Gal Schlezinger
8dc33f2790 fix bugs because I misindented things 2024-07-12 15:13:21 +03:00
Gal Schlezinger
5f64ba326c jazzPings 2024-07-12 15:13:20 +03:00
Gal Schlezinger
7ccb15107c cojson-transport-ws: reuse runtime and use fibers instead of setTimeout
when calling Effect.runFork we don't propagate layers
and using fibers can allow us to interrupt ongoing requests
when the pings fail
2024-07-12 15:13:16 +03:00
Anselm
b102964743 Get rid of husky for now 2024-07-12 11:56:54 +01:00
Anselm
216d50a09c Remove old homepage build 2024-07-12 11:55:44 +01:00
Anselm
07ea59fdcb Release 2024-07-12 11:14:15 +01:00
Anselm
932a84a47f Update to Effect 3.5.2 2024-07-12 11:13:41 +01:00
Anselm
34dda7bdbd Release 2024-07-05 11:18:53 +01:00
Anselm
49fa153581 Merge branch 'fix-fs' 2024-07-05 11:17:54 +01:00
Anselm
c80b827775 Release 2024-06-30 16:28:53 +01:00
Anselm Eickhoff
a2bf9f988a Merge pull request #218 from Schniz/fix-mutating-nullable-field 2024-06-30 08:31:57 +01:00
Gal Schlezinger
ac27b2d5c2 jazz-tools: allow to mutate nullable fields into null
when having a co.encoded(Schema.NullOr(Schema.String)), construction
with null works well, but mutating a value into null throws.
This commit fixes it and adds a test that verifies it actually works.
2024-06-30 09:49:15 +03:00
Anselm
c813518fdc Release 2024-06-28 16:27:15 +01:00
Anselm
d5034ed5c3 Provide current res in ProgressiveImg 2024-06-28 16:26:03 +01:00
Anselm
cf2c29a365 Use verce lanalytics & speed insights 2024-06-28 14:54:58 +01:00
Anselm
d948823db6 Add package manager to package.json 2024-06-28 12:08:23 +01:00
Anselm
060ad4630d Resurrect inspector 2024-06-27 16:54:23 +01:00
Anselm
0ddceac4c0 Fix FS bugs 2024-06-26 13:45:09 +01:00
Anselm
a862cb8819 Release 2024-06-25 15:13:55 +01:00
Anselm Eickhoff
4246aed7db Merge pull request #214 from gardencmp/effect-streams-for-peers
Effect streams for peers
2024-06-25 15:12:56 +01:00
Anselm
41554e0e0b Release 2024-06-25 13:57:24 +01:00
Anselm
93c4d8155e Fix CoList.toJSON() 2024-06-25 13:56:20 +01:00
Anselm
24eefd49f1 Release 2024-06-25 13:46:54 +01:00
Anselm
e712f1e8ef Fix circular toJSON bug #215 2024-06-25 13:46:14 +01:00
Anselm
33db0fd654 Keep old hompage lockfile (unrelated) 2024-06-13 22:17:00 +01:00
Anselm
478ded93de Merge branch 'main' into effect-streams-for-peers 2024-06-13 22:12:33 +01:00
Anselm
89ad1fb79d Fix remaining tests by introducing even more special cases 2024-06-13 22:11:25 +01:00
Anselm Eickhoff
1ba40806ec Merge pull request #213 from tobiaslins/fix-demo-auth-nextjs
Fix DemoAuth for Next.js
2024-06-13 17:43:11 +01:00
Tobias Lins
73ae281e4a Fix demoAuth for next.js 2024-06-13 18:41:29 +02:00
Anselm
a35353c987 Use Effect streams 2024-06-13 15:58:21 +01:00
Anselm
1cb91003cc Release 2024-06-12 15:12:00 +01:00
Anselm
d850022491 Fix #210 2024-06-12 15:10:22 +01:00
Anselm
93792ab6f6 Remove lofi conf badge 2024-06-06 12:02:31 +01:00
Anselm
95dfe7af6a Guide fix 2024-06-04 17:56:34 +01:00
Anselm
734258eb17 Release 2024-06-04 12:14:59 +01:00
Anselm
f3bcf96fad Also cache agent ID in RawControlledAccount 2024-06-04 12:13:54 +01:00
115 changed files with 13560 additions and 9252 deletions

View File

@@ -12,7 +12,7 @@
"jazz-react",
"jazz-nodejs",
"jazz-run",
"cojson-transport-nodejs-ws",
"cojson-transport-ws",
"cojson-storage-indexeddb",
"cojson-storage-sqlite"
]

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
example: ["chat", "pets", "todo"]
example: ["chat", "pets", "todo", "inspector"]
# example: ["twit", "chat", "counter-js-auth0", "pets", "twit", "file-drop", "inspector"]
steps:
@@ -53,59 +53,12 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
build-homepage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 16
cache: 'pnpm'
- 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: Pnpm Install (root)
run: |
pnpm install
working-directory: .
- name: Pnpm Install & Build (homepage)
run: |
pnpm install
pnpm build;
working-directory: ./homepage/homepage
- name: Docker Build & Push
uses: docker/build-push-action@v4
with:
context: ./homepage
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: ["chat", "pets", "todo"]
example: ["chat", "pets", "todo", "inspector"]
# example: ["twit", "chat", "counter-js-auth0", "pets", "twit", "file-drop", "inspector"]
steps:
@@ -135,37 +88,4 @@ jobs:
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR=${{ secrets.NOMAD_ADDR }} nomad job run job-instance.nomad;
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=${{ secrets.NOMAD_ADDR }} nomad job run job-instance.nomad;
working-directory: ./homepage
working-directory: ./examples/${{ matrix.example }}

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

View File

@@ -1,5 +1,128 @@
# jazz-example-chat
## 0.0.71
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-react@0.7.24
## 0.0.70
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-react@0.7.23
- jazz-tools@0.7.23
## 0.0.69
### Patch Changes
- jazz-react@0.7.22
## 0.0.68
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-react@0.7.21
## 0.0.67
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-react@0.7.20
## 0.0.66
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-react@0.7.19
## 0.0.65
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- jazz-react@0.7.18
- jazz-tools@0.7.18
## 0.0.64
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- jazz-react@0.7.17
- jazz-tools@0.7.17
## 0.0.63
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-react@0.7.16
## 0.0.62
### Patch Changes
- Updated dependencies
- jazz-react@0.7.15
## 0.0.61
### Patch Changes
- Updated dependencies
- cojson@0.7.14
- jazz-tools@0.7.14
- jazz-react@0.7.14
## 0.0.60
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
- jazz-react@0.7.13
## 0.0.59
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
- jazz-react@0.7.12
## 0.0.58
### Patch Changes
- Updated dependencies
- cojson@0.7.11
- jazz-react@0.7.11
- jazz-tools@0.7.11
## 0.0.57
### Patch Changes
- Updated dependencies
- cojson@0.7.10
- jazz-react@0.7.10
- jazz-tools@0.7.10
## 0.0.56
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-chat",
"private": true,
"version": "0.0.56",
"version": "0.0.71",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,49 @@
# jazz-example-chat
## 0.0.51
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- cojson-transport-ws@0.7.23
## 0.0.50
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.0.49
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-transport-ws@0.7.18
## 0.0.48
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-transport-ws@0.7.17
## 0.0.47
### Patch Changes
- Updated dependencies
- cojson@0.6.7
- jazz-react@0.5.5
- jazz-react-auth-local@0.4.18
## 0.0.46
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<link rel="stylesheet" href="/src/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jazz Inspector</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "inspector",
"name": "jazz-inspector",
"private": true,
"version": "0.0.47",
"version": "0.0.51",
"type": "module",
"scripts": {
"dev": "vite",
@@ -17,9 +17,9 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"hash-slash": "workspace:*",
"jazz-react": "workspace:*",
"jazz-react-auth-local": "workspace:*",
"cojson": "workspace:*",
"cojson-transport-ws": "workspace:*",
"effect": "^3.5.2",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,4 @@
import ReactDOM from "react-dom/client";
import App from "./viewer/new-app";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,92 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
margin: 0;
padding: 0;
}
}
.animate-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateZ(400px) translateY(30px) scale(1.05);
opacity: 0.4;
}
to {
transform: translateZ(0) scale(1);
opacity: 1;
}
}

View File

@@ -0,0 +1,18 @@
export function LinkIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
</svg>
);
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import { PageInfo } from "./types";
interface BreadcrumbsProps {
path: PageInfo[];
onBreadcrumbClick: (index: number) => void;
}
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
path,
onBreadcrumbClick,
}) => {
return (
<div className="z-20 relative bg-indigo-400/10 backdrop-blur-sm rounded-lg inline-flex px-2 py-1 whitespace-pre transition-all items-center space-x-1 min-h-10">
<button
onClick={() => onBreadcrumbClick(-1)}
className="flex items-center justify-center p-1 rounded-sm hover:bg-indigo-500/10 transition-colors"
aria-label="Go to home"
>
<img src="jazz-logo.png" alt="Jazz Logo" className="size-5" />
</button>
{path.map((page, index) => {
return (
<span
key={index}
className="inline-block first:pl-1 last:pr-1"
>
{index === 0 ? null : (
<span className="text-indigo-500/30">{" / "}</span>
)}
<button
onClick={() => onBreadcrumbClick(index)}
className="text-indigo-700 hover:underline"
>
{index === 0 ? page.name || "Root" : page.name}
</button>
</span>
);
})}
</div>
);
};

View File

@@ -0,0 +1,353 @@
import {
CoID,
LocalNode,
RawBinaryCoStream,
RawCoStream,
RawCoValue,
} from "cojson";
import { JsonObject, JsonValue } from "cojson/src/jsonValue";
import { PageInfo } from "./types";
import { base64URLtoBytes } from "cojson/src/base64url";
import { useEffect, useState } from "react";
import { ArrowDownToLine } from "lucide-react";
import {
BinaryStreamItem,
BinaryStreamStart,
CoStreamItem,
} from "cojson/src/coValues/coStream";
import { AccountOrGroupPreview } from "./value-renderer";
// typeguard for BinaryStreamStart
function isBinaryStreamStart(item: unknown): item is BinaryStreamStart {
return (
typeof item === "object" &&
item !== null &&
"type" in item &&
item.type === "start"
);
}
function detectCoStreamType(value: RawCoStream | RawBinaryCoStream) {
const firstKey = Object.keys(value.items)[0];
if (!firstKey)
return {
type: "unknown",
};
const items = value.items[firstKey as never]?.map((v) => v.value);
if (!items)
return {
type: "unknown",
};
const firstItem = items[0];
if (!firstItem)
return {
type: "unknown",
};
// This is a binary stream
if (isBinaryStreamStart(firstItem)) {
return {
type: "binary",
items: items as BinaryStreamItem[],
};
} else {
return {
type: "coStream",
};
}
}
async function getBlobFromCoStream({
items,
onlyFirstChunk = false,
}: {
items: BinaryStreamItem[];
onlyFirstChunk?: boolean;
}) {
if (onlyFirstChunk && items.length > 1) {
items = items.slice(0, 2);
}
const chunks: Uint8Array[] = [];
const binary_U_prefixLength = 8;
let lastProgressUpdate = Date.now();
for (const item of items.slice(1)) {
if (item.type === "end") {
break;
}
if (item.type !== "chunk") {
console.error("Invalid binary stream chunk", item);
return undefined;
}
const chunk = base64URLtoBytes(item.chunk.slice(binary_U_prefixLength));
// totalLength += chunk.length;
chunks.push(chunk);
if (Date.now() - lastProgressUpdate > 100) {
lastProgressUpdate = Date.now();
}
}
const defaultMime = "mimeType" in items[0] ? items[0].mimeType : null;
const blob = new Blob(chunks, defaultMime ? { type: defaultMime } : {});
const mimeType =
defaultMime === "" ? await detectPDFMimeType(blob) : defaultMime;
return {
blob,
mimeType: mimeType as string,
unfinishedChunks: items.length > 1,
totalSize:
"totalSizeBytes" in items[0]
? (items[0].totalSizeBytes as number)
: undefined,
};
}
const detectPDFMimeType = async (blob: Blob): Promise<string> => {
const arrayBuffer = await blob.slice(0, 4).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const header = uint8Array.reduce(
(acc, byte) => acc + String.fromCharCode(byte),
"",
);
if (header === "%PDF") {
return "application/pdf";
}
return "application/octet-stream";
};
const BinaryDownloadButton = ({
pdfBlob,
fileName = "document",
label,
mimeType,
}: {
pdfBlob: Blob;
mimeType?: string;
fileName?: string;
label: string;
}) => {
const downloadFile = () => {
const url = URL.createObjectURL(
new Blob([pdfBlob], mimeType ? { type: mimeType } : {}),
);
const link = document.createElement("a");
link.href = url;
link.download =
mimeType === "application/pdf" ? `${fileName}.pdf` : fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<button
className="flex items-center gap-2 px-2 py-1 text-gray-900 border border-gray-900/10 bg-clip-border shadow-sm transition-colors rounded bg-gray-50 text-sm"
onClick={downloadFile}
>
<ArrowDownToLine size={16} />
{label}
{/* Download {mimeType === "application/pdf" ? "PDF" : "File"} */}
</button>
);
};
const LabelContentPair = ({
label,
content,
}: {
label: string;
content: React.ReactNode;
}) => {
return (
<div className="flex flex-col gap-1.5 ">
<span className="uppercase text-xs font-medium text-gray-600 tracking-wide">
{label}
</span>
<span>{content}</span>
</div>
);
};
function RenderCoBinaryStream({
value,
items,
}: {
items: BinaryStreamItem[];
value: RawBinaryCoStream;
}) {
const [file, setFile] = useState<
| {
blob: Blob;
mimeType: string;
unfinishedChunks: boolean;
totalSize: number | undefined;
}
| undefined
| null
>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// load only the first chunk to get the mime type and size
getBlobFromCoStream({
items,
onlyFirstChunk: true,
})
.then((v) => {
if (v) {
setFile(v);
if (v.mimeType.includes("image")) {
// If it's an image, load the full blob
getBlobFromCoStream({
items,
}).then((s) => {
if (s) setFile(s);
});
}
}
})
.finally(() => setIsLoading(false));
}, [items]);
if (!isLoading && !file) return <div>No blob</div>;
if (isLoading) return <div>Loading...</div>;
if (!file) return <div>No blob</div>;
const { blob, mimeType } = file;
const sizeInKB = (file.totalSize || 0) / 1024;
return (
<div className="space-y-8 mt-4">
<div className="grid grid-cols-3 gap-2 max-w-3xl">
<LabelContentPair
label="Mime Type"
content={
<span className="font-mono bg-gray-100 rounded px-2 py-1 text-sm">
{mimeType || "No mime type"}
</span>
}
/>
<LabelContentPair
label="Size"
content={<span>{sizeInKB.toFixed(2)} KB</span>}
/>
<LabelContentPair
label="Download"
content={
<BinaryDownloadButton
fileName={value.id.toString()}
pdfBlob={blob}
mimeType={mimeType}
label={
mimeType === "application/pdf"
? "Download PDF"
: "Download File"
}
/>
}
/>
</div>
{mimeType === "image/png" || mimeType === "image/jpeg" ? (
<LabelContentPair
label="Preview"
content={
<div className="bg-gray-50 p-3 rounded-sm">
<RenderBlobImage blob={blob} />
</div>
}
/>
) : null}
</div>
);
}
function RenderCoStream({
value,
node,
}: {
value: RawCoStream;
node: LocalNode;
}) {
const streamPerUser = Object.keys(value.items);
const userCoIds = streamPerUser.map(
(stream) => stream.split("_session")[0],
);
return (
<div className="grid grid-cols-3 gap-2">
{userCoIds.map((id, idx) => (
<div
className="bg-gray-100 p-3 rounded-lg transition-colors overflow-hidden bg-white border hover:bg-gray-100/5 cursor-pointer shadow-sm"
key={id}
>
<AccountOrGroupPreview
coId={id as CoID<RawCoValue>}
node={node}
/>
{/* @ts-expect-error - TODO: fix types */}
{value.items[streamPerUser[idx]]?.map(
(item: CoStreamItem<JsonValue>) => (
<div>
{new Date(item.madeAt).toLocaleString()}{" "}
{JSON.stringify(item.value)}
</div>
),
)}
</div>
))}
</div>
);
}
export function CoStreamView({
value,
node,
}: {
data: JsonObject;
onNavigate: (pages: PageInfo[]) => void;
node: LocalNode;
value: RawCoStream;
}) {
// if (!value) return <div>No value</div>;
const streamType = detectCoStreamType(value);
if (streamType.type === "binary") {
if (streamType.items === undefined) {
return <div>No binary stream</div>;
}
return (
<RenderCoBinaryStream
value={value as RawBinaryCoStream}
items={streamType.items}
/>
);
}
if (streamType.type === "coStream") {
return <RenderCoStream value={value} node={node} />;
}
if (streamType.type === "unknown") return <div>Unknown stream type</div>;
return <div>Unknown stream type</div>;
}
function RenderBlobImage({ blob }: { blob: Blob }) {
const urlCreator = window.URL || window.webkitURL;
return <img src={urlCreator.createObjectURL(blob)} />;
}

View File

@@ -0,0 +1,73 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import { JsonObject } from "cojson/src/jsonValue";
import { CoMapPreview, ValueRenderer } from "./value-renderer";
import clsx from "clsx";
import { PageInfo, isCoId } from "./types";
import { ResolveIcon } from "./type-icon";
export function GridView({
data,
onNavigate,
node,
}: {
data: JsonObject;
onNavigate: (pages: PageInfo[]) => void;
node: LocalNode;
}) {
const entries = Object.entries(data);
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-2">
{entries.map(([key, child], childIndex) => (
<div
key={childIndex}
className={clsx(
"bg-gray-100 p-3 rounded-lg transition-colors overflow-hidden",
isCoId(child)
? "bg-white border hover:bg-gray-100/5 cursor-pointer shadow-sm"
: "bg-gray-50",
)}
onClick={() =>
isCoId(child) &&
onNavigate([
{ coId: child as CoID<RawCoValue>, name: key },
])
}
>
<h3 className="truncate">
{isCoId(child) ? (
<span className="font-medium flex justify-between">
{key}
<div className="px-2 py-1 text-xs bg-gray-100 rounded">
<ResolveIcon
coId={child as CoID<RawCoValue>}
node={node}
/>
</div>
</span>
) : (
<span>{key}</span>
)}
</h3>
<div className="mt-2 text-sm">
{isCoId(child) ? (
<CoMapPreview
coId={child as CoID<RawCoValue>}
node={node}
/>
) : (
<ValueRenderer
json={child}
onCoIDClick={(coId) => {
onNavigate([{ coId, name: key }]);
}}
compact
/>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { LocalNode } from "cojson";
import { Breadcrumbs } from "./breadcrumbs";
import { usePagePath } from "./use-page-path";
import { PageInfo } from "./types";
import { PageStack } from "./page-stack";
export default function CoJsonViewer({
defaultPath,
node,
}: {
defaultPath?: PageInfo[];
node: LocalNode;
}) {
const { path, addPages, goToIndex, goBack } = usePagePath(defaultPath);
return (
<div className="w-full h-screen bg-gray-100 p-4 overflow-hidden">
<Breadcrumbs path={path} onBreadcrumbClick={goToIndex} />
<PageStack
path={path}
node={node}
goBack={goBack}
addPages={addPages}
/>
</div>
);
}

View File

@@ -0,0 +1,313 @@
import React, { useState, useEffect } from "react";
import {
LocalNode,
CoID,
RawCoValue,
RawAccount,
AgentSecret,
AccountID,
cojsonInternals,
WasmCrypto,
} from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import { Effect } from "effect";
import { Trash2 } from "lucide-react";
import { Breadcrumbs } from "./breadcrumbs";
import { usePagePath } from "./use-page-path";
import { PageStack } from "./page-stack";
import { resolveCoValue, useResolvedCoValue } from "./use-resolve-covalue";
import clsx from "clsx";
interface Account {
id: CoID<RawAccount>;
secret: AgentSecret;
}
export default function CoJsonViewerApp() {
const [accounts, setAccounts] = useState<Account[]>(() => {
const storedAccounts = localStorage.getItem("inspectorAccounts");
return storedAccounts ? JSON.parse(storedAccounts) : [];
});
const [currentAccount, setCurrentAccount] = useState<Account | null>(() => {
const lastSelectedId = localStorage.getItem("lastSelectedAccountId");
if (lastSelectedId) {
const lastAccount = accounts.find(
(account) => account.id === lastSelectedId,
);
return lastAccount || null;
}
return null;
});
const [localNode, setLocalNode] = useState<LocalNode | null>(null);
const [coValueId, setCoValueId] = useState<CoID<RawCoValue> | "">("");
const { path, addPages, goToIndex, goBack, setPage } = usePagePath();
useEffect(() => {
localStorage.setItem("inspectorAccounts", JSON.stringify(accounts));
}, [accounts]);
useEffect(() => {
if (currentAccount) {
localStorage.setItem("lastSelectedAccountId", currentAccount.id);
} else {
localStorage.removeItem("lastSelectedAccountId");
}
}, [currentAccount]);
useEffect(() => {
if (!currentAccount) {
setLocalNode(null);
goToIndex(-1);
return;
}
WasmCrypto.create().then(async (crypto) => {
const wsPeer = await Effect.runPromise(
createWebSocketPeer({
id: "mesh",
websocket: new WebSocket("wss://mesh.jazz.tools"),
role: "server",
}),
);
const node = await LocalNode.withLoadedAccount({
accountID: currentAccount.id,
accountSecret: currentAccount.secret,
sessionID: cojsonInternals.newRandomSessionID(
currentAccount.id,
),
peersToLoadFrom: [wsPeer],
crypto,
migration: async () => {
console.log("Not running any migration in inspector");
},
});
setLocalNode(node);
});
}, [currentAccount, goToIndex]);
const addAccount = (id: AccountID, secret: AgentSecret) => {
const newAccount = { id, secret };
setAccounts([...accounts, newAccount]);
setCurrentAccount(newAccount);
};
const deleteCurrentAccount = () => {
if (currentAccount) {
const updatedAccounts = accounts.filter(
(account) => account.id !== currentAccount.id,
);
setAccounts(updatedAccounts);
setCurrentAccount(
updatedAccounts.length > 0 ? updatedAccounts[0] : null,
);
}
};
const handleCoValueIdSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (coValueId) {
setPage(coValueId);
}
};
return (
<div className="w-full h-screen bg-gray-100 p-4 overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-4">
<Breadcrumbs path={path} onBreadcrumbClick={goToIndex} />
<AccountSwitcher
accounts={accounts}
currentAccount={currentAccount}
setCurrentAccount={setCurrentAccount}
deleteCurrentAccount={deleteCurrentAccount}
localNode={localNode}
/>
</div>
<PageStack
path={path}
node={localNode}
goBack={goBack}
addPages={addPages}
>
{!currentAccount ? (
<AddAccountForm addAccount={addAccount} />
) : (
<form
onSubmit={handleCoValueIdSubmit}
aria-hidden={path.length !== 0}
className={clsx(
"flex flex-col justify-center items-center gap-2 h-full w-full mb-20 ",
"transition-all duration-150",
path.length > 0
? "opacity-0 -translate-y-2 scale-95"
: "opacity-100",
)}
>
<fieldset className="flex flex-col gap-2 text-sm">
<h2 className="text-3xl font-medium text-gray-950 text-center mb-4">
Jazz CoValue Inspector
</h2>
<input
className="border p-4 rounded-lg min-w-[21rem] font-mono"
placeholder="co_z1234567890abcdef123456789"
value={coValueId}
onChange={(e) =>
setCoValueId(
e.target.value as CoID<RawCoValue>,
)
}
/>
<button
type="submit"
className="bg-indigo-500 hover:bg-indigo-500/80 text-white px-4 py-2 rounded-md"
>
Inspect
</button>
<hr />
<button
type="button"
className="border inline-block px-2 py-1.5 text-black rounded"
onClick={() => {
setCoValueId(currentAccount.id);
setPage(currentAccount.id);
}}
>
Inspect My Account
</button>
</fieldset>
</form>
)}
</PageStack>
</div>
);
}
function AccountSwitcher({
accounts,
currentAccount,
setCurrentAccount,
deleteCurrentAccount,
localNode,
}: {
accounts: Account[];
currentAccount: Account | null;
setCurrentAccount: (account: Account | null) => void;
deleteCurrentAccount: () => void;
localNode: LocalNode | null;
}) {
return (
<div className="relative flex items-center gap-1">
<select
value={currentAccount?.id || "add-account"}
onChange={(e) => {
if (e.target.value === "add-account") {
setCurrentAccount(null);
} else {
const account = accounts.find(
(a) => a.id === e.target.value,
);
setCurrentAccount(account || null);
}
}}
className="p-2 px-4 bg-gray-100/50 border border-indigo-500/10 backdrop-blur-sm rounded-md text-indigo-700 appearance-none"
>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{localNode ? (
<AccountNameDisplay
accountId={account.id}
node={localNode}
/>
) : (
account.id
)}
</option>
))}
<option value="add-account">Add account</option>
</select>
{currentAccount && (
<button
onClick={deleteCurrentAccount}
className="p-3 rounded hover:bg-gray-200 transition-colors"
title="Delete Account"
>
<Trash2 size={16} className="text-gray-500" />
</button>
)}
</div>
);
}
function AddAccountForm({
addAccount,
}: {
addAccount: (id: AccountID, secret: AgentSecret) => void;
}) {
const [id, setId] = useState("");
const [secret, setSecret] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addAccount(id as AccountID, secret as AgentSecret);
setId("");
setSecret("");
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-2 max-w-md mx-auto h-full justify-center"
>
<h2 className="text-2xl font-medium text-gray-900 mb-3">
Add an Account to Inspect
</h2>
<input
className="border py-2 px-3 rounded-md"
placeholder="Account ID"
value={id}
onChange={(e) => setId(e.target.value)}
/>
<input
type="password"
className="border py-2 px-3 rounded-md"
placeholder="Account Secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<button
type="submit"
className="bg-indigo-500 text-white px-4 py-2 rounded-md"
>
Add Account
</button>
</form>
);
}
function AccountNameDisplay({
accountId,
node,
}: {
accountId: CoID<RawAccount>;
node: LocalNode;
}) {
const { snapshot } = useResolvedCoValue(accountId, node);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
if (snapshot && typeof snapshot === "object" && "profile" in snapshot) {
const profileId = snapshot.profile as CoID<RawCoValue>;
resolveCoValue(profileId, node).then((profileResult) => {
if (
profileResult.snapshot &&
typeof profileResult.snapshot === "object" &&
"name" in profileResult.snapshot
) {
setName(profileResult.snapshot.name as string);
}
});
}
}, [snapshot, node]);
return name ? `${name} <${accountId}>` : accountId;
}

View File

@@ -0,0 +1,55 @@
import { Page } from "./page"; // Assuming you have a Page component
import { CoID, LocalNode, RawCoValue } from "cojson";
// Define the structure of a page in the path
interface PageInfo {
coId: CoID<RawCoValue>;
name?: string;
}
// Props for the PageStack component
interface PageStackProps {
path: PageInfo[];
node?: LocalNode | null;
goBack: () => void;
addPages: (pages: PageInfo[]) => void;
children?: React.ReactNode;
}
export function PageStack({
path,
node,
goBack,
addPages,
children,
}: PageStackProps) {
return (
<div className="relative mt-4 h-[calc(100vh-6rem)]">
{children && (
<div className="absolute inset-0 pb-20">{children}</div>
)}
{node &&
path.map((page, index) => (
<Page
key={`${page.coId}-${index}`}
coId={page.coId}
node={node}
name={page.name || page.coId}
onHeaderClick={goBack}
onNavigate={addPages}
isTopLevel={index === path.length - 1}
style={{
transform: `translateZ(${(index - path.length + 1) * 200}px) scale(${
1 - (path.length - index - 1) * 0.05
}) translateY(${-(index - path.length + 1) * -4}%)`,
opacity: 1 - (path.length - index - 1) * 0.05,
zIndex: index,
transitionProperty: "transform, opacity",
transitionDuration: "0.3s",
transitionTimingFunction: "ease-out",
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,154 @@
import clsx from "clsx";
import { CoID, LocalNode, RawCoStream, RawCoValue } from "cojson";
import { useEffect, useState } from "react";
import { useResolvedCoValue } from "./use-resolve-covalue";
import { GridView } from "./grid-view";
import { PageInfo } from "./types";
import { TableView } from "./table-viewer";
import { TypeIcon } from "./type-icon";
import { CoStreamView } from "./co-stream-view";
import { AccountOrGroupPreview } from "./value-renderer";
type PageProps = {
coId: CoID<RawCoValue>;
node: LocalNode;
name: string;
onNavigate: (newPages: PageInfo[]) => void;
onHeaderClick?: () => void;
isTopLevel?: boolean;
style: React.CSSProperties;
};
export function Page({
coId,
node,
name,
onNavigate,
onHeaderClick,
style,
isTopLevel,
}: PageProps) {
const { value, snapshot, type, extendedType } = useResolvedCoValue(
coId,
node,
);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const supportsTableView = type === "colist" || extendedType === "record";
// Automatically switch to table view if the page is a CoMap record
useEffect(() => {
if (supportsTableView) {
setViewMode("table");
}
}, [supportsTableView]);
if (snapshot === "unavailable") {
return <div style={style}>Data unavailable</div>;
}
if (!snapshot) {
return <div style={style}></div>;
}
return (
<div
style={style}
className={clsx(
"absolute inset-0 border border-gray-900/5 bg-clip-padding bg-white rounded-xl shadow-lg p-6 animate-in",
)}
>
{!isTopLevel && (
<div
className="absolute inset-x-0 top-0 h-10"
aria-label="Back"
onClick={() => {
onHeaderClick?.();
}}
aria-hidden="true"
></div>
)}
<div className="flex justify-between items-center mb-4">
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-bold flex items-start flex-col gap-1">
<span>
{name}
{typeof snapshot === "object" &&
"name" in snapshot ? (
<span className="text-gray-600 font-medium">
{" "}
{
(
snapshot as {
name: string;
}
).name
}
</span>
) : null}
</span>
</h2>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-700 font-medium py-0.5 px-1 -ml-0.5 rounded bg-gray-700/5 inline-block font-mono">
{type && (
<TypeIcon
type={type}
extendedType={extendedType}
/>
)}
</span>
<span className="text-xs text-gray-700 font-medium py-0.5 px-1 -ml-0.5 rounded bg-gray-700/5 inline-block font-mono">
{coId}
</span>
</div>
</div>
{/* {supportsTableView && (
<button
onClick={toggleViewMode}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{viewMode === "grid" ? "Table View" : "Grid View"}
</button>
)} */}
</div>
<div className="overflow-auto max-h-[calc(100%-4rem)]">
{type === "costream" ? (
<CoStreamView
data={snapshot}
onNavigate={onNavigate}
node={node}
value={value as RawCoStream}
/>
) : viewMode === "grid" ? (
<GridView
data={snapshot}
onNavigate={onNavigate}
node={node}
/>
) : (
<TableView
data={snapshot}
node={node}
onNavigate={onNavigate}
/>
)}
{/* --- */}
{extendedType !== "account" && extendedType !== "group" && (
<div className="text-xs text-gray-500 mt-4">
Owned by{" "}
<AccountOrGroupPreview
coId={value.group.id}
node={node}
showId
onClick={() => {
onNavigate([
{ coId: value.group.id, name: "owner" },
]);
}}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import { JsonObject } from "cojson/src/jsonValue";
import { PageInfo } from "./types";
import { useMemo, useState } from "react";
import { ValueRenderer } from "./value-renderer";
import { LinkIcon } from "../link-icon";
import { useResolvedCoValues } from "./use-resolve-covalue";
export function TableView({
data,
node,
onNavigate,
}: {
data: JsonObject;
node: LocalNode;
onNavigate: (pages: PageInfo[]) => void;
}) {
const [visibleRowsCount, setVisibleRowsCount] = useState(10);
const [coIdArray, visibleRows] = useMemo(() => {
const coIdArray = Array.isArray(data)
? data
: Object.values(data).every(
(k) => typeof k === "string" && k.startsWith("co_"),
)
? Object.values(data).map((k) => k as CoID<RawCoValue>)
: [];
const visibleRows = coIdArray.slice(0, visibleRowsCount);
return [coIdArray, visibleRows];
}, [data, visibleRowsCount]);
const resolvedRows = useResolvedCoValues(visibleRows, node);
const hasMore = visibleRowsCount < coIdArray.length;
if (!coIdArray.length) {
return <div>No data to display</div>;
}
if (resolvedRows.length === 0) {
return <div>Loading...</div>;
}
const keys = Array.from(
new Set(
resolvedRows.flatMap((item) => Object.keys(item.snapshot || {})),
),
);
const loadMore = () => {
setVisibleRowsCount((prevVisibleRows) => prevVisibleRows + 10);
};
return (
<div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 border-b">
<tr>
{["", ...keys].map((key) => (
<th
key={key}
className="px-4 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 rounded"
>
{key}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{resolvedRows
.slice(0, visibleRowsCount)
.map((item, index) => (
<tr key={index}>
<td className="px-1 py-0">
<button
onClick={() =>
onNavigate([
{
coId: item.value!.id,
name: index.toString(),
},
])
}
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 hover:text-blue-500 hover:bg-gray-100 rounded"
>
<LinkIcon />
</button>
</td>
{keys.map((key) => (
<td
key={key}
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500"
>
<ValueRenderer
json={
(item.snapshot as JsonObject)[
key
]
}
onCoIDClick={(coId) => {
async function handleClick() {
onNavigate([
{
coId: item.value!
.id,
name: index.toString(),
},
{
coId: coId,
name: key,
},
]);
}
handleClick();
}}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="py-4 text-gray-500 flex items-center justify-between gap-2">
<span>
Showing {Math.min(visibleRowsCount, coIdArray.length)} of{" "}
{coIdArray.length}
</span>
{hasMore && (
<div className="text-center">
<button
onClick={loadMore}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Load More
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import {
CoJsonType,
ExtendedCoJsonType,
useResolvedCoValue,
} from "./use-resolve-covalue";
export const TypeIcon = ({
type,
extendedType,
}: {
type: CoJsonType;
extendedType?: ExtendedCoJsonType;
}) => {
const iconMap: Record<ExtendedCoJsonType | CoJsonType, string> = {
record: "{} Record",
image: "🖼️ Image",
comap: "{} CoMap",
costream: "≋ CoStream",
colist: "☰ CoList",
account: "👤 Account",
group: "👥 Group",
};
const iconKey = extendedType || type;
const icon = iconMap[iconKey as keyof typeof iconMap];
return icon ? <span className="font-mono">{icon}</span> : null;
};
export const ResolveIcon = ({
coId,
node,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
}) => {
const { type, extendedType, snapshot } = useResolvedCoValue(coId, node);
if (snapshot === "unavailable" && !type) {
return <div className="text-gray-600 font-medium">Unavailable</div>;
}
if (!type) return <div className="whitespace-pre w-14 font-mono"> </div>;
return <TypeIcon type={type} extendedType={extendedType} />;
};

View File

@@ -0,0 +1,9 @@
import { CoID, RawCoValue } from "cojson";
export type PageInfo = {
coId: CoID<RawCoValue>;
name?: string;
};
export const isCoId = (coId: unknown): coId is CoID<RawCoValue> =>
typeof coId === "string" && coId.startsWith("co_");

View File

@@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from "react";
import { PageInfo } from "./types";
import { CoID, RawCoValue } from "cojson";
export function usePagePath(defaultPath?: PageInfo[]) {
const [path, setPath] = useState<PageInfo[]>(() => {
const hash = window.location.hash.slice(2); // Remove '#/'
if (hash) {
try {
return decodePathFromHash(hash);
} catch (e) {
console.error("Failed to parse hash:", e);
}
}
return defaultPath || [];
});
const updatePath = useCallback((newPath: PageInfo[]) => {
setPath(newPath);
const hash = encodePathToHash(newPath);
window.location.hash = `#/${hash}`;
}, []);
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(2);
if (hash) {
try {
const newPath = decodePathFromHash(hash);
setPath(newPath);
} catch (e) {
console.error("Failed to parse hash:", e);
}
} else if (defaultPath) {
setPath(defaultPath);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [defaultPath]);
useEffect(() => {
if (
defaultPath &&
JSON.stringify(path) !== JSON.stringify(defaultPath)
) {
updatePath(defaultPath);
}
}, [defaultPath, path, updatePath]);
const addPages = useCallback(
(newPages: PageInfo[]) => {
updatePath([...path, ...newPages]);
},
[path, updatePath],
);
const goToIndex = useCallback(
(index: number) => {
updatePath(path.slice(0, index + 1));
},
[path, updatePath],
);
const setPage = useCallback(
(coId: CoID<RawCoValue>) => {
updatePath([{ coId, name: "Root" }]);
},
[updatePath],
);
const goBack = useCallback(() => {
if (path.length > 1) {
updatePath(path.slice(0, path.length - 1));
}
}, [path, updatePath]);
return {
path,
setPage,
addPages,
goToIndex,
goBack,
};
}
function encodePathToHash(path: PageInfo[]): string {
return path
.map((page) => {
if (page.name && page.name !== "Root") {
return `${page.coId}:${encodeURIComponent(page.name)}`;
}
return page.coId;
})
.join("/");
}
function decodePathFromHash(hash: string): PageInfo[] {
return hash.split("/").map((segment) => {
const [coId, encodedName] = segment.split(":");
return {
coId,
name: encodedName ? decodeURIComponent(encodedName) : undefined,
} as PageInfo;
});
}

View File

@@ -0,0 +1,152 @@
import { CoID, LocalNode, RawBinaryCoStream, RawCoValue } from "cojson";
import { useEffect, useState } from "react";
export type CoJsonType = "comap" | "costream" | "colist";
export type ExtendedCoJsonType = "image" | "record" | "account" | "group";
type JSON = string | number | boolean | null | JSON[] | { [key: string]: JSON };
type JSONObject = { [key: string]: JSON };
type ResolvedImageDefinition = {
originalSize: [number, number];
placeholderDataURL?: string;
[res: `${number}x${number}`]: RawBinaryCoStream["id"];
};
// Type guard for browser image
export const isBrowserImage = (
coValue: JSONObject,
): coValue is ResolvedImageDefinition => {
return "originalSize" in coValue && "placeholderDataURL" in coValue;
};
export type ResolvedGroup = {
readKey: string;
[key: string]: JSON;
};
export const isGroup = (coValue: JSONObject): coValue is ResolvedGroup => {
return "readKey" in coValue;
};
export type ResolvedAccount = {
profile: {
name: string;
};
[key: string]: JSON;
};
export const isAccount = (coValue: JSONObject): coValue is ResolvedAccount => {
return isGroup(coValue) && "profile" in coValue;
};
export async function resolveCoValue(
coValueId: CoID<RawCoValue>,
node: LocalNode,
): Promise<
| {
value: RawCoValue;
snapshot: JSONObject;
type: CoJsonType | null;
extendedType: ExtendedCoJsonType | undefined;
}
| {
value: undefined;
snapshot: "unavailable";
type: null;
extendedType: undefined;
}
> {
const value = await node.load(coValueId);
if (value === "unavailable") {
return {
value: undefined,
snapshot: "unavailable",
type: null,
extendedType: undefined,
};
}
const snapshot = value.toJSON() as JSONObject;
const type = value.type as CoJsonType;
// Determine extended type
let extendedType: ExtendedCoJsonType | undefined;
if (type === "comap") {
if (isBrowserImage(snapshot)) {
extendedType = "image";
} else if (isAccount(snapshot)) {
extendedType = "account";
} else if (isGroup(snapshot)) {
extendedType = "group";
} else {
// This check is a bit of a hack
// There might be a better way to do this
const children = Object.values(snapshot).slice(0, 10);
if (
children.every(
(c) => typeof c === "string" && c.startsWith("co_"),
) &&
children.length > 3
) {
extendedType = "record";
}
}
}
return {
value,
snapshot,
type,
extendedType,
};
}
export function useResolvedCoValue(
coValueId: CoID<RawCoValue>,
node: LocalNode,
) {
const [result, setResult] =
useState<Awaited<ReturnType<typeof resolveCoValue>>>();
useEffect(() => {
resolveCoValue(coValueId, node).then(setResult);
}, [coValueId, node]);
return (
result || {
value: undefined,
snapshot: undefined,
type: undefined,
extendedType: undefined,
}
);
}
export function useResolvedCoValues(
coValueIds: CoID<RawCoValue>[],
node: LocalNode,
) {
const [results, setResults] = useState<
Awaited<ReturnType<typeof resolveCoValue>>[]
>([]);
useEffect(() => {
console.log("RETECHING", coValueIds);
const fetchResults = async () => {
if (coValueIds.length === 0) return;
const resolvedValues = await Promise.all(
coValueIds.map((coValueId) => resolveCoValue(coValueId, node)),
);
console.log({ resolvedValues });
setResults(resolvedValues);
};
fetchResults();
}, [coValueIds, node]);
return results;
}

View File

@@ -0,0 +1,248 @@
import clsx from "clsx";
import { CoID, JsonValue, LocalNode, RawCoValue } from "cojson";
import { LinkIcon } from "../link-icon";
import {
isBrowserImage,
resolveCoValue,
useResolvedCoValue,
} from "./use-resolve-covalue";
import React, { useEffect, useState } from "react";
// Is there a chance we can pass the actual CoValue here?
export function ValueRenderer({
json,
compact,
onCoIDClick,
}: {
json: JsonValue | undefined;
compact?: boolean;
onCoIDClick?: (childNode: CoID<RawCoValue>) => void;
}) {
if (typeof json === "undefined" || json === undefined) {
return <span className="text-gray-400">undefined</span>;
}
if (json === null) {
return <span className="text-gray-400">null</span>;
}
if (typeof json === "string" && json.startsWith("co_")) {
return (
<span
className={clsx(
"inline-flex gap-1 items-center",
onCoIDClick &&
"text-blue-500 cursor-pointer hover:underline",
)}
onClick={() => {
onCoIDClick?.(json as CoID<RawCoValue>);
}}
>
{json}
{onCoIDClick && <LinkIcon />}
</span>
);
}
if (typeof json === "string") {
return (
<span className="text-green-900 font-mono">
{/* <span className="select-none opacity-70">{'"'}</span> */}
{json}
{/* <span className="select-none opacity-70">{'"'}</span> */}
</span>
);
}
if (typeof json === "number") {
return <span className="text-purple-500">{json}</span>;
}
if (typeof json === "boolean") {
return (
<span
className={clsx(
json
? "text-green-700 bg-green-700/5"
: "text-amber-700 bg-amber-500/5",
"font-mono",
"inline-block px-1 py-0.5 rounded",
)}
>
{json.toString()}
</span>
);
}
if (Array.isArray(json)) {
return (
<span title={JSON.stringify(json)}>
Array <span className="text-gray-500">({json.length})</span>
</span>
);
}
if (typeof json === "object") {
return (
<span
title={JSON.stringify(json, null, 2)}
className="inline-block max-w-64 truncate"
>
{compact ? (
<span>
Object{" "}
<span className="text-gray-500">
({Object.keys(json).length})
</span>
</span>
) : (
JSON.stringify(json, null, 2)
)}
</span>
);
}
return <span>{String(json)}</span>;
}
export const CoMapPreview = ({
coId,
node,
limit = 6,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
limit?: number;
}) => {
const { value, snapshot, type, extendedType } = useResolvedCoValue(
coId,
node,
);
if (!snapshot) {
return (
<div className="rounded bg-gray-100 animate-pulse whitespace-pre w-24">
{" "}
</div>
);
}
if (snapshot === "unavailable" && !value) {
return <div className="text-gray-500">Unavailable</div>;
}
if (extendedType === "image" && isBrowserImage(snapshot)) {
return (
<div>
<img
src={snapshot.placeholderDataURL}
className="size-8 border-2 border-white drop-shadow-md my-2"
/>
<span className="text-gray-500 text-sm">
{snapshot.originalSize[0]} x {snapshot.originalSize[1]}
</span>
{/* <CoMapPreview coId={value[]} node={node} /> */}
{/* <ProgressiveImg image={value}>
{({ src }) => <img src={src} className={clsx("w-full")} />}
</ProgressiveImg> */}
</div>
);
}
if (extendedType === "record") {
return (
<div>
Record{" "}
<span className="text-gray-500">
({Object.keys(snapshot).length})
</span>
</div>
);
}
if (type === "colist") {
return (
<div>
List{" "}
<span className="text-gray-500">
({(snapshot as unknown as []).length})
</span>
</div>
);
}
return (
<div className="text-sm flex flex-col gap-2 items-start">
<div className="grid grid-cols-[auto_1fr] gap-2">
{Object.entries(snapshot)
.slice(0, limit)
.map(([key, value]) => (
<React.Fragment key={key}>
<span className="font-medium">{key}: </span>
<span>
<ValueRenderer json={value} />
</span>
</React.Fragment>
))}
</div>
{Object.entries(snapshot).length > limit && (
<div className="text-left text-xs text-gray-500 mt-2">
{Object.entries(snapshot).length - limit} more
</div>
)}
</div>
);
};
export function AccountOrGroupPreview({
coId,
node,
showId = false,
onClick,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
showId?: boolean;
onClick?: (name?: string) => void;
}) {
const { snapshot, extendedType } = useResolvedCoValue(coId, node);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
if (extendedType === "account") {
resolveCoValue(
(snapshot as unknown as { profile: CoID<RawCoValue> }).profile,
node,
).then(({ snapshot }) => {
if (
typeof snapshot === "object" &&
"name" in snapshot &&
typeof snapshot.name === "string"
) {
setName(snapshot.name);
}
});
}
}, [snapshot, node, extendedType]);
if (!snapshot) return <span>Loading...</span>;
if (extendedType !== "account" && extendedType !== "group") {
return <span>CoID is not an account or group</span>;
}
const displayName =
extendedType === "account" ? name || "Account" : "Group";
const displayText = showId ? `${displayName} (${coId})` : displayName;
const props = onClick
? {
onClick: () => onClick(displayName),
className: "text-blue-500 cursor-pointer hover:underline",
}
: {
className: "text-gray-500",
};
return <span {...props}>{displayText}</span>;
}

View File

@@ -1,5 +1,132 @@
# jazz-example-pets
## 0.0.89
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-browser-media-images@0.7.24
- jazz-react@0.7.24
## 0.0.88
### Patch Changes
- Updated dependencies
- jazz-react@0.7.23
- jazz-tools@0.7.23
- jazz-browser-media-images@0.7.23
## 0.0.87
### Patch Changes
- jazz-browser-media-images@0.7.22
- jazz-react@0.7.22
## 0.0.86
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-browser-media-images@0.7.21
- jazz-react@0.7.21
## 0.0.85
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-browser-media-images@0.7.20
- jazz-react@0.7.20
## 0.0.84
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-browser-media-images@0.7.19
- jazz-react@0.7.19
## 0.0.83
### Patch Changes
- jazz-react@0.7.18
- jazz-tools@0.7.18
- jazz-browser-media-images@0.7.18
## 0.0.82
### Patch Changes
- jazz-react@0.7.17
- jazz-tools@0.7.17
- jazz-browser-media-images@0.7.17
## 0.0.81
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-browser-media-images@0.7.16
- jazz-react@0.7.16
## 0.0.80
### Patch Changes
- Updated dependencies
- jazz-react@0.7.15
## 0.0.79
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.14
- jazz-react@0.7.14
- jazz-browser-media-images@0.7.14
## 0.0.78
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
- jazz-browser-media-images@0.7.13
- jazz-react@0.7.13
## 0.0.77
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
- jazz-browser-media-images@0.7.12
- jazz-react@0.7.12
## 0.0.76
### Patch Changes
- jazz-react@0.7.11
- jazz-tools@0.7.11
- jazz-browser-media-images@0.7.11
## 0.0.75
### Patch Changes
- jazz-react@0.7.10
- jazz-tools@0.7.10
- jazz-browser-media-images@0.7.10
## 0.0.74
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.74",
"version": "0.0.89",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,118 @@
# jazz-example-todo
## 0.0.88
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-react@0.7.24
## 0.0.87
### Patch Changes
- Updated dependencies
- jazz-react@0.7.23
- jazz-tools@0.7.23
## 0.0.86
### Patch Changes
- jazz-react@0.7.22
## 0.0.85
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-react@0.7.21
## 0.0.84
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-react@0.7.20
## 0.0.83
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-react@0.7.19
## 0.0.82
### Patch Changes
- jazz-react@0.7.18
- jazz-tools@0.7.18
## 0.0.81
### Patch Changes
- jazz-react@0.7.17
- jazz-tools@0.7.17
## 0.0.80
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-react@0.7.16
## 0.0.79
### Patch Changes
- Updated dependencies
- jazz-react@0.7.15
## 0.0.78
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.14
- jazz-react@0.7.14
## 0.0.77
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
- jazz-react@0.7.13
## 0.0.76
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
- jazz-react@0.7.12
## 0.0.75
### Patch Changes
- jazz-react@0.7.11
- jazz-tools@0.7.11
## 0.0.74
### Patch Changes
- jazz-react@0.7.10
- jazz-tools@0.7.10
## 0.0.73
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.73",
"version": "0.0.88",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -178,7 +178,7 @@ function App() {// old
if (issue) {// old
return <IssueComponent issue={issue} />; // old
} else { // old
return <button onClick={createIssue}>Create Issue</button>; // old
return <button onClick={createIssue}>Create Issue</button>;
} // old
} // old
// old

View File

@@ -9,9 +9,11 @@ import localFont from "next/font/local";
import { GcmpLogo, JazzLogo } from "@/components/logos";
import { SiGithub, SiDiscord, SiTwitter } from "@icons-pack/react-simple-icons";
import { Nav, NavLink, Newsletter, NewsletterButton } from "@/components/nav";
import { MailIcon } from "lucide-react";
import { DocNav } from "@/components/docs/nav";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Analytics } from "@vercel/analytics/react";
// If loading a variable font, you don't need to specify the font weight
const manrope = Manrope({
subsets: ["latin"],
@@ -48,6 +50,8 @@ export default function RootLayout({
"flex flex-col items-center bg-stone-50 dark:bg-stone-950 overflow-x-hidden",
].join(" ")}
>
<SpeedInsights />
<Analytics />
<ThemeProvider
attribute="class"
defaultTheme="system"
@@ -108,7 +112,7 @@ export default function RootLayout({
<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="max-sm:text-right">
© 2023
© {new Date().getFullYear()}
<br />
Garden Computing, Inc.
</p>
@@ -192,12 +196,6 @@ export default function RootLayout({
</div>
</footer>
</ThemeProvider>
<script
defer
data-api="/api/event"
data-domain="jazz.tools"
src="/js/script.js"
></script>
</body>
</html>
);

View File

@@ -28,12 +28,6 @@ import Link from "next/link";
<Prose>
<a href="https://app.localfirstconf.com/schedule/conference/every-app-secretly-wants-to-be-local-first" className="-mt-8 md:-mt-20 float-right top-[5rem] right-4 border border-stone-700 dark:border-stone-300 rounded flex gap-3 items-center px-4 py-2 mb-4 rotate-2 md:rotate-6 no-underline hover:scale-105 transition-transform">
<div className="text-sm font-bold uppercase">See you in Berlin<br/>May 30-31!</div>
<LocalFirstConfLogo className="w-24"/>
</a>
# Instant sync.
<Slogan>A new way to build apps with distributed state.</Slogan>

File diff suppressed because one or more lines are too long

View File

@@ -14,6 +14,7 @@
"*.{ts,tsx}": "eslint --fix",
"*.{js,jsx,mdx,json}": "prettier --write"
},
"packageManager": "pnpm@9.1.4",
"dependencies": {
"@evilmartians/harmony": "^1.0.0",
"@icons-pack/react-simple-icons": "^9.1.0",
@@ -21,6 +22,8 @@
"@mdx-js/react": "^2.3.0",
"@next/mdx": "^13.5.4",
"@types/mdx": "^2.0.8",
"@vercel/analytics": "^1.3.1",
"@vercel/speed-insights": "^1.0.12",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.284.0",

5244
homepage/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
# jazz-example-chat
## 0.0.47
### Patch Changes
- Updated dependencies
- cojson@0.6.7
- jazz-react@0.5.5
- jazz-react-auth-local@0.4.18
## 0.0.46
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<link rel="stylesheet" href="/src/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jazz Chat Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,243 +0,0 @@
import {
WithJazz,
useJazz,
DemoAuth,
useAutoSub,
useBinaryStream,
} from "jazz-react";
import ReactDOM from "react-dom/client";
import { HashRoute } from "hash-slash";
import { Account, CoID, CoValue, SessionID } from "cojson";
import { clsx } from "clsx";
import { ImageDefinition } from "cojson/src/media";
import { CoJsonTree } from "./cojson-tree";
ReactDOM.createRoot(document.getElementById("root")!).render(
<WithJazz
auth={DemoAuth({ appName: "Jazz Chat Example" })}
apiKey="api_z9d034j3t34ht034ir"
>
<App />
</WithJazz>
);
function App() {
return (
<div className="flex flex-col items-center justify-between w-screen h-screen p-2 ">
<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) => <Inspect coValueId={id as CoID<CoValue>} />,
},
{ reportToParentFrame: true }
)}
</div>
);
}
function Home() {
return (
<form
className="mb-auto"
onSubmit={(event) => {
const coValueId = (event.target as any).coValueId
.value as CoID<CoValue>;
location.hash = "/" + coValueId;
event.preventDefault();
}}
>
<input name="coValueId" className="border" />
<button>Inspect</button>
</form>
);
}
function Tag({ children, href }: { children: React.ReactNode; href?: string }) {
if (href) {
return (
<a
href={href}
className="border text-xs px-2 py-0.5 rounded hover:underline"
>
{children}
</a>
);
}
return <span className="border text-xs px-2 py-0.5 rounded">{children}</span>;
}
function ImageCoValue({ value }: { value: ImageDefinition["_shape"] }) {
const keys = Object.keys(value);
const keyIncludingRes = keys.find((key) => key.includes("x"));
const idToResolve = keyIncludingRes
? value[keyIncludingRes as `${number}x${number}`]
: null;
if (!idToResolve) return <div>Can't find image</div>;
const image = useBinaryStream(idToResolve);
return (
<img src={image?.blobURL || value.placeholderDataURL} alt="placeholder" />
);
}
function Inspect({ coValueId }: { coValueId: CoID<CoValue> }) {
const coValue = useAutoSub(coValueId);
const values = coValue?.meta.coValue.toJSON() || {};
const isImage = "placeholderDataURL" in values;
const isGroup = coValue?.meta.group.id === coValueId;
const entires = Object.entries(values as any) as [string, string][];
const onlyCoValues = entires.filter(([key]) => key.startsWith("co_"));
let title = "";
if (isImage) {
title = "Image";
} else if (isGroup) {
title = "Group";
}
return (
<div className="mb-auto">
<h1 className="text-xl font-bold mb-2">
Inspecting {title}{" "}
<span className="text-gray-500 text-sm">{coValueId}</span>
</h1>
{isGroup ? (
<p>
{onlyCoValues.length > 0 ? <h3>Permissions</h3> : ""}
<div className="flex gap-2 flex-col">
{onlyCoValues?.map(([key, value]) => (
<div className="flex gap-1 items-center">
<span className="bg-gray-200 text-xs px-2 py-0.5 rounded">
{value}
</span>
<AccountInfo accountID={key as CoID<Account>} />
</div>
))}
</div>
</p>
) : (
<span className="">
Group{" "}
<Tag href={`#/${coValue?.meta.group.id}`}>
{coValue?.meta.group.id}
</Tag>
</span>
)}
{isImage ? (
<div className="my-2">
<ImageCoValue value={values as any} />
</div>
) : null}
<pre className="max-w-[80vw] overflow-scroll text-sm mt-4">
<CoJsonTree coValueId={coValueId} />
</pre>
<h2 className="text-lg font-semibold mt-10 mb-4">Sessions</h2>
{coValue && <Sessions coValue={coValue.meta.coValue} />}
</div>
);
}
function Sessions({ coValue }: { coValue: CoValue }) {
const validTx = coValue.core.getValidSortedTransactions();
return (
<div className="max-w-[80vw] border rounded">
{[...coValue.core.sessionLogs.entries()].map(([sessionID, session]) => (
<div
key={sessionID}
className="mv-10 flex gap-2 border-b p-5 flex-wrap flex-col"
>
<div className="flex gap-2 flex-row">
<SessionInfo
sessionID={sessionID}
transactionCount={session.transactions.length}
/>
</div>
<div className="flex gap-1 flex-wrap max-h-64 overflow-y-auto p-1 bg-gray-50 rounded">
{session.transactions.map((tx, txIdx) => {
const correspondingValidTx = validTx.find(
(validTx) =>
validTx.txID.sessionID === sessionID &&
validTx.txID.txIndex == txIdx
);
return (
<div
key={txIdx}
className={clsx(
"text-xs flex-1 p-2 border rounded min-w-36 max-w-40 overflow-scroll bg-white",
!correspondingValidTx && "bg-red-50 border-red-100"
)}
>
<div>{new Date(tx.madeAt).toLocaleString()}</div>
<div>{tx.privacy}</div>
<pre>
{correspondingValidTx
? JSON.stringify(
correspondingValidTx.changes,
undefined,
2
)
: "invalid/undecryptable"}
</pre>
</div>
);
})}
</div>
<div className="text-xs">
{session.lastHash} / {session.lastSignature}{" "}
</div>
</div>
))}
</div>
);
}
function SessionInfo({
sessionID,
transactionCount,
}: {
sessionID: SessionID;
transactionCount: number;
}) {
let Prefix = sessionID.startsWith("co_") ? (
<AccountInfo accountID={sessionID.split("_session_")[0] as CoID<Account>} />
) : (
<pre className="text-xs">{sessionID.split("_session_")[0]}</pre>
);
return (
<div>
{Prefix}
<div>
<span className="text-xs">
Session {sessionID.split("_session_")[1]}
</span>
<span className="text-xs text-gray-600 font-medium">
{" "}
- {transactionCount} txs
</span>
</div>
</div>
);
}
function AccountInfo({ accountID }: { accountID: CoID<Account> }) {
const account = useAutoSub(accountID);
return (
<div className="flex items-center gap-2">
<h1>{account?.profile?.name}</h1>
<Tag href={`#/${accountID}`}>{account?.id}</Tag>
</div>
);
}

View File

@@ -1,151 +0,0 @@
import clsx from "clsx";
import { CoID, CoValue } from "cojson";
import { useAutoSub } from "jazz-react";
import { useState } from "react";
import { LinkIcon } from "./link-icon";
export function CoJsonTree({ coValueId }: { coValueId: CoID<CoValue> }) {
const coValue = useAutoSub(coValueId);
const values = coValue?.meta.coValue.toJSON() || {};
return <RenderCoValueJSON json={values} />;
}
function RenderObject({ json }: { json: Record<string, any> }) {
const [limit, setLimit] = useState(10);
const hasMore = Object.keys(json).length > limit;
const entries = Object.entries(json).slice(0, limit);
return (
<div className="flex gap-x-1 flex-col font-mono text-xs overflow-auto">
{entries.map(([key, value]) => {
return <RenderObjectValue property={key} value={value} />;
})}
{hasMore ? (
<div
className="text-gray-500 cursor-pointer"
onClick={() => setLimit((l) => l + 10)}
>
... {Object.keys(json).length - limit} more
</div>
) : null}
</div>
);
}
function RenderObjectValue({
property,
value,
}: {
property: string;
value: any;
}) {
const [shouldLoad, setShouldLoad] = useState(false);
const isCoValue =
typeof value === "string" ? value?.startsWith("co_") : false;
return (
<div className={clsx(`flex group`)}>
<span className="text-gray-500 flex">
<RenderCoValueJSON json={property} />:{" "}
</span>
{isCoValue ? (
<div className={clsx(shouldLoad && "pb-2")}>
<div className="flex items-center ">
<div onClick={() => setShouldLoad((s) => !s)}>
<div className="w-8 text-center text-gray-700 font-mono px-1 text-xs rounded hover:bg-gray-300 cursor-pointer">
{shouldLoad ? `-` : `...`}
</div>
</div>
<a href={`#/${value}`} className="ml-2 group-hover:block hidden">
<LinkIcon />
</a>
</div>
<span>{shouldLoad ? <CoJsonTree coValueId={value} /> : null}</span>
</div>
) : (
<div className="">
<RenderCoValueJSON json={value} />
</div>
)}
</div>
);
}
function RenderCoValueArray({ json }: { json: any[] }) {
const [limit, setLimit] = useState(10);
const hasMore = json.length > limit;
const entries = json.slice(0, limit);
return (
<div className="flex gap-x-1 flex-col font-mono text-xs overflow-auto">
{entries.map((value, idx) => {
return (
<div key={idx} className="flex gap-x-1">
<RenderCoValueJSON json={value} />
</div>
);
})}
{hasMore ? (
<div
className="text-gray-500 cursor-pointer"
onClick={() => setLimit((l) => l + 10)}
>
... {json.length - limit} more
</div>
) : null}
</div>
);
}
function RenderCoValueJSON({
json,
}: {
json:
| Record<string, any>
| any[]
| string
| null
| number
| boolean
| undefined;
}) {
if (typeof json === "undefined") {
return <>"undefined"</>;
} else if (Array.isArray(json)) {
return (
<div className="">
<span className="text-gray-500">[</span>
<div className="ml-2">
<RenderCoValueArray json={json} />
</div>
<span className="text-gray-500">]</span>
</div>
);
} else if (
typeof json === "object" &&
json &&
Object.getPrototypeOf(json) === Object.prototype
) {
return <RenderObject json={json} />;
} else if (typeof json === "string") {
if (json?.startsWith("co_")) {
return (
<>
<a className="underline" href={`#/${json}`}>
{'"'}
{json}
{'"'}
</a>
</>
);
} else {
return <div className="truncate max-w-64 ml-1">{json}</div>;
}
} else {
return <div className="truncate max-w-64">{JSON.stringify(json)}</div>;
}
}

View File

@@ -1,78 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
margin: 0;
padding: 0;
}
}

View File

@@ -1,18 +0,0 @@
export function LinkIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
</svg>
);
}

View File

@@ -6,10 +6,9 @@
"packages/*",
"examples/*"
],
"packageManager": "pnpm@9.1.4",
"devDependencies": {
"@changesets/cli": "^2.27.3",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"prettier": "^3.1.1",
"ts-node": "^10.9.1",
"turbo": "^1.11.2",
@@ -24,8 +23,7 @@
"format": "pnpm run -r format && cd homepage/homepage && pnpm run format",
"changeset": "changeset",
"changeset-version": "changeset version",
"release": "pnpm changeset publish && git push --follow-tags",
"prepare": "husky"
"release": "pnpm changeset publish && git push --follow-tags"
},
"lint-staged": {},
"version": "0.0.0"

View File

@@ -1,5 +1,47 @@
# cojson-storage-indexeddb
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.14
### Patch Changes
- Updated dependencies
- cojson@0.7.14
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -1,14 +1,14 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.7.9",
"version": "0.7.23",
"main": "dist/index.js",
"type": "module",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:*",
"typescript": "^5.1.6",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
"effect": "^3.5.2",
"typescript": "^5.1.6"
},
"devDependencies": {
"@vitest/browser": "^0.34.1",

View File

@@ -6,14 +6,11 @@ import {
CojsonInternalTypes,
MAX_RECOMMENDED_TX_SIZE,
AccountID,
IncomingSyncStream,
OutgoingSyncQueue,
} from "cojson";
import {
ReadableStream,
WritableStream,
ReadableStreamDefaultReader,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
import { SyncPromise } from "./syncPromises.js";
import { Effect, Queue, Stream } from "effect";
type CoValueRow = {
id: CojsonInternalTypes.RawCoID;
@@ -46,39 +43,35 @@ type SignatureAfterRow = {
export class IDBStorage {
db: IDBDatabase;
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
toLocalNode: OutgoingSyncQueue;
constructor(
db: IDBDatabase,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>,
fromLocalNode: IncomingSyncStream,
toLocalNode: OutgoingSyncQueue,
) {
this.db = db;
this.fromLocalNode = fromLocalNode.getReader();
this.toLocalNode = toLocalNode.getWriter();
this.toLocalNode = toLocalNode;
void (async () => {
let done = false;
while (!done) {
const result = await this.fromLocalNode.read();
done = result.done;
if (result.value) {
// console.log(
// "IDB: handling msg",
// result.value.id,
// result.value.action
// );
await this.handleSyncMessage(result.value);
// console.log(
// "IDB: handled msg",
// result.value.id,
// result.value.action
// );
}
}
})();
void fromLocalNode.pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => this.handleSyncMessage(msg),
catch: (e) =>
new Error(
`Error reading from localNode, handling msg\n\n${JSON.stringify(
msg,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
)}`,
{ cause: e },
),
}),
),
Effect.runPromise,
);
}
static async asPeer(
@@ -89,23 +82,30 @@ export class IDBStorage {
localNodeName: "local",
},
): Promise<Peer> {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace },
);
return Effect.runPromise(
Effect.gen(function* () {
const [localNodeAsPeer, storageAsPeer] =
yield* cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace },
);
await IDBStorage.open(
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
);
yield* Effect.promise(() =>
IDBStorage.open(
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
),
);
return { ...storageAsPeer, priority: 100 };
return { ...storageAsPeer, priority: 100 };
}),
);
}
static async open(
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>,
fromLocalNode: IncomingSyncStream,
toLocalNode: OutgoingSyncQueue,
) {
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("jazz-storage", 4);
@@ -150,23 +150,6 @@ export class IDBStorage {
keyPath: ["ses", "idx"],
});
}
// if (ev.oldVersion !== 0 && ev.oldVersion <= 3) {
// // fix embarrassing off-by-one error for transaction indices
// console.log("Migration: fixing off-by-one error");
// const transaction = (
// ev.target as unknown as { transaction: IDBTransaction }
// ).transaction;
// const txsStore = transaction.objectStore("transactions");
// const txs = await promised(txsStore.getAll());
// for (const tx of txs) {
// await promised(txsStore.delete([tx.ses, tx.idx]));
// tx.idx -= 1;
// await promised(txsStore.add(tx));
// }
// console.log("Migration: fixing off-by-one error - done");
// }
};
});
@@ -409,29 +392,35 @@ export class IDBStorage {
),
).then(() => {
// we're done with IndexedDB stuff here so can use native Promises again
setTimeout(async () => {
await this.toLocalNode.write({
action: "known",
...ourKnown,
asDependencyOf,
});
setTimeout(() =>
Effect.runPromise(
Effect.gen(this, function* () {
yield* Queue.offer(this.toLocalNode, {
action: "known",
...ourKnown,
asDependencyOf,
});
const nonEmptyNewContentPieces =
newContentPieces.filter(
(piece) =>
piece.header ||
Object.keys(piece.new).length > 0,
);
const nonEmptyNewContentPieces =
newContentPieces.filter(
(piece) =>
piece.header ||
Object.keys(piece.new)
.length > 0,
);
// console.log(theirKnown.id, nonEmptyNewContentPieces);
// console.log(theirKnown.id, nonEmptyNewContentPieces);
for (const piece of nonEmptyNewContentPieces) {
await this.toLocalNode.write(piece);
await new Promise((resolve) =>
setTimeout(resolve, 0),
);
}
}, 0);
for (const piece of nonEmptyNewContentPieces) {
yield* Queue.offer(
this.toLocalNode,
piece,
);
yield* Effect.yieldNow();
}
}),
),
);
return Promise.resolve();
});
@@ -456,13 +445,15 @@ export class IDBStorage {
const header = msg.header;
if (!header) {
console.error("Expected to be sent header first");
void this.toLocalNode.write({
action: "known",
id: msg.id,
header: false,
sessions: {},
isCorrection: true,
});
void Effect.runPromise(
Queue.offer(this.toLocalNode, {
action: "known",
id: msg.id,
header: false,
sessions: {},
isCorrection: true,
}),
);
throw new Error("Expected to be sent header first");
}
@@ -524,11 +515,13 @@ export class IDBStorage {
),
).then(() => {
if (invalidAssumptions) {
void this.toLocalNode.write({
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
});
void Effect.runPromise(
Queue.offer(this.toLocalNode, {
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
}),
);
}
});
});

View File

@@ -1,5 +1,47 @@
# cojson-storage-sqlite
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.14
### Patch Changes
- Updated dependencies
- cojson@0.7.14
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -1,15 +1,15 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.7.9",
"version": "0.7.23",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.5.2",
"cojson": "workspace:*",
"typescript": "^5.1.6",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
"effect": "^3.5.2",
"typescript": "^5.1.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.4"

View File

@@ -6,15 +6,12 @@ import {
SessionID,
MAX_RECOMMENDED_TX_SIZE,
AccountID,
IncomingSyncStream,
OutgoingSyncQueue,
} from "cojson";
import {
ReadableStream,
WritableStream,
ReadableStreamDefaultReader,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
import Database, { Database as DatabaseT } from "better-sqlite3";
import { Effect, Queue, Stream } from "effect";
type CoValueRow = {
id: CojsonInternalTypes.RawCoID;
@@ -46,30 +43,36 @@ type SignatureAfterRow = {
};
export class SQLiteStorage {
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
toLocalNode: OutgoingSyncQueue;
db: DatabaseT;
constructor(
db: DatabaseT,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>,
fromLocalNode: IncomingSyncStream,
toLocalNode: OutgoingSyncQueue,
) {
this.db = db;
this.fromLocalNode = fromLocalNode.getReader();
this.toLocalNode = toLocalNode.getWriter();
this.toLocalNode = toLocalNode;
void (async () => {
let done = false;
while (!done) {
const result = await this.fromLocalNode.read();
done = result.done;
if (result.value) {
await this.handleSyncMessage(result.value);
}
}
})();
void fromLocalNode.pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => this.handleSyncMessage(msg),
catch: (e) =>
new Error(
`Error reading from localNode, handling msg\n\n${JSON.stringify(
msg,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
)}`,
{ cause: e },
),
}),
),
Effect.runPromise,
);
}
static async asPeer({
@@ -81,25 +84,32 @@ export class SQLiteStorage {
trace?: boolean;
localNodeName?: string;
}): Promise<Peer> {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace },
);
return Effect.runPromise(
Effect.gen(function* () {
const [localNodeAsPeer, storageAsPeer] =
yield* cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace },
);
await SQLiteStorage.open(
filename,
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
);
yield* Effect.promise(() =>
SQLiteStorage.open(
filename,
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
),
);
return { ...storageAsPeer, priority: 100 };
return { ...storageAsPeer, priority: 100 };
}),
);
}
static async open(
filename: string,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>,
fromLocalNode: IncomingSyncStream,
toLocalNode: OutgoingSyncQueue,
) {
const db = Database(filename);
db.pragma("journal_mode = WAL");
@@ -431,11 +441,13 @@ export class SQLiteStorage {
);
}
await this.toLocalNode.write({
action: "known",
...ourKnown,
asDependencyOf,
});
await Effect.runPromise(
Queue.offer(this.toLocalNode, {
action: "known",
...ourKnown,
asDependencyOf,
}),
);
const nonEmptyNewContentPieces = newContentPieces.filter(
(piece) => piece.header || Object.keys(piece.new).length > 0,
@@ -444,7 +456,7 @@ export class SQLiteStorage {
// console.log(theirKnown.id, nonEmptyNewContentPieces);
for (const piece of nonEmptyNewContentPieces) {
await this.toLocalNode.write(piece);
await Effect.runPromise(Queue.offer(this.toLocalNode, piece));
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
@@ -466,13 +478,15 @@ export class SQLiteStorage {
const header = msg.header;
if (!header) {
console.error("Expected to be sent header first");
await this.toLocalNode.write({
action: "known",
id: msg.id,
header: false,
sessions: {},
isCorrection: true,
});
await Effect.runPromise(
Queue.offer(this.toLocalNode, {
action: "known",
id: msg.id,
header: false,
sessions: {},
isCorrection: true,
}),
);
return;
}
@@ -604,11 +618,13 @@ export class SQLiteStorage {
})();
if (invalidAssumptions) {
await this.toLocalNode.write({
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
});
await Effect.runPromise(
Queue.offer(this.toLocalNode, {
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
}),
);
}
}

View File

@@ -1,94 +0,0 @@
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", () => {
try {
controller.close();
} catch (ignore) {
// will throw if already closed, with no way to check before-hand
}
});
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

@@ -1,5 +1,53 @@
# cojson-transport-nodejs-ws
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
## 0.7.22
### Patch Changes
- Increase disconnect timeout for now
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.14
### Patch Changes
- Updated dependencies
- cojson@0.7.14
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -1,15 +1,14 @@
{
"name": "cojson-transport-nodejs-ws",
"name": "cojson-transport-ws",
"type": "module",
"version": "0.7.9",
"version": "0.7.23",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:*",
"typescript": "^5.1.6",
"ws": "^8.14.2",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
"effect": "^3.5.2",
"typescript": "^5.1.6"
},
"scripts": {
"dev": "tsc --watch --sourceMap --outDir dist",

View File

@@ -0,0 +1,119 @@
import { DisconnectedError, Peer, PingTimeoutError, SyncMessage } from "cojson";
import { Stream, Queue, Effect, Console } from "effect";
interface WebsocketEvents {
close: { code: number; reason: string };
message: { data: unknown };
open: void;
}
interface PingMsg {
time: number;
dc: string;
}
interface AnyWebSocket {
addEventListener<K extends keyof WebsocketEvents>(
type: K,
listener: (event: WebsocketEvents[K]) => void,
): void;
removeEventListener<K extends keyof WebsocketEvents>(
type: K,
listener: (event: WebsocketEvents[K]) => void,
): void;
close(): void;
send(data: string): void;
}
const g: typeof globalThis & {
jazzPings?: {
received: number;
sent: number;
dc: string;
}[];
} = globalThis;
export function createWebSocketPeer(options: {
id: string;
websocket: AnyWebSocket;
role: Peer["role"];
}): Effect.Effect<Peer> {
return Effect.gen(function* () {
const ws = options.websocket;
const ws_ = ws as unknown as Stream.EventListener<WebsocketEvents["message"]>;
const outgoing = yield* Queue.unbounded<SyncMessage>();
const closed = once(ws, "close").pipe(
Effect.flatMap(
(event) =>
new DisconnectedError({
message: `${event.code}: ${event.reason}`,
}),
),
Stream.fromEffect,
);
const isSyncMessage = (msg: unknown): msg is SyncMessage => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((msg as any)?.type === "ping") {
const ping = msg as PingMsg;
g.jazzPings ||= [];
g.jazzPings.push({
received: Date.now(),
sent: ping.time,
dc: ping.dc,
});
return false;
}
return true;
};
yield* Effect.forkDaemon(Effect.gen(function* () {
yield* once(ws, "open");
yield* Queue.take(outgoing).pipe(
Effect.andThen((message) => ws.send(JSON.stringify(message))),
Effect.forever,
);
}));
type E = WebsocketEvents["message"];
const messages = Stream.fromEventListener<E>(ws_, "message").pipe(
Stream.timeoutFail(() => new PingTimeoutError(), "10 seconds"),
Stream.tapError((_e) =>
Console.warn("Ping timeout").pipe(
Effect.andThen(Effect.try(() => ws.close())),
Effect.catchAll((e) =>
Console.error(
"Error while trying to close ws on ping timeout",
e,
),
),
),
),
Stream.mergeLeft(closed),
Stream.map((_) => JSON.parse(_.data as string)),
Stream.filter(isSyncMessage),
Stream.buffer({ capacity: "unbounded" }),
Stream.onDone(() => Queue.shutdown(outgoing)),
);
return {
id: options.id,
incoming: messages,
outgoing,
role: options.role,
};
});
}
const once = <Event extends keyof WebsocketEvents>(
ws: AnyWebSocket,
event: Event,
) =>
Effect.async<WebsocketEvents[Event]>((register) => {
const cb = (msg: WebsocketEvents[Event]) => {
ws.removeEventListener(event, cb);
register(Effect.succeed(msg));
};
ws.addEventListener(event, cb);
});

View File

@@ -1,5 +1,41 @@
# cojson
## 0.7.23
### Patch Changes
- Mostly complete OPFS implementation (single-tab only)
## 0.7.18
### Patch Changes
- Update to Effect 3.5.2
## 0.7.17
### Patch Changes
- Fix bugs in new storage interface
## 0.7.14
### Patch Changes
- Use Effect Queues and Streams instead of custom queue implementation
## 0.7.11
### Patch Changes
- Fix webpack import of node:crypto module
## 0.7.10
### Patch Changes
- Also cache agent ID in RawControlledAccount
## 0.7.9
### Patch Changes

View File

@@ -5,7 +5,7 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.7.9",
"version": "0.7.23",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",
@@ -22,9 +22,8 @@
"@noble/ciphers": "^0.1.3",
"@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.1",
"effect": "^3.1.5",
"hash-wasm": "^4.9.0",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
"effect": "^3.5.2",
"hash-wasm": "^4.9.0"
},
"scripts": {
"dev": "tsc --watch --sourceMap --outDir dist",

View File

@@ -97,7 +97,12 @@ export class RawControlledAccount<Meta extends AccountMeta = AccountMeta>
}
currentAgentID(): AgentID {
return this.crypto.getAgentID(this.agentSecret);
if (this._cachedCurrentAgentID) {
return this._cachedCurrentAgentID;
}
const agentID = this.crypto.getAgentID(this.agentSecret);
this._cachedCurrentAgentID = agentID;
return agentID;
}
currentSignerID(): SignerID {

View File

@@ -44,11 +44,13 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
if ("crypto" in globalThis) {
resolve();
} else {
return import("node:crypto").then(({ webcrypto }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).crypto = webcrypto;
resolve();
});
return import(/*webpackIgnore: true*/ "node:crypto").then(
({ webcrypto }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).crypto = webcrypto;
resolve();
},
);
}
}),
]).then(([blake3instance]) => new WasmCrypto(blake3instance));

View File

@@ -41,7 +41,13 @@ import type {
BinaryCoStreamMeta,
} from "./coValues/coStream.js";
import type { JsonValue } from "./jsonValue.js";
import type { SyncMessage, Peer } from "./sync.js";
import type {
SyncMessage,
Peer,
IncomingSyncStream,
OutgoingSyncQueue,
} from "./sync.js";
import { DisconnectedError, PingTimeoutError } from "./sync.js";
import type { AgentSecret } from "./crypto/crypto.js";
import type {
AccountID,
@@ -117,9 +123,19 @@ export {
SyncMessage,
isRawCoID,
LSMStorage,
DisconnectedError,
PingTimeoutError,
};
export type { Value, FileSystem, FSErr, BlockFilename, WalFilename };
export type {
Value,
FileSystem,
FSErr,
BlockFilename,
WalFilename,
IncomingSyncStream,
OutgoingSyncQueue,
};
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CojsonInternalTypes {

View File

@@ -3,7 +3,7 @@ import { CoValueChunk } from "./index.js";
import { RawCoID } from "../ids.js";
import { CryptoProvider, StreamingHash } from "../crypto/crypto.js";
export type BlockFilename = `${string}-L${number}-H${number}.jsonl`;
export type BlockFilename = `L${number}-${string}-${string}-H${number}.jsonl`;
export type BlockHeader = { id: RawCoID; start: number; length: number }[];
@@ -78,8 +78,9 @@ export function readHeader<RH, FS extends FileSystem<unknown, RH>>(
export function writeBlock<WH, RH, FS extends FileSystem<WH, RH>>(
chunks: Map<RawCoID, CoValueChunk>,
level: number,
blockNumber: number,
fs: FS,
): Effect.Effect<void, FSErr> {
): Effect.Effect<BlockFilename, FSErr> {
if (chunks.size === 0) {
return Effect.die(new Error("No chunks to write"));
}
@@ -118,20 +119,26 @@ export function writeBlock<WH, RH, FS extends FileSystem<WH, RH>>(
const headerBytes = textEncoder.encode(JSON.stringify(blockHeader));
yield* $(fs.append(file, headerBytes));
console.log(
"full file",
yield* $(
fs.read(file as unknown as RH, 0, offset + headerBytes.length),
),
);
// console.log(
// "full file",
// yield* $(
// fs.read(file as unknown as RH, 0, offset + headerBytes.length),
// ),
// );
const filename: BlockFilename = `${hash.digest()}-L${level}-H${
headerBytes.length
}.jsonl`;
console.log("renaming to" + filename);
const filename: BlockFilename = `L${level}-${(
blockNumber + ""
).padStart(3, "0")}-${hash
.digest()
.replace("hash_", "")
.slice(0, 15)}-H${headerBytes.length}.jsonl`;
// console.log("renaming to" + filename);
yield* $(fs.closeAndRename(file, filename));
console.log("Wrote block", filename, blockHeader);
return filename;
// console.log("Wrote block", filename, blockHeader);
// console.log("IDs in block", blockHeader.map(e => e.id));
});
}
@@ -147,6 +154,7 @@ export function writeToWal<WH, RH, FS extends FileSystem<WH, RH>>(
...chunk,
};
const bytes = textEncoder.encode(JSON.stringify(walEntry) + "\n");
console.log("writing to WAL", handle, id, bytes.length);
yield* $(fs.append(handle, bytes));
});
}

View File

@@ -126,10 +126,12 @@ export function mergeChunks(
} else {
const lastNewEntry = newEntries[newEntries.length - 1]!;
lastNewEntry.transactions.push(...entry.transactions);
lastNewEntry.lastSignature = entry.lastSignature;
bytesSinceLastSignature += entry.transactions.length;
}
}
newSessions[sessionID] = newEntries;
} else {
return Either.right("nonContigous" as const);
}

View File

@@ -1,18 +1,20 @@
import {
ReadableStream,
WritableStream,
ReadableStreamDefaultReader,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
import { Effect, Either, SynchronizedRef } from "effect";
Effect,
Either,
Queue,
Stream,
SynchronizedRef,
Deferred,
} from "effect";
import { RawCoID } from "../ids.js";
import { CoValueHeader, Transaction } from "../coValueCore.js";
import { Signature } from "../crypto/crypto.js";
import {
CoValueKnownState,
IncomingSyncStream,
NewContentMessage,
OutgoingSyncQueue,
Peer,
SyncMessage,
} from "../sync.js";
import { CoID, RawCoValue } from "../index.js";
import { connectedPeers } from "../streamUtils.js";
@@ -35,6 +37,8 @@ import {
} from "./FileSystem.js";
export type { FSErr, BlockFilename, WalFilename } from "./FileSystem.js";
const MAX_N_LEVELS = 3;
export type CoValueChunk = {
header?: CoValueHeader;
sessionEntries: {
@@ -47,9 +51,6 @@ export type CoValueChunk = {
};
export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
fs: FS;
currentWal: SynchronizedRef.SynchronizedRef<WH | undefined>;
coValues: SynchronizedRef.SynchronizedRef<{
[id: RawCoID]: CoValueChunk | undefined;
@@ -59,46 +60,34 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
BlockFilename,
{ [id: RawCoID]: { start: number; length: number } }
>();
blockFileHandles = new Map<
BlockFilename,
Deferred.Deferred<{ handle: RH; size: number }, FSErr>
>();
constructor(
fs: FS,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>,
public fs: FS,
public fromLocalNode: IncomingSyncStream,
public toLocalNode: OutgoingSyncQueue,
) {
this.fs = fs;
this.fromLocalNode = fromLocalNode.getReader();
this.toLocalNode = toLocalNode.getWriter();
this.coValues = SynchronizedRef.unsafeMake({});
this.currentWal = SynchronizedRef.unsafeMake<WH | undefined>(undefined);
void Effect.runPromise(
Effect.gen(this, function* () {
let done = false;
while (!done) {
const result = yield* Effect.promise(() =>
this.fromLocalNode.read(),
);
done = result.done;
if (result.value) {
if (result.value.action === "done") {
continue;
}
if (result.value.action === "content") {
yield* this.handleNewContent(result.value);
} else {
yield* this.sendNewContent(
result.value.id,
result.value,
undefined,
);
}
void this.fromLocalNode.pipe(
Stream.runForEach((msg) =>
Effect.gen(this, function* () {
if (msg.action === "done") {
return;
}
}
return;
}),
if (msg.action === "content") {
yield* this.handleNewContent(msg);
} else {
yield* this.sendNewContent(msg.id, msg, undefined);
}
}),
),
Effect.runPromise,
);
setTimeout(() => this.compact(), 20000);
@@ -132,15 +121,13 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
}
if (!coValue) {
yield* Effect.promise(() =>
this.toLocalNode.write({
id: id,
action: "known",
header: false,
sessions: {},
asDependencyOf,
}),
);
yield* Queue.offer(this.toLocalNode, {
id: id,
action: "known",
header: false,
sessions: {},
asDependencyOf,
});
return coValues;
}
@@ -195,17 +182,15 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
const ourKnown: CoValueKnownState = chunkToKnownState(id, coValue);
yield* Effect.promise(() =>
this.toLocalNode.write({
action: "known",
...ourKnown,
asDependencyOf,
}),
);
yield* Queue.offer(this.toLocalNode, {
action: "known",
...ourKnown,
asDependencyOf,
});
for (const message of newContentMessages) {
if (Object.keys(message.new).length === 0) continue;
yield* Effect.promise(() => this.toLocalNode.write(message));
yield* Queue.offer(this.toLocalNode, message);
}
return { ...coValues, [id]: coValue };
@@ -220,7 +205,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
let newWal = wal;
if (!newWal) {
newWal = yield* this.fs.createFile(
`wal-${new Date().toISOString()}-${Math.random()
`wal-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.jsonl`,
);
@@ -260,7 +245,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
if (!coValue) {
if (newContent.header) {
console.log("Creating in WAL", newContent.id);
// console.log("Creating in WAL", newContent.id);
yield* this.withWAL((wal) =>
writeToWal(
wal,
@@ -286,7 +271,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
// })
// )
// );
console.warn(
yield* Effect.logWarning(
"Incontiguous incoming update for " + newContent.id,
);
return coValues;
@@ -296,6 +281,23 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
if (Either.isRight(merged)) {
yield* Effect.logWarning(
"Non-contigous new content for " + newContent.id,
Object.entries(coValue.sessionEntries).map(
([session, entries]) =>
entries.map((entry) => ({
session: session,
after: entry.after,
length: entry.transactions.length,
})),
),
Object.entries(
newContentAsChunk.sessionEntries,
).map(([session, entries]) =>
entries.map((entry) => ({
session: session,
after: entry.after,
length: entry.transactions.length,
})),
),
);
// yield* Effect.promise(() =>
@@ -308,7 +310,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
return coValues;
} else {
console.log("Appending to WAL", newContent.id);
// console.log("Appending to WAL", newContent.id);
yield* this.withWAL((wal) =>
writeToWal(
wal,
@@ -325,24 +327,65 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
);
}
loadCoValue<WH, RH, FS extends FileSystem<WH, RH>>(
getBlockHandle(
blockFile: BlockFilename,
fs: FS,
): Effect.Effect<{ handle: RH; size: number }, FSErr> {
return Effect.gen(this, function* () {
let handleAndSize = this.blockFileHandles.get(blockFile);
if (!handleAndSize) {
handleAndSize = yield* Deferred.make<
{ handle: RH; size: number },
FSErr
>();
this.blockFileHandles.set(blockFile, handleAndSize);
yield* Deferred.complete(
handleAndSize,
fs.openToRead(blockFile),
);
}
return yield* Deferred.await(handleAndSize);
});
}
loadCoValue(
id: RawCoID,
fs: FS,
): Effect.Effect<CoValueChunk | undefined, FSErr> {
// return _loadChunkFromWal(id, fs);
return Effect.gen(this, function* () {
const files = this.fileCache || (yield* fs.listFiles());
this.fileCache = files;
const blockFiles = files.filter((name) =>
name.startsWith("hash_"),
) as BlockFilename[];
const blockFiles = (
files.filter((name) => name.startsWith("L")) as BlockFilename[]
).sort();
let result;
for (const blockFile of blockFiles) {
let cachedHeader:
| { [id: RawCoID]: { start: number; length: number } }
| undefined = this.headerCache.get(blockFile);
const { handle, size } = yield* fs.openToRead(blockFile);
let handleAndSize = this.blockFileHandles.get(blockFile);
if (!handleAndSize) {
handleAndSize = yield* Deferred.make<
{ handle: RH; size: number },
FSErr
>();
this.blockFileHandles.set(blockFile, handleAndSize);
yield* Deferred.complete(
handleAndSize,
fs.openToRead(blockFile),
);
}
const { handle, size } = yield* this.getBlockHandle(
blockFile,
fs,
);
// console.log("Attempting to load", id, blockFile);
if (!cachedHeader) {
cachedHeader = {};
@@ -363,17 +406,31 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
}
const headerEntry = cachedHeader[id];
let result;
// console.log("Header entry", id, headerEntry);
if (headerEntry) {
result = yield* readChunk(handle, headerEntry, fs);
const nextChunk = yield* readChunk(handle, headerEntry, fs);
if (result) {
const merged = mergeChunks(result, nextChunk);
if (Either.isRight(merged)) {
yield* Effect.logWarning(
"Non-contigous chunks while loading " + id,
result,
nextChunk,
);
} else {
result = merged.left;
}
} else {
result = nextChunk;
}
}
yield* fs.close(handle);
return result;
// yield* fs.close(handle);
}
return undefined;
return result;
});
}
@@ -389,7 +446,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
const coValues = new Map<RawCoID, CoValueChunk>();
console.log("Compacting WAL files", walFiles);
yield* Effect.log("Compacting WAL files", walFiles);
if (walFiles.length === 0) return;
yield* SynchronizedRef.updateEffect(this.currentWal, (wal) =>
@@ -402,7 +459,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
);
for (const fileName of walFiles) {
const { handle, size } =
const { handle, size }: { handle: RH; size: number } =
yield* this.fs.openToRead(fileName);
if (size === 0) {
yield* this.fs.close(handle);
@@ -422,7 +479,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
if (existingChunk) {
const merged = mergeChunks(existingChunk, chunk);
if (Either.isRight(merged)) {
console.warn(
yield* Effect.logWarning(
"Non-contigous chunks in " +
chunk.id +
", " +
@@ -441,18 +498,157 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
yield* this.fs.close(handle);
}
yield* writeBlock(coValues, 0, this.fs);
const highestBlockNumber = fileNames.reduce((acc, name) => {
if (name.startsWith("L" + MAX_N_LEVELS)) {
const num = parseInt(name.split("-")[1]!);
if (num > acc) {
return num;
}
}
return acc;
}, 0);
console.log(
[...coValues.keys()],
fileNames,
highestBlockNumber,
);
yield* writeBlock(
coValues,
MAX_N_LEVELS,
highestBlockNumber + 1,
this.fs,
);
for (const walFile of walFiles) {
yield* this.fs.removeFile(walFile);
}
this.fileCache = undefined;
const fileNames2 = yield* this.fs.listFiles();
const blockFiles = (
fileNames2.filter((name) =>
name.startsWith("L"),
) as BlockFilename[]
).sort();
const blockFilesByLevelInOrder: {
[level: number]: BlockFilename[];
} = {};
for (const blockFile of blockFiles) {
const level = parseInt(blockFile.split("-")[0]!.slice(1));
if (!blockFilesByLevelInOrder[level]) {
blockFilesByLevelInOrder[level] = [];
}
blockFilesByLevelInOrder[level]!.push(blockFile);
}
console.log(blockFilesByLevelInOrder);
for (let level = MAX_N_LEVELS; level > 0; level--) {
const nBlocksDesired = Math.pow(2, level);
const blocksInLevel = blockFilesByLevelInOrder[level];
if (
blocksInLevel &&
blocksInLevel.length > nBlocksDesired
) {
yield* Effect.log("Compacting blocks in level", level, blocksInLevel);
const coValues = new Map<RawCoID, CoValueChunk>();
for (const blockFile of blocksInLevel) {
const {
handle,
size,
}: { handle: RH; size: number } =
yield* this.getBlockHandle(blockFile, this.fs);
if (size === 0) {
continue;
}
const header = yield* readHeader(
blockFile,
handle,
size,
this.fs,
);
for (const entry of header) {
const chunk = yield* readChunk(
handle,
entry,
this.fs,
);
const existingChunk = coValues.get(entry.id);
if (existingChunk) {
const merged = mergeChunks(
existingChunk,
chunk,
);
if (Either.isRight(merged)) {
yield* Effect.logWarning(
"Non-contigous chunks in " +
entry.id +
", " +
blockFile,
existingChunk,
chunk,
);
} else {
coValues.set(entry.id, merged.left);
}
} else {
coValues.set(entry.id, chunk);
}
}
}
let levelBelow = blockFilesByLevelInOrder[level - 1];
if (!levelBelow) {
levelBelow = [];
blockFilesByLevelInOrder[level - 1] = levelBelow;
}
const highestBlockNumberInLevelBelow =
levelBelow.reduce((acc, name) => {
const num = parseInt(name.split("-")[1]!);
if (num > acc) {
return num;
}
return acc;
}, 0);
const newBlockName = yield* writeBlock(
coValues,
level - 1,
highestBlockNumberInLevelBelow + 1,
this.fs,
);
levelBelow.push(newBlockName);
// delete blocks that went into this one
for (const blockFile of blocksInLevel) {
const handle = yield* this.getBlockHandle(
blockFile,
this.fs,
);
yield* this.fs.close(handle.handle);
yield* this.fs.removeFile(blockFile);
}
}
}
}),
);
setTimeout(() => this.compact(), 5000);
}
static asPeer<WH, RH, FS extends FileSystem<WH, RH>>({
static async asPeer<WH, RH, FS extends FileSystem<WH, RH>>({
fs,
trace,
localNodeName = "local",
@@ -460,15 +656,13 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
fs: FS;
trace?: boolean;
localNodeName?: string;
}): Peer {
const [localNodeAsPeer, storageAsPeer] = connectedPeers(
localNodeName,
"storage",
{
}): Promise<Peer> {
const [localNodeAsPeer, storageAsPeer] = await Effect.runPromise(
connectedPeers(localNodeName, "storage", {
peer1role: "client",
peer2role: "server",
trace,
},
}),
);
new LSMStorage(fs, localNodeAsPeer.incoming, localNodeAsPeer.outgoing);

View File

@@ -1,8 +1,4 @@
import {
ReadableStream,
TransformStream,
WritableStream,
} from "isomorphic-streams";
import { Console, Effect, Queue, Stream } from "effect";
import { Peer, PeerID, SyncMessage } from "./sync.js";
export function connectedPeers(
@@ -17,160 +13,60 @@ export function connectedPeers(
peer1role?: Peer["role"];
peer2role?: Peer["role"];
} = {},
): [Peer, Peer] {
const [inRx1, inTx1] = newStreamPair<SyncMessage>(peer1id + "_in");
const [outRx1, outTx1] = newStreamPair<SyncMessage>(peer1id + "_out");
): Effect.Effect<[Peer, Peer]> {
return Effect.gen(function* () {
const [from1to2Rx, from1to2Tx] = yield* newQueuePair(
trace ? { traceAs: `${peer1id} -> ${peer2id}` } : undefined,
);
const [from2to1Rx, from2to1Tx] = yield* newQueuePair(
trace ? { traceAs: `${peer2id} -> ${peer1id}` } : undefined,
);
const [inRx2, inTx2] = newStreamPair<SyncMessage>(peer2id + "_in");
const [outRx2, outTx2] = newStreamPair<SyncMessage>(peer2id + "_out");
const peer2AsPeer: Peer = {
id: peer2id,
incoming: from2to1Rx,
outgoing: from1to2Tx,
role: peer2role,
};
void outRx2
.pipeThrough(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void },
) {
trace &&
console.debug(
`${peer2id} -> ${peer1id}`,
JSON.stringify(
chunk,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
2,
),
);
controller.enqueue(chunk);
},
}),
)
.pipeTo(inTx1);
const peer1AsPeer: Peer = {
id: peer1id,
incoming: from1to2Rx,
outgoing: from2to1Tx,
role: peer1role,
};
void outRx1
.pipeThrough(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void },
) {
trace &&
console.debug(
`${peer1id} -> ${peer2id}`,
JSON.stringify(
chunk,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
2,
),
);
controller.enqueue(chunk);
},
}),
)
.pipeTo(inTx2);
const peer2AsPeer: Peer = {
id: peer2id,
incoming: inRx1,
outgoing: outTx1,
role: peer2role,
};
const peer1AsPeer: Peer = {
id: peer1id,
incoming: inRx2,
outgoing: outTx2,
role: peer1role,
};
return [peer1AsPeer, peer2AsPeer];
return [peer1AsPeer, peer2AsPeer];
});
}
export function newStreamPair<T>(
pairName?: string,
): [ReadableStream<T>, WritableStream<T>] {
let queueLength = 0;
let readerClosed = false;
export function newQueuePair(
options: { traceAs?: string } = {},
): Effect.Effect<[Stream.Stream<SyncMessage>, Queue.Enqueue<SyncMessage>]> {
return Effect.gen(function* () {
const queue = yield* Queue.unbounded<SyncMessage>();
let resolveEnqueue: (enqueue: (item: T) => void) => void;
const enqueuePromise = new Promise<(item: T) => void>((resolve) => {
resolveEnqueue = resolve;
});
let resolveClose: (close: () => void) => void;
const closePromise = new Promise<() => void>((resolve) => {
resolveClose = resolve;
});
let queueWasOverflowing = false;
function maybeReportQueueLength() {
if (queueLength >= 100) {
queueWasOverflowing = true;
if (queueLength % 100 === 0) {
console.warn(pairName, "overflowing queue length", queueLength);
}
if (options.traceAs) {
return [
Stream.fromQueue(queue).pipe(
Stream.tap((msg) =>
Console.debug(
options.traceAs,
JSON.stringify(
msg,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
2,
),
),
),
),
queue,
];
} else {
if (queueWasOverflowing) {
console.debug(pairName, "ok queue length", queueLength);
queueWasOverflowing = false;
}
return [Stream.fromQueue(queue), queue];
}
}
const readable = new ReadableStream<T>({
async start(controller) {
resolveEnqueue(controller.enqueue.bind(controller));
resolveClose(controller.close.bind(controller));
},
cancel(_reason) {
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");
} else {
// make sure write resolves before corresponding read, but make sure writes are still in order
await lastWritePromise;
lastWritePromise = new Promise((resolve) => {
enqueue(chunk);
resolve();
});
}
},
async abort(reason) {
console.debug("Manually closing writer", reason);
const close = await closePromise;
close();
},
});
return [readable, writable];
}

View File

@@ -1,13 +1,9 @@
import { Signature } from "./crypto/crypto.js";
import { CoValueHeader, Transaction } from "./coValueCore.js";
import { CoValueCore } from "./coValueCore.js";
import { LocalNode } from "./localNode.js";
import {
ReadableStream,
WritableStream,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
import { LocalNode, newLoadingState } from "./localNode.js";
import { RawCoID, SessionID } from "./ids.js";
import { Data, Effect, Queue, Stream } from "effect";
export type CoValueKnownState = {
id: RawCoID;
@@ -60,10 +56,24 @@ export type DoneMessage = {
export type PeerID = string;
export class DisconnectedError extends Data.TaggedError("DisconnectedError")<{
message: string;
}> {}
export class PingTimeoutError extends Error {
readonly _tag = "PingTimeoutError";
}
export type IncomingSyncStream = Stream.Stream<
SyncMessage,
DisconnectedError | PingTimeoutError
>;
export type OutgoingSyncQueue = Queue.Enqueue<SyncMessage>;
export interface Peer {
id: PeerID;
incoming: ReadableStream<SyncMessage>;
outgoing: WritableStream<SyncMessage>;
incoming: IncomingSyncStream;
outgoing: OutgoingSyncQueue;
role: "peer" | "server" | "client";
delayOnError?: number;
priority?: number;
@@ -73,8 +83,8 @@ export interface PeerState {
id: PeerID;
optimisticKnownStates: { [id: RawCoID]: CoValueKnownState };
toldKnownState: Set<RawCoID>;
incoming: ReadableStream<SyncMessage>;
outgoing: WritableStreamDefaultWriter<SyncMessage>;
incoming: IncomingSyncStream;
outgoing: OutgoingSyncQueue;
role: "peer" | "server" | "client";
delayOnError?: number;
priority?: number;
@@ -127,25 +137,24 @@ export class SyncManager {
});
}
async loadFromPeers(id: RawCoID, excludePeer?: PeerID) {
for (const peer of this.peersInPriorityOrder()) {
if (peer.id === excludePeer) {
continue;
}
if (peer.role !== "server") {
continue;
}
async loadFromPeers(id: RawCoID, forPeer?: PeerID) {
const eligiblePeers = this.peersInPriorityOrder().filter(
(peer) => peer.id !== forPeer && peer.role === "server",
);
for (const peer of eligiblePeers) {
// console.log("loading", id, "from", peer.id);
peer.outgoing
.write({
Effect.runPromise(
Queue.offer(peer.outgoing, {
action: "load",
id: id,
header: false,
sessions: {},
})
.catch((e) => {
console.error("Error writing to peer", e);
});
}),
).catch((e) => {
console.error("Error writing to peer", e);
});
const coValueEntry = this.local.coValues[id];
if (coValueEntry?.state !== "loading") {
continue;
@@ -297,7 +306,9 @@ export class SyncManager {
let lastYield = performance.now();
for (const [_i, piece] of newContentPieces.entries()) {
// console.log(
// `${id} -> ${peer.id}: Sending content piece ${i + 1}/${newContentPieces.length} header: ${!!piece.header}`,
// `${id} -> ${peer.id}: Sending content piece ${i + 1}/${
// newContentPieces.length
// } header: ${!!piece.header}`,
// // Object.values(piece.new).map((s) => s.newTransactions)
// );
await this.trySendToPeer(peer, piece);
@@ -328,7 +339,7 @@ export class SyncManager {
id: peer.id,
optimisticKnownStates: {},
incoming: peer.incoming,
outgoing: peer.outgoing.getWriter(),
outgoing: peer.outgoing,
toldKnownState: new Set(),
role: peer.role,
delayOnError: peer.delayOnError,
@@ -354,91 +365,55 @@ export class SyncManager {
void initialSync();
}
const readIncoming = async () => {
try {
for await (const msg of peerState.incoming) {
try {
// 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);
// });
} 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,
void Effect.runPromise(
peerState.incoming.pipe(
Stream.ensuring(
Effect.sync(() => {
console.log("Peer disconnected:", peer.id);
delete this.peers[peer.id];
}),
),
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => this.handleSyncMessage(msg, peerState),
catch: (e) =>
new Error(
`Error reading from peer ${
peer.id
}, handling msg\n\n${JSON.stringify(
msg,
(k, v) =>
k === "changes" ||
k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
)}`,
{ cause: e },
),
e,
);
if (peerState.delayOnError) {
await new Promise<void>((resolve) => {
setTimeout(resolve, peerState.delayOnError);
});
}
}
}
} catch (e) {
console.error(`Error reading from peer ${peer.id}`, e);
}
console.log("Peer disconnected:", peer.id);
delete this.peers[peer.id];
};
void readIncoming();
}).pipe(
Effect.timeoutFail({
duration: 10000,
onTimeout: () =>
new Error("Took >10s to process message"),
}),
),
),
Effect.catchAll((e) =>
Effect.logError(
"Error in peer",
peer.id,
e.message,
typeof e.cause === "object" &&
e.cause instanceof Error &&
e.cause.message,
),
),
),
);
}
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();
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`
// )
// );
} else {
resolve();
}
})
.catch((e) => {
console.error(
new Error(
`Error writing to peer ${peer.id}, disconnecting`,
{
cause: e,
},
),
);
delete this.peers[peer.id];
});
});
return Effect.runPromise(Queue.offer(peer.outgoing, msg));
}
async handleLoad(msg: LoadMessage, peer: PeerState) {
@@ -447,21 +422,50 @@ export class SyncManager {
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);
});
// special case: we should be able to solve this much more neatly
// with an explicit state machine in the future
const eligiblePeers = this.peersInPriorityOrder().filter(
(other) => other.id !== peer.id && peer.role === "server",
);
if (eligiblePeers.length === 0) {
if (msg.header || Object.keys(msg.sessions).length > 0) {
this.local.coValues[msg.id] = newLoadingState(
new Set([peer.id]),
);
this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
}).catch((e) => {
console.error("Error sending known state back", e);
});
}
return;
} else {
this.local
.loadCoValueCore(msg.id, {
dontLoadFrom: peer.id,
dontWaitFor: peer.id,
})
.catch((e) => {
console.error("Error loading coValue in handleLoad", e);
});
}
entry = this.local.coValues[msg.id]!;
}
if (entry.state === "loading") {
console.log(
"Waiting for loaded",
msg.id,
"after message from",
peer.id,
);
const loaded = await entry.done;
console.log("Loaded", msg.id, loaded);
if (loaded === "unavailable") {
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
peer.toldKnownState.add(msg.id);
@@ -508,7 +512,7 @@ export class SyncManager {
}
} else {
throw new Error(
"Expected coValue entry to be created, missing subscribe?",
`Expected coValue entry for ${msg.id} to be created on known state, missing subscribe?`,
);
}
}
@@ -549,7 +553,7 @@ export class SyncManager {
if (!entry) {
throw new Error(
"Expected coValue entry to be created, missing subscribe?",
`Expected coValue entry for ${msg.id} to be created on new content, missing subscribe?`,
);
}

View File

@@ -3,6 +3,7 @@ import { newRandomSessionID } from "../coValueCore.js";
import { LocalNode } from "../localNode.js";
import { connectedPeers } from "../streamUtils.js";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { Effect } from "effect";
const Crypto = await WasmCrypto.create();
@@ -52,11 +53,15 @@ test("Can create account with one node, and then load it on another", async () =
map.set("foo", "bar", "private");
expect(map.get("foo")).toEqual("bar");
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {
trace: true,
peer1role: "server",
peer2role: "client",
});
const [node1asPeer, node2asPeer] = await Effect.runPromise(
connectedPeers("node1", "node2", {
trace: true,
peer1role: "server",
peer2role: "client",
}),
);
console.log("After connected peers");
node.syncManager.addPeer(node2asPeer);

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,111 @@
# jazz-browser-media-images
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-browser@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.23
- jazz-browser@0.7.23
## 0.7.22
### Patch Changes
- jazz-browser@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-browser@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-browser@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-browser@0.7.19
## 0.7.18
### Patch Changes
- jazz-browser@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- jazz-browser@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-browser@0.7.16
## 0.7.14
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.14
- jazz-browser@0.7.14
## 0.7.13
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
- jazz-browser@0.7.13
## 0.7.12
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
- jazz-browser@0.7.12
## 0.7.11
### Patch Changes
- jazz-browser@0.7.11
- jazz-tools@0.7.11
## 0.7.10
### Patch Changes
- jazz-browser@0.7.10
- jazz-tools@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-browser-media-images",
"version": "0.7.9",
"version": "0.7.24",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,119 @@
# jazz-browser
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- cojson-storage-indexeddb@0.7.23
- cojson-transport-ws@0.7.23
## 0.7.22
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-storage-indexeddb@0.7.18
- cojson-transport-ws@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-storage-indexeddb@0.7.17
- cojson-transport-ws@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
## 0.7.14
### Patch Changes
- Updated dependencies
- cojson@0.7.14
- jazz-tools@0.7.14
- cojson-storage-indexeddb@0.7.14
- cojson-transport-ws@0.7.14
## 0.7.13
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
## 0.7.12
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
- cojson-storage-indexeddb@0.7.11
- jazz-tools@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
- cojson-storage-indexeddb@0.7.10
- jazz-tools@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-browser",
"version": "0.7.9",
"version": "0.7.24",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -9,8 +9,8 @@
"@scure/bip39": "^1.3.0",
"cojson": "workspace:*",
"cojson-storage-indexeddb": "workspace:*",
"effect": "^3.1.5",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae",
"cojson-transport-ws": "workspace:*",
"effect": "^3.5.2",
"jazz-tools": "workspace:*",
"typescript": "^5.1.6"
},

View File

@@ -7,7 +7,7 @@ import {
} from "cojson";
import { Effect } from "effect";
export class OPFSFilesystem implements FileSystem<number, number> {
export class OPFSFilesystem implements FileSystem<{id: number, filename: string}, {id: number, filename: string}> {
opfsWorker: Worker;
callbacks: Map<number, (event: MessageEvent) => void> = new Map();
nextRequestId = 0;
@@ -31,13 +31,13 @@ export class OPFSFilesystem implements FileSystem<number, number> {
listFiles(): Effect.Effect<string[], FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("listFiles" + requestId);
performance.mark("listFiles" + requestId + "_listFiles");
this.callbacks.set(requestId, (event) => {
performance.mark("listFilesEnd" + requestId);
performance.mark("listFilesEnd" + requestId + "_listFiles");
performance.measure(
"listFiles" + requestId,
"listFiles" + requestId,
"listFilesEnd" + requestId,
"listFiles" + requestId + "_listFiles",
"listFiles" + requestId + "_listFiles",
"listFilesEnd" + requestId + "_listFiles",
);
cb(Effect.succeed(event.data.fileNames));
});
@@ -47,22 +47,22 @@ export class OPFSFilesystem implements FileSystem<number, number> {
openToRead(
filename: string,
): Effect.Effect<{ handle: number; size: number }, FSErr, never> {
): Effect.Effect<{ handle: {id: number, filename: string}; size: number }, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("openToRead" + requestId);
performance.mark("openToRead" + "_" + filename);
this.callbacks.set(requestId, (event) => {
cb(
Effect.succeed({
handle: event.data.handle,
handle: {id: event.data.handle, filename},
size: event.data.size,
}),
);
performance.mark("openToReadEnd" + requestId);
performance.mark("openToReadEnd" + "_" + filename);
performance.measure(
"openToRead" + requestId,
"openToRead" + requestId,
"openToReadEnd" + requestId,
"openToRead" + "_" + filename,
"openToRead" + "_" + filename,
"openToReadEnd" + "_" + filename,
);
});
this.opfsWorker.postMessage({
@@ -73,18 +73,18 @@ export class OPFSFilesystem implements FileSystem<number, number> {
});
}
createFile(filename: string): Effect.Effect<number, FSErr, never> {
createFile(filename: string): Effect.Effect<{id: number, filename: string}, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("createFile" + requestId);
performance.mark("createFile" + "_" + filename);
this.callbacks.set(requestId, (event) => {
performance.mark("createFileEnd" + requestId);
performance.mark("createFileEnd" + "_" + filename);
performance.measure(
"createFile" + requestId,
"createFile" + requestId,
"createFileEnd" + requestId,
"createFile" + "_" + filename,
"createFile" + "_" + filename,
"createFileEnd" + "_" + filename,
);
cb(Effect.succeed(event.data.handle));
cb(Effect.succeed({id: event.data.handle, filename}));
});
this.opfsWorker.postMessage({
type: "createFile",
@@ -96,18 +96,18 @@ export class OPFSFilesystem implements FileSystem<number, number> {
openToWrite(
filename: string,
): Effect.Effect<FileSystemFileHandle, FSErr, never> {
): Effect.Effect<{id: number, filename: string}, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("openToWrite" + requestId);
performance.mark("openToWrite" + "_" + filename);
this.callbacks.set(requestId, (event) => {
performance.mark("openToWriteEnd" + requestId);
performance.mark("openToWriteEnd" + "_" + filename);
performance.measure(
"openToWrite" + requestId,
"openToWrite" + requestId,
"openToWriteEnd" + requestId,
"openToWrite" + "_" + filename,
"openToWrite" + "_" + filename,
"openToWriteEnd" + "_" + filename,
);
cb(Effect.succeed(event.data.handle));
cb(Effect.succeed({id: event.data.handle, filename}));
});
this.opfsWorker.postMessage({
type: "openToWrite",
@@ -118,24 +118,24 @@ export class OPFSFilesystem implements FileSystem<number, number> {
}
append(
handle: number,
handle: {id: number, filename: string},
data: Uint8Array,
): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("append" + requestId);
performance.mark("append" + "_" + handle.filename);
this.callbacks.set(requestId, (_) => {
performance.mark("appendEnd" + requestId);
performance.mark("appendEnd" + "_" + handle.filename);
performance.measure(
"append" + requestId,
"append" + requestId,
"appendEnd" + requestId,
"append" + "_" + handle.filename,
"append" + "_" + handle.filename,
"appendEnd" + "_" + handle.filename,
);
cb(Effect.succeed(undefined));
});
this.opfsWorker.postMessage({
type: "append",
handle,
handle: handle.id,
data,
requestId,
});
@@ -143,25 +143,25 @@ export class OPFSFilesystem implements FileSystem<number, number> {
}
read(
handle: number,
handle: {id: number, filename: string},
offset: number,
length: number,
): Effect.Effect<Uint8Array, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("read" + requestId);
performance.mark("read" + "_" + handle.filename);
this.callbacks.set(requestId, (event) => {
performance.mark("readEnd" + requestId);
performance.mark("readEnd" + "_" + handle.filename);
performance.measure(
"read" + requestId,
"read" + requestId,
"readEnd" + requestId,
"read" + "_" + handle.filename,
"read" + "_" + handle.filename,
"readEnd" + "_" + handle.filename,
);
cb(Effect.succeed(event.data.data));
});
this.opfsWorker.postMessage({
type: "read",
handle,
handle: handle.id,
offset,
length,
requestId,
@@ -169,46 +169,48 @@ export class OPFSFilesystem implements FileSystem<number, number> {
});
}
close(handle: number): Effect.Effect<void, FSErr, never> {
close(handle: {id: number, filename: string}): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("close" + requestId);
performance.mark("close" + "_" + handle.filename);
this.callbacks.set(requestId, (_) => {
performance.mark("closeEnd" + requestId);
performance.mark("closeEnd" + "_" + handle.filename);
performance.measure(
"close" + requestId,
"close" + requestId,
"closeEnd" + requestId,
"close" + "_" + handle.filename,
"close" + "_" + handle.filename,
"closeEnd" + "_" + handle.filename,
);
cb(Effect.succeed(undefined));
});
this.opfsWorker.postMessage({
type: "close",
handle,
handle: handle.id,
requestId,
});
});
}
closeAndRename(
handle: number,
handle: {id: number, filename: string},
filename: BlockFilename,
): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("closeAndRename" + requestId);
performance.mark("closeAndRename" + "_" + handle.filename);
this.callbacks.set(requestId, () => {
performance.mark("closeAndRenameEnd" + requestId);
performance.mark(
"closeAndRenameEnd" + "_" + handle.filename,
);
performance.measure(
"closeAndRename" + requestId,
"closeAndRename" + requestId,
"closeAndRenameEnd" + requestId,
"closeAndRename" + "_" + handle.filename,
"closeAndRename" + "_" + handle.filename,
"closeAndRenameEnd" + "_" + handle.filename,
);
cb(Effect.succeed(undefined));
});
this.opfsWorker.postMessage({
type: "closeAndRename",
handle,
handle: handle.id,
filename,
requestId,
});
@@ -220,13 +222,13 @@ export class OPFSFilesystem implements FileSystem<number, number> {
): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
const requestId = this.nextRequestId++;
performance.mark("removeFile" + requestId);
performance.mark("removeFile" + "_" + filename);
this.callbacks.set(requestId, () => {
performance.mark("removeFileEnd" + requestId);
performance.mark("removeFileEnd" + "_" + filename);
performance.measure(
"removeFile" + requestId,
"removeFile" + requestId,
"removeFileEnd" + requestId,
"removeFile" + "_" + filename,
"removeFile" + "_" + filename,
"removeFileEnd" + "_" + filename,
);
cb(Effect.succeed(undefined));
});
@@ -309,7 +311,7 @@ const opfsWorkerJSSrc = `
postMessage({requestId: event.data.requestId, data: buffer, result: "done"});
} else if (event.data.type === "close") {
const handle = handlesByRequest.get(event.data.handle);
console.log("Closing handle", filenamesForHandles.get(handle), event.data.handle, handle);
// console.log("Closing handle", filenamesForHandles.get(handle), event.data.handle, handle);
handle.flush();
handle.close();
handlesByRequest.delete(handle);

View File

@@ -1,11 +1,8 @@
import { ReadableStream, WritableStream } from "isomorphic-streams";
import {
CoValue,
ID,
Peer,
AgentID,
SessionID,
SyncMessage,
cojsonInternals,
InviteSecret,
Account,
@@ -17,6 +14,8 @@ import { AccountID, LSMStorage } from "cojson";
import { AuthProvider } from "./auth/auth.js";
import { OPFSFilesystem } from "./OPFSFilesystem.js";
import { IDBStorage } from "cojson-storage-indexeddb";
import { Effect, Queue } from "effect";
import { createWebSocketPeer } from "cojson-transport-ws";
export * from "./auth/auth.js";
/** @category Context Creation */
@@ -29,7 +28,7 @@ export type BrowserContext<Acc extends Account> = {
/** @category Context Creation */
export async function createJazzBrowserContext<Acc extends Account>({
auth,
peer,
peer: peerAddr,
reconnectionTimeout: initialReconnectionTimeout = 500,
storage = "indexedDB",
crypto: customCrypto,
@@ -37,13 +36,19 @@ export async function createJazzBrowserContext<Acc extends Account>({
auth: AuthProvider<Acc>;
peer: `wss://${string}` | `ws://${string}`;
reconnectionTimeout?: number;
storage?: "indexedDB" | "experimentalOPFSdoNotUseOrYouWillBeFired";
storage?: "indexedDB" | "singleTabOPFS";
crypto?: CryptoProvider;
}): Promise<BrowserContext<Acc>> {
const crypto = customCrypto || (await WasmCrypto.create());
let sessionDone: () => void;
const firstWsPeer = createWebSocketPeer(peer);
const firstWsPeer = await Effect.runPromise(
createWebSocketPeer({
websocket: new WebSocket(peerAddr),
id: peerAddr + "@" + new Date().toISOString(),
role: "server",
}),
);
let shouldTryToReconnect = true;
let currentReconnectionTimeout = initialReconnectionTimeout;
@@ -77,7 +82,7 @@ export async function createJazzBrowserContext<Acc extends Account>({
while (shouldTryToReconnect) {
if (
Object.keys(me._raw.core.node.syncManager.peers).some(
(peerId) => peerId.includes(peer),
(peerId) => peerId.includes(peerAddr),
)
) {
// TODO: this might drain battery, use listeners instead
@@ -107,7 +112,13 @@ export async function createJazzBrowserContext<Acc extends Account>({
});
me._raw.core.node.syncManager.addPeer(
createWebSocketPeer(peer),
await Effect.runPromise(
createWebSocketPeer({
websocket: new WebSocket(peerAddr),
id: peerAddr + "@" + new Date().toISOString(),
role: "server",
}),
),
);
}
}
@@ -124,9 +135,7 @@ export async function createJazzBrowserContext<Acc extends Account>({
for (const peer of Object.values(
me._raw.core.node.syncManager.peers,
)) {
peer.outgoing
.close()
.catch((e) => console.error("Error while closing peer", e));
void Effect.runPromise(Queue.shutdown(peer.outgoing));
}
sessionDone?.();
},
@@ -207,140 +216,6 @@ export function getSessionHandleFor(
};
}
function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
return new ReadableStream<T>({
start(controller) {
let pingTimeout: ReturnType<typeof setTimeout> | undefined;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (pingTimeout) {
clearTimeout(pingTimeout);
}
pingTimeout = setTimeout(() => {
console.debug("Ping timeout");
try {
controller.close();
ws.close();
} catch (e) {
console.error(
"Error while trying to close ws on ping timeout",
e,
);
}
}, 2500);
if (msg.type === "ping") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).jazzPings = (window as any).jazzPings || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).jazzPings.push({
received: Date.now(),
sent: msg.time,
dc: msg.dc,
});
return;
}
controller.enqueue(msg);
};
const closeListener = () => {
controller.close();
clearTimeout(pingTimeout);
};
ws.addEventListener("close", closeListener);
ws.addEventListener("error", () => {
controller.error(new Error("The WebSocket errored!"));
ws.removeEventListener("close", closeListener);
});
},
cancel() {
ws.close();
},
});
}
export function createWebSocketPeer(syncAddress: string): Peer {
const ws = new WebSocket(syncAddress);
const incoming = websocketReadableStream<SyncMessage>(ws);
const outgoing = websocketWritableStream<SyncMessage>(ws);
return {
id: syncAddress + "@" + new Date().toISOString(),
incoming,
outgoing,
role: "server",
};
}
function websocketWritableStream<T>(ws: WebSocket) {
const initialQueue = [] as T[];
let isOpen = false;
return new WritableStream<T>({
start(controller) {
ws.addEventListener("error", (event) => {
controller.error(
new Error("The WebSocket errored!" + JSON.stringify(event)),
);
});
ws.addEventListener("close", () => {
controller.error(
new Error("The server closed the connection unexpectedly!"),
);
});
ws.addEventListener("open", () => {
for (const item of initialQueue) {
ws.send(JSON.stringify(item));
}
isOpen = true;
});
},
async write(chunk) {
if (isOpen) {
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.
} else {
initialQueue.push(chunk);
}
},
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.addEventListener(
"close",
(e) => {
if (e.wasClean) {
resolve();
} else {
reject(
new Error("The connection was not closed cleanly"),
);
}
},
{ once: true },
);
ws.close(code, reasonString);
});
}
}
/** @category Invite Links */
export function createInviteLink<C extends CoValue>(
value: C,

View File

@@ -1,5 +1,115 @@
# jazz-autosub
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- cojson-transport-ws@0.7.23
## 0.7.22
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-transport-ws@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-transport-ws@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
## 0.7.14
### Patch Changes
- Updated dependencies
- cojson@0.7.14
- jazz-tools@0.7.14
- cojson-transport-ws@0.7.14
## 0.7.13
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
## 0.7.12
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
- cojson-transport-nodejs-ws@0.7.11
- jazz-tools@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
- cojson-transport-nodejs-ws@0.7.10
- jazz-tools@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -5,10 +5,11 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.7.9",
"version": "0.7.24",
"dependencies": {
"cojson": "workspace:*",
"cojson-transport-nodejs-ws": "workspace:*",
"cojson-transport-ws": "workspace:*",
"effect": "^3.5.2",
"jazz-tools": "workspace:*",
"ws": "^8.14.2"
},

View File

@@ -1,11 +1,8 @@
import {
websocketReadableStream,
websocketWritableStream,
} from "cojson-transport-nodejs-ws";
import { WebSocket } from "ws";
import { AgentSecret, Peer, SessionID, WasmCrypto } from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import { Account, CoValueClass, ID } from "jazz-tools";
import { Effect } from "effect";
import { WebSocket } from "ws";
/** @category Context Creation */
export async function startWorker<Acc extends Account>({
@@ -21,14 +18,13 @@ export async function startWorker<Acc extends Account>({
syncServer?: string;
accountSchema?: CoValueClass<Acc> & typeof Account;
}): Promise<{ worker: Acc }> {
const ws = new WebSocket(peer);
const wsPeer: Peer = {
id: "upstream",
role: "server",
incoming: websocketReadableStream(ws),
outgoing: websocketWritableStream(ws),
};
const wsPeer: Peer = await Effect.runPromise(
createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peer),
role: "server",
}),
);
if (!accountID) {
throw new Error("No accountID provided");
@@ -52,17 +48,17 @@ export async function startWorker<Acc extends Account>({
crypto: await WasmCrypto.create(),
});
setInterval(() => {
setInterval(async () => {
if (!worker._raw.core.node.syncManager.peers["upstream"]) {
console.log(new Date(), "Reconnecting to upstream " + peer);
const ws = new WebSocket(peer);
const wsPeer: Peer = {
id: "upstream",
role: "server",
incoming: websocketReadableStream(ws),
outgoing: websocketWritableStream(ws),
};
const wsPeer: Peer = await Effect.runPromise(
createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peer),
role: "server",
}),
);
worker._raw.core.node.syncManager.addPeer(wsPeer);
}

View File

@@ -1,5 +1,128 @@
# jazz-react
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-browser@0.7.24
## 0.7.23
### Patch Changes
- Mostly complete OPFS implementation (single-tab only)
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- jazz-browser@0.7.23
## 0.7.22
### Patch Changes
- jazz-browser@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-browser@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-browser@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-browser@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- jazz-browser@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- jazz-browser@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-browser@0.7.16
## 0.7.15
### Patch Changes
- Provide current res in ProgressiveImg
## 0.7.14
### Patch Changes
- Updated dependencies
- cojson@0.7.14
- jazz-tools@0.7.14
- jazz-browser@0.7.14
## 0.7.13
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
- jazz-browser@0.7.13
## 0.7.12
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
- jazz-browser@0.7.12
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
- jazz-browser@0.7.11
- jazz-tools@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
- jazz-browser@0.7.10
- jazz-tools@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react",
"version": "0.7.9",
"version": "0.7.24",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -104,7 +104,10 @@ const DemoAuthBasicUI = ({
signUp: (username: string) => void;
}) => {
const [username, setUsername] = useState<string>("");
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
const darkMode =
typeof window !== "undefined"
? window.matchMedia("(prefers-color-scheme: dark)").matches
: false;
return (
<div

View File

@@ -23,7 +23,7 @@ export function createJazzReactContext<Acc extends Account>({
}: {
auth: ReactAuthHook<Acc>;
peer: `wss://${string}` | `ws://${string}`;
storage?: "indexedDB" | "experimentalOPFSdoNotUseOrYouWillBeFired";
storage?: "indexedDB" | "singleTabOPFS";
}): JazzReactContext<Acc> {
const JazzContext = React.createContext<
| {

View File

@@ -9,7 +9,10 @@ export function useProgressiveImg({
image: ImageDefinition | null | undefined;
maxWidth?: number;
}) {
const [src, setSrc] = useState<string | undefined>(undefined);
const [current, setCurrent] = useState<
| { src?: string; res?: `${number}x${number}` | "placeholder" }
| undefined
>(undefined);
useEffect(() => {
let lastHighestRes: string | undefined;
@@ -22,21 +25,28 @@ export function useProgressiveImg({
const blob = highestRes.stream.toBlob();
if (blob) {
const blobURI = URL.createObjectURL(blob);
setSrc(blobURI);
setCurrent({ src: blobURI, res: highestRes.res });
return () => {
setTimeout(() => URL.revokeObjectURL(blobURI), 200);
};
}
}
} else {
setSrc(update?.placeholderDataURL);
setCurrent({
src: update?.placeholderDataURL,
res: "placeholder",
});
}
});
return unsub;
}, [image?.id, maxWidth]);
return { src, originalSize: image?.originalSize };
return {
src: current?.src,
res: current?.res,
originalSize: image?.originalSize,
};
}
/** @category Media */
@@ -47,6 +57,7 @@ export function ProgressiveImg({
}: {
children: (result: {
src: string | undefined;
res: `${number}x${number}` | "placeholder" | undefined;
originalSize: readonly [number, number] | undefined;
}) => React.ReactNode;
image: ImageDefinition | null | undefined;

View File

@@ -1,5 +1,115 @@
# jazz-autosub
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- cojson-transport-ws@0.7.23
## 0.7.22
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-transport-ws@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-transport-ws@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
## 0.7.14
### Patch Changes
- Updated dependencies
- cojson@0.7.14
- jazz-tools@0.7.14
- cojson-transport-ws@0.7.14
## 0.7.13
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
## 0.7.12
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
- cojson-transport-nodejs-ws@0.7.11
- jazz-tools@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
- cojson-transport-nodejs-ws@0.7.10
- jazz-tools@0.7.10
## 0.7.9
### Patch Changes

View File

@@ -3,7 +3,7 @@
"bin": "./dist/index.js",
"type": "module",
"license": "MIT",
"version": "0.7.9",
"version": "0.7.24",
"scripts": {
"lint": "eslint . --ext ts,tsx",
"format": "prettier --write './src/**/*.{ts,tsx}'",
@@ -15,8 +15,8 @@
"@effect/platform-node": "^0.49.2",
"@effect/schema": "^0.66.16",
"cojson": "workspace:*",
"cojson-transport-nodejs-ws": "workspace:*",
"effect": "^3.1.5",
"cojson-transport-ws": "workspace:*",
"effect": "^3.5.2",
"fast-check": "^3.17.2",
"jazz-tools": "workspace:*",
"ws": "^8.14.2"

View File

@@ -2,10 +2,7 @@
import { Command, Options } from "@effect/cli";
import { NodeContext, NodeRuntime } from "@effect/platform-node";
import { Console, Effect } from "effect";
import {
websocketReadableStream,
websocketWritableStream,
} from "cojson-transport-nodejs-ws";
import { createWebSocketPeer } from "cojson-transport-ws";
import { WebSocket } from "ws";
import {
Account,
@@ -24,23 +21,20 @@ const peer = Options.text("peer")
const accountCreate = Command.make(
"create",
{ name, peer },
({ name, peer }) => {
({ name, peer: peerAddr }) => {
return Effect.gen(function* () {
const ws = new WebSocket(peer);
const crypto = yield* Effect.promise(() => WasmCrypto.create());
const peer = yield* createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peerAddr),
role: "server",
});
const account: Account = yield* Effect.promise(async () =>
Account.create({
creationProps: { name },
peersToLoadFrom: [
{
id: "upstream",
role: "server",
incoming: websocketReadableStream(ws),
outgoing: websocketWritableStream(ws),
},
],
peersToLoadFrom: [peer],
crypto,
}),
);
@@ -59,7 +53,11 @@ const accountCreate = Command.make(
),
);
const ws2 = new WebSocket(peer);
const peer2 = yield* createWebSocketPeer({
id: "upstream2",
websocket: new WebSocket(peerAddr),
role: "server",
});
yield* Effect.promise(async () =>
Account.become({
@@ -68,14 +66,7 @@ const accountCreate = Command.make(
sessionID: cojsonInternals.newRandomSessionID(
account.id as unknown as AccountID,
),
peersToLoadFrom: [
{
id: "upstream",
role: "server",
incoming: websocketReadableStream(ws2),
outgoing: websocketWritableStream(ws2),
},
],
peersToLoadFrom: [peer2],
crypto,
}),
);

View File

@@ -1,5 +1,93 @@
# jazz-autosub
## 0.7.24
### Patch Changes
- Remove effectful API for loading/subscribing
## 0.7.23
### Patch Changes
- Mostly complete OPFS implementation (single-tab only)
- Updated dependencies
- cojson@0.7.23
## 0.7.21
### Patch Changes
- Fix another bug in CoMap 'has' proxy trap
## 0.7.20
### Patch Changes
- Fix bug in CoMap 'has' trap
## 0.7.19
### Patch Changes
- Add support for "in" operator in CoMaps
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.16
### Patch Changes
- Fix: allow null in encoded fields
## 0.7.14
### Patch Changes
- Use Effect Queues and Streams instead of custom queue implementation
- Updated dependencies
- cojson@0.7.14
## 0.7.13
### Patch Changes
- Fix CoList.toJSON()
## 0.7.12
### Patch Changes
- Fix: toJSON infinitely recurses on circular CoValue structures
## 0.7.11
### Patch Changes
- Updated dependencies
- cojson@0.7.11
- cojson-transport-nodejs-ws@0.7.11
## 0.7.10
### Patch Changes
- Updated dependencies
- cojson@0.7.10
- cojson-transport-nodejs-ws@0.7.10
## 0.7.9
### Patch Changes

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