Compare commits

...

14 Commits

Author SHA1 Message Date
Anselm
909a101f99 Publish
- jazz-example-pets@0.0.3
 - jazz-example-todo@0.0.28
 - cojson@0.1.12
 - cojson-simple-sync@0.1.13
 - cojson-storage-sqlite@0.1.10
 - jazz-browser@0.1.12
 - jazz-browser-auth-local@0.1.12
 - jazz-react@0.1.14
 - jazz-react-auth-local@0.1.14
 - jazz-storage-indexeddb@0.1.12
2023-09-08 17:29:07 +01:00
Anselm
df0b6fe138 Update docs 2023-09-08 17:28:53 +01:00
Anselm
0543756016 More optimizations and first support for streaming hashing 2023-09-08 17:28:33 +01:00
Anselm
92eae0e180 Publish
- jazz-example-pets@0.0.2
 - jazz-example-todo@0.0.27
 - cojson@0.1.11
 - cojson-simple-sync@0.1.12
 - cojson-storage-sqlite@0.1.9
 - jazz-browser@0.1.11
 - jazz-browser-auth-local@0.1.11
 - jazz-react@0.1.13
 - jazz-react-auth-local@0.1.13
 - jazz-storage-indexeddb@0.1.11
2023-09-08 10:23:44 +01:00
Anselm
9ccc97fcd3 Update docs 2023-09-08 10:23:26 +01:00
Anselm
120ba57274 Beginning of new rate-my-pet example 2023-09-08 10:22:56 +01:00
Anselm
0679a64002 cojson performance optimizations 2023-09-08 10:22:46 +01:00
Anselm
e9d561adbd Fix dangling promises 2023-09-07 19:44:16 +01:00
Anselm
bb5fd24f6a Publish
- jazz-example-todo@0.0.26
 - cojson@0.1.10
 - cojson-simple-sync@0.1.11
 - cojson-storage-sqlite@0.1.8
 - jazz-browser@0.1.10
 - jazz-browser-auth-local@0.1.10
 - jazz-react@0.1.12
 - jazz-react-auth-local@0.1.12
 - jazz-storage-indexeddb@0.1.10
2023-09-07 19:40:12 +01:00
Anselm
18d5b9146f API for CoStream & BinaryCoStream 2023-09-07 18:49:36 +01:00
Anselm Eickhoff
39850d465f Merge pull request #64 from gardencmp:anselm-gar-137
Basic Documentation
2023-09-07 14:09:55 +01:00
Anselm
27e0d6df46 Fix example 2023-09-07 13:29:11 +01:00
Anselm
6d0c820724 Hide internal again 2023-09-07 13:28:07 +01:00
Anselm
78a1d5a614 Fix refactor issues 2023-09-07 13:16:07 +01:00
67 changed files with 3182 additions and 137 deletions

1018
DOCS.md

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
examples/pets/.gitignore vendored Normal file
View File

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

4
examples/pets/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM caddy:2.7.3-alpine
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
COPY ./dist /usr/share/caddy/

65
examples/pets/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Jazz Todo List Example
Live version: https://example-todo.jazz.tools
## Installing & running the example locally
Start by checking out just the example app to a folder:
```bash
npx degit gardencmp/jazz/examples/todo jazz-example-todo
cd jazz-example-todo
```
(This ensures that you have the example app without git history or our multi-package monorepo)
Install dependencies:
```bash
npm install
```
Start the dev server:
```bash
npm run dev
```
## Structure
- [`src/basicComponents`](./src/basicComponents) contains simple components to build the UI, unrelated to Jazz (powered by [shadcn/ui](https://ui.shadcn.com))
- [`src/components`](./src/components/) contains helper components that do contain Jazz-specific logic, but are not super relevant to understand the basics of Jazz and CoJSON
- [`src/0_main.tsx`](./src/0_main.tsx), [`src/1_types.ts`](./src/1_types.ts), [`src/2_App.tsx`](./src/2_App.tsx), [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx), [`src/router.ts`](./src/router.ts) - the main files for this example, see the walkthrough below
## Walkthrough
### Main parts
- The top-level provider `<WithJazz/>`: [`src/0_main.tsx`](./src/0_main.tsx)
- Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
### Helpers
- Getting user profiles in `<NameBadge/>`: [`src/components/NameBadge.tsx`](./src/components/NameBadge.tsx)
- (not yet commented) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
- (not yet commented) `location.hash`-based routing and accepting invite links with `useSimpleHashRouterThatAcceptsInvites()` in [`src/router.ts`](./src/router.ts)
This is the whole Todo List app!
## Questions / problems / feedback
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
## Configuration: sync server
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/0_main.tsx](./src/0_main.tsx).

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "stone",
"cssVariables": true
},
"aliases": {
"components": "@/basicComponents",
"utils": "@/basicComponents/lib/utils"
}
}

13
examples/pets/index.html Normal file
View File

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

View File

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

View File

@@ -0,0 +1,45 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.4",
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "^0.1.14",
"jazz-react-auth-local": "^0.1.14",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"uniqolor": "^1.1.0",
"use-debounce": "^9.0.4"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,38 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { WithJazz } from "jazz-react";
import { LocalAuth } from "jazz-react-auth-local";
import { ThemeProvider, TitleAndLogo } from "./basicComponents/index.ts";
import { PrettyAuthUI } from "./components/Auth.tsx";
import App from "./2_App.tsx";
/** Walkthrough: The top-level provider `<WithJazz/>`
*
* This shows how to use the top-level provider `<WithJazz/>`,
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
* - no backend needed. */
const appName = "Jazz Rate My Pet Example";
const auth = LocalAuth({
appName,
Component: PrettyAuthUI,
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider>
<TitleAndLogo name={appName} />
<WithJazz auth={auth}>
<App />
</WithJazz>
</ThemeProvider>
</React.StrictMode>
);
/** Walkthrough: Continue with ./1_types.ts */

View File

@@ -0,0 +1,27 @@
import { CoMap, CoID, BinaryCoStream, CoStream } from "cojson";
/** Walkthrough: Defining the data model with CoJSON
*
* Here, we define our main data model of TODO
*
* TODO
**/
export type PetPost = CoMap<{
name: string;
image: CoID<BinaryCoStream>;
reactions: CoID<PetReactions>;
}>;
export type ReactionType =
| "aww"
| "love"
| "haha"
| "wow"
| "tiny"
| "chonkers"
| "good";
export type PetReactions = CoStream<ReactionType>;
/** Walkthrough: Continue with ./2_App.tsx */

View File

@@ -0,0 +1,50 @@
import { useCallback } from "react";
import { useJazz } from "jazz-react";
import { PetPost } from "./1_types";
import { Button } from "./basicComponents";
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
import { PetPostUI } from "./4_PetPostUI";
import { CreatePetPostForm } from "./4_CreatePetPostForm";
/** Walkthrough: Creating pet posts & routing in `<App/>`
*
* <App> is the main app component, handling client-side routing based
* on the CoValue ID (CoID) of our PetPost, stored in the URL hash
* - which can also contain invite links.
*/
export default function App() {
// A `LocalNode` represents a local view of loaded & created CoValues.
// It is associated with a current user account, which will determine
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
const { localNode, logOut } = useJazz();
// This sets up routing and accepting invites, skip for now
const [currentPetPostID, navigateToPetPostID] =
useSimpleHashRouterThatAcceptsInvites<PetPost>(localNode);
return (
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
{currentPetPostID ? (
<PetPostUI petPostID={currentPetPostID} />
) : (
<CreatePetPostForm onCreate={navigateToPetPostID} />
)}
<Button
onClick={() => {
navigateToPetPostID(undefined);
logOut();
}}
variant="outline"
>
Log Out
</Button>
</div>
);
}
/** Walkthrough: continue with ./3_TodoTable.tsx */

View File

@@ -0,0 +1,103 @@
import { useCallback, useState } from "react";
import { BinaryCoStream, CoID } from "cojson";
import {
useBinaryStream,
useJazz,
useTelepathicState,
} from "jazz-react";
import { PetPost, PetReactions, ReactionType } from "./1_types";
import {
Input,
Button,
} from "./basicComponents";
import { InviteButton } from "./components/InviteButton";
import { NameBadge } from "./components/NameBadge";
import { useDebouncedCallback } from "use-debounce";
import { createBinaryStreamHandler } from "jazz-react";
/** Walkthrough: TODO
*/
export function CreatePetPostForm({
onCreate,
}: {
onCreate: (id: CoID<PetPost>) => void;
}) {
const { localNode } = useJazz();
const [creatingPostId, setCreatingPostId] = useState<
CoID<PetPost> | undefined
>(undefined);
const creatingPetPost = useTelepathicState(creatingPostId);
const onChangeName = useDebouncedCallback((name: string) => {
let petPost = creatingPetPost;
if (!petPost) {
const petPostGroup = localNode.createGroup();
petPost = petPostGroup.createMap<PetPost>();
const reactions = petPostGroup.createStream<PetReactions>();
petPost = petPost.edit((petPost) => {
petPost.set("reactions", reactions.id);
});
setCreatingPostId(petPost.id);
}
petPost.edit((petPost) => {
petPost.set("name", name);
});
}, 200);
const onImageCreated = useCallback(
(image: BinaryCoStream) => {
if (!creatingPetPost) throw new Error("Never get here");
creatingPetPost.edit((petPost) => {
petPost.set("image", image.id);
});
},
[creatingPetPost]
);
const image = useBinaryStream(creatingPetPost?.get("image"));
return (
<div>
<Input
type="text"
placeholder="Pet Name"
onChange={event => onChangeName(event.target.value)}
value={creatingPetPost?.get("name")}
/>
{image ? (
<img src={image.blobURL} />
) : (
creatingPetPost && (
<Input
type="file"
onChange={createBinaryStreamHandler(
onImageCreated,
creatingPetPost.group
)}
/>
)
)}
{creatingPetPost?.get("name") && creatingPetPost?.get("image") && (
<Button
onClick={() => {
onCreate(creatingPetPost.id);
}}
>
Submit Post
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { useCallback } from "react";
import { CoID } from "cojson";
import { useTelepathicState } from "jazz-react";
import { PetPost } from "./1_types";
import { InviteButton } from "./components/InviteButton";
import { NameBadge } from "./components/NameBadge";
/** Walkthrough: TODO
*/
export function PetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
return (<div>TODO</div>);
}

View File

@@ -0,0 +1,7 @@
export function TitleAndLogo({name}: {name: string}) {
return <>
<div className="flex items-center gap-2 justify-center mt-5">
<img src="jazz-logo.png" className="h-5" /> {name}
</div>
</>
}

View File

@@ -0,0 +1,4 @@
export { Button } from "./ui/button";
export { Input } from "./ui/input";
export { TitleAndLogo } from "./TitleAndLogo";
export { ThemeProvider } from "./themeProvider";

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState } from "react";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: string;
storageKey?: string;
};
type ThemeProviderState = {
theme: string;
setTheme: (theme: string) => void;
};
const initialState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState(
() => localStorage.getItem(storageKey) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: string) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/basicComponents/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/basicComponents/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import { LocalAuthComponent } from "jazz-react-auth-local";
import { Input, Button } from "../basicComponents";
export const PrettyAuthUI: LocalAuthComponent = ({
loading,
logIn,
signUp,
}) => {
const [username, setUsername] = useState<string>("");
return (
<div className="w-full h-full flex items-center justify-center p-5">
{loading ? (
<div>Loading...</div>
) : (
<div className="w-72 flex flex-col gap-4">
<form
className="w-72 flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
signUp(username);
}}
>
<Input
placeholder="Display name"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="webauthn"
className="text-base"
/>
<Button asChild>
<Input
type="submit"
value="Sign Up as new account"
/>
</Button>
</form>
<Button onClick={logIn}>
Log In with existing account
</Button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { useState } from "react";
import { TodoProject } from "../1_types";
import { createInviteLink } from "jazz-react";
import QRCode from "qrcode";
import { useToast, Button } from "../basicComponents";
export function InviteButton({ list }: { list?: TodoProject }) {
const [existingInviteLink, setExistingInviteLink] = useState<string>();
const { toast } = useToast();
return (
list?.group.myRole() === "admin" && (
<Button
size="sm"
className="py-0"
disabled={!list}
variant="outline"
onClick={async () => {
let inviteLink = existingInviteLink;
if (list && !inviteLink) {
inviteLink = createInviteLink(list, "writer");
setExistingInviteLink(inviteLink);
}
if (inviteLink) {
const qr = await QRCode.toDataURL(inviteLink, {
errorCorrectionLevel: "L",
});
navigator.clipboard.writeText(inviteLink).then(() =>
toast({
title: "Copied invite link to clipboard!",
description: (
<img src={qr} className="w-20 h-20" />
),
})
);
}
}}
>
Invite
</Button>
)
);
}

View File

@@ -0,0 +1,46 @@
import { AccountID } from "cojson";
import { useProfile } from "jazz-react";
import { Skeleton } from "@/basicComponents";
import uniqolor from "uniqolor";
/** Walkthrough: Getting user profiles in `<NameBadge/>`
*
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
* useTelepathicState on an account's profile.
*
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
* additional properties).
*
* In our case, we just display the profile name (which is set by the LocalAuth
* provider when we first create an account).
*/
export function NameBadge({ accountID }: { accountID?: AccountID }) {
const profile = useProfile(accountID);
return accountID && profile?.get("name") ? (
<span
className="rounded-full py-0.5 px-2 text-xs"
style={randomUserColor(accountID)}
>
{profile.get("name")}
</span>
) : (
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
);
}
function randomUserColor(accountID: AccountID) {
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
return {
color: theme == "light" ? darkColor : brightColor,
background: theme == "light" ? brightColor : darkColor,
};
}

View File

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

View File

@@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from "react";
import { CoID, LocalNode, CoValueImpl } from "cojson";
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
localNode: LocalNode
) {
const [currentValueId, setCurrentValueId] = useState<CoID<C>>();
useEffect(() => {
const listener = async () => {
const acceptedInvitation = await consumeInviteLinkFromWindowLocation<C>(localNode);
if (acceptedInvitation) {
setCurrentValueId(acceptedInvitation.valueID);
window.location.hash = acceptedInvitation.valueID;
return;
}
setCurrentValueId(
(window.location.hash.slice(1) as CoID<C>) || undefined
);
};
window.addEventListener("hashchange", listener);
listener();
return () => {
window.removeEventListener("hashchange", listener);
};
}, [localNode]);
const navigateToValue = useCallback((id: CoID<C> | undefined) => {
window.location.hash = id || "";
}, []);
return [currentValueId, navigateToValue] as const;
}

1
examples/pets/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
minify: false
}
})

View File

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

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { CoID, LocalNode, ContentType } from "cojson"; import { CoID, LocalNode, CoValueImpl } from "cojson";
import { consumeInviteLinkFromWindowLocation } from "jazz-react"; import { consumeInviteLinkFromWindowLocation } from "jazz-react";
export function useSimpleHashRouterThatAcceptsInvites<C extends ContentType>( export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
localNode: LocalNode localNode: LocalNode
) { ) {
const [currentValueId, setCurrentValueId] = useState<CoID<C>>(); const [currentValueId, setCurrentValueId] = useState<CoID<C>>();

View File

@@ -180,16 +180,24 @@ async function main() {
group.children group.children
?.map((memberId) => { ?.map((memberId) => {
const member = child.children!.find( const member = child.children!.find(
(child) => child.id === memberId (member) => member.id === memberId
)!; )!;
if (member.kind === 2048 || member.kind === 512) { if (member.kind === 2048 || member.kind === 512) {
return documentConstructorOrMethod(member, child); if (member.signatures?.every(sig => sig.comment?.modifierTags?.includes("@internal"))) {
return ""
} else {
return documentConstructorOrMethod(member, child);
}
} else if ( } else if (
member.kind === 1024 || member.kind === 1024 ||
member.kind === 262144 member.kind === 262144
) { ) {
return documentProperty(member, child); if (member.comment?.modifierTags?.includes("@internal")) {
return ""
} else {
return documentProperty(member, child);
}
} else { } else {
return "Unknown member kind " + member.kind; return "Unknown member kind " + member.kind;
} }

View File

@@ -4,7 +4,7 @@
"types": "src/index.ts", "types": "src/index.ts",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "0.1.10", "version": "0.1.13",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.3", "@types/jest": "^29.5.3",
"@types/ws": "^8.5.5", "@types/ws": "^8.5.5",
@@ -16,8 +16,8 @@
"typescript": "5.0.2" "typescript": "5.0.2"
}, },
"dependencies": { "dependencies": {
"cojson": "^0.1.9", "cojson": "^0.1.12",
"cojson-storage-sqlite": "^0.1.7", "cojson-storage-sqlite": "^0.1.10",
"ws": "^8.13.0" "ws": "^8.13.0"
}, },
"scripts": { "scripts": {
@@ -31,5 +31,6 @@
"jest": { "jest": {
"preset": "ts-jest", "preset": "ts-jest",
"testEnvironment": "node" "testEnvironment": "node"
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -1,13 +1,13 @@
{ {
"name": "cojson-storage-sqlite", "name": "cojson-storage-sqlite",
"type": "module", "type": "module",
"version": "0.1.7", "version": "0.1.10",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^8.5.2", "better-sqlite3": "^8.5.2",
"cojson": "^0.1.9", "cojson": "^0.1.12",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"scripts": { "scripts": {
@@ -17,5 +17,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.4" "@types/better-sqlite3": "^7.6.4"
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -5,7 +5,7 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "0.1.9", "version": "0.1.12",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.3", "@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/eslint-plugin": "^6.2.1",
@@ -19,9 +19,8 @@
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.1.3", "@noble/ciphers": "^0.1.3",
"@noble/curves": "^1.1.0", "@noble/curves": "^1.1.0",
"@noble/hashes": "^1.3.1",
"@scure/base": "^1.1.1", "@scure/base": "^1.1.1",
"fast-json-stable-stringify": "https://github.com/tirithen/fast-json-stable-stringify#7a3dcf2", "hash-wasm": "^4.9.0",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae" "isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
}, },
"scripts": { "scripts": {
@@ -51,5 +50,6 @@
"/node_modules/", "/node_modules/",
"/dist/" "/dist/"
] ]
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -1,7 +1,12 @@
import { newRandomSessionID } from "./coValueCore.js"; import { newRandomSessionID } from "./coValueCore.js";
import { cojsonReady } from "./index.js";
import { LocalNode } from "./node.js"; import { LocalNode } from "./node.js";
import { connectedPeers } from "./streamUtils.js"; import { connectedPeers } from "./streamUtils.js";
beforeEach(async () => {
await cojsonReady;
});
test("Can create a node while creating a new account with profile", async () => { test("Can create a node while creating a new account with profile", async () => {
const { node, accountID, accountSecret, sessionID } = const { node, accountID, accountSecret, sessionID } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington"); LocalNode.withNewlyCreatedAccount("Hermes Puggington");

View File

@@ -13,7 +13,8 @@ import {
getAgentSignerSecret, getAgentSignerSecret,
} from "./crypto.js"; } from "./crypto.js";
import { AgentID } from "./ids.js"; import { AgentID } from "./ids.js";
import { CoMap, LocalNode } from "./index.js"; import { CoMap } from "./coValues/coMap.js";
import { LocalNode } from "./node.js";
import { Group, GroupContent } from "./group.js"; import { Group, GroupContent } from "./group.js";
export function accountHeaderForInitialAgentSecret( export function accountHeaderForInitialAgentSecret(

View File

@@ -0,0 +1,32 @@
import { base64URLtoBytes, bytesToBase64url } from "./base64url";
const txt = new TextEncoder();
test("Test our Base64 URL encoding and decoding", () => {
// tests from the RFC
expect(base64URLtoBytes("")).toEqual(new Uint8Array([]));
expect(bytesToBase64url(new Uint8Array([]))).toEqual("");
expect(bytesToBase64url(txt.encode("f"))).toEqual("Zg==");
expect(bytesToBase64url(txt.encode("fo"))).toEqual("Zm8=");
expect(bytesToBase64url(txt.encode("foo"))).toEqual("Zm9v");
expect(bytesToBase64url(txt.encode("foob"))).toEqual("Zm9vYg==");
expect(bytesToBase64url(txt.encode("fooba"))).toEqual("Zm9vYmE=");
expect(bytesToBase64url(txt.encode("foobar"))).toEqual("Zm9vYmFy");
// reverse
expect(base64URLtoBytes("Zg==")).toEqual(txt.encode("f"));
expect(base64URLtoBytes("Zm8=")).toEqual(txt.encode("fo"));
expect(base64URLtoBytes("Zm9v")).toEqual(txt.encode("foo"));
expect(base64URLtoBytes("Zm9vYg==")).toEqual(txt.encode("foob"));
expect(base64URLtoBytes("Zm9vYmE=")).toEqual(txt.encode("fooba"));
expect(base64URLtoBytes("Zm9vYmFy")).toEqual(txt.encode("foobar"));
expect(base64URLtoBytes("V2hhdCBkb2VzIDIgKyAyLjEgZXF1YWw_PyB-IDQ=")).toEqual(
txt.encode("What does 2 + 2.1 equal?? ~ 4")
);
// reverse
expect(
bytesToBase64url(txt.encode("What does 2 + 2.1 equal?? ~ 4"))
).toEqual("V2hhdCBkb2VzIDIgKyAyLjEgZXF1YWw_PyB-IDQ=");
});

View File

@@ -0,0 +1,68 @@
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export function base64URLtoBytes(base64: string) {
base64 = base64.replace(/=/g, "");
const n = base64.length;
const rem = n % 4;
const k = rem && rem - 1; // how many bytes the last base64 chunk encodes
const m = (n >> 2) * 3 + k; // total encoded bytes
const encoded = new Uint8Array(n + 3);
encoder.encodeInto(base64 + "===", encoded);
for (let i = 0, j = 0; i < n; i += 4, j += 3) {
const x =
(lookup[encoded[i]!]! << 18) +
(lookup[encoded[i + 1]!]! << 12) +
(lookup[encoded[i + 2]!]! << 6) +
lookup[encoded[i + 3]!]!;
encoded[j] = x >> 16;
encoded[j + 1] = (x >> 8) & 0xff;
encoded[j + 2] = x & 0xff;
}
return new Uint8Array(encoded.buffer, 0, m);
}
export function bytesToBase64url(bytes: Uint8Array) {
// const before = performance.now();
const m = bytes.length;
const k = m % 3;
const n = Math.floor(m / 3) * 4 + (k && k + 1);
const N = Math.ceil(m / 3) * 4;
const encoded = new Uint8Array(N);
for (let i = 0, j = 0; j < m; i += 4, j += 3) {
const y =
(bytes[j]! << 16) + (bytes[j + 1]! << 8) + (bytes[j + 2]! | 0);
encoded[i] = encodeLookup[y >> 18]!;
encoded[i + 1] = encodeLookup[(y >> 12) & 0x3f]!;
encoded[i + 2] = encodeLookup[(y >> 6) & 0x3f]!;
encoded[i + 3] = encodeLookup[y & 0x3f]!;
}
let base64 = decoder.decode(new Uint8Array(encoded.buffer, 0, n));
if (k === 1) base64 += "==";
if (k === 2) base64 += "=";
// const after = performance.now();
// console.log(
// "bytesToBase64url bandwidth in MB/s for length",
// (1000 * bytes.length / (after - before)) / (1024 * 1024),
// bytes.length
// );
return base64;
}
const alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const lookup = new Uint8Array(128);
for (const [i, a] of Array.from(alphabet).entries()) {
lookup[a.charCodeAt(0)] = i;
}
lookup["=".charCodeAt(0)] = 0;
const encodeLookup = new Uint8Array(64);
for (const [i, a] of Array.from(alphabet).entries()) {
encodeLookup[i] = a.charCodeAt(0);
}

View File

@@ -1,8 +1,14 @@
import { accountOrAgentIDfromSessionID } from "./coValueCore.js"; import { accountOrAgentIDfromSessionID } from "./coValueCore.js";
import { BinaryCoStream } from "./coValues/coStream.js";
import { createdNowUnique } from "./crypto.js"; import { createdNowUnique } from "./crypto.js";
import { cojsonReady } from "./index.js";
import { LocalNode } from "./node.js"; import { LocalNode } from "./node.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js"; import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
beforeEach(async () => {
await cojsonReady;
});
test("Empty CoMap works", () => { test("Empty CoMap works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID()); const node = new LocalNode(...randomAnonymousAccountAndSessionID());
@@ -281,4 +287,129 @@ test("Can push into empty list", () => {
editable.push("hello", "trusting"); editable.push("hello", "trusting");
expect(editable.toJSON()).toEqual(["hello"]); expect(editable.toJSON()).toEqual(["hello"]);
}); });
}) });
test("Empty CoStream works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "costream") {
throw new Error("Expected stream");
}
expect(content.type).toEqual("costream");
expect(content.toJSON()).toEqual({});
expect(content.getSingleStream()).toEqual(undefined);
});
test("Can push into CoStream", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "costream") {
throw new Error("Expected stream");
}
content.edit((editable) => {
editable.push({ hello: "world" }, "trusting");
expect(editable.toJSON()).toEqual({
[node.currentSessionID]: [{ hello: "world" }],
});
editable.push({ foo: "bar" }, "trusting");
expect(editable.toJSON()).toEqual({
[node.currentSessionID]: [{ hello: "world" }, { foo: "bar" }],
});
expect(editable.getSingleStream()).toEqual([
{ hello: "world" },
{ foo: "bar" },
]);
});
});
test("Empty BinaryCoStream works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: { type: "binary" },
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "costream" || content.meta?.type !== "binary" || !(content instanceof BinaryCoStream)) {
throw new Error("Expected binary stream");
}
expect(content.type).toEqual("costream");
expect(content.meta.type).toEqual("binary");
expect(content.toJSON()).toEqual({});
expect(content.getBinaryChunks()).toEqual(undefined);
});
test("Can push into BinaryCoStream", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: { type: "binary" },
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "costream" || content.meta?.type !== "binary" || !(content instanceof BinaryCoStream)) {
throw new Error("Expected binary stream");
}
content.edit((editable) => {
editable.startBinaryStream({mimeType: "text/plain", fileName: "test.txt"}, "trusting");
expect(editable.getBinaryChunks()).toEqual({
mimeType: "text/plain",
fileName: "test.txt",
chunks: [],
finished: false,
});
editable.pushBinaryStreamChunk(new Uint8Array([1, 2, 3]), "trusting");
expect(editable.getBinaryChunks()).toEqual({
mimeType: "text/plain",
fileName: "test.txt",
chunks: [new Uint8Array([1, 2, 3])],
finished: false,
});
editable.pushBinaryStreamChunk(new Uint8Array([4, 5, 6]), "trusting");
expect(editable.getBinaryChunks()).toEqual({
mimeType: "text/plain",
fileName: "test.txt",
chunks: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])],
finished: false,
});
editable.endBinaryStream("trusting");
expect(editable.getBinaryChunks()).toEqual({
mimeType: "text/plain",
fileName: "test.txt",
chunks: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])],
finished: true,
});
});
});

View File

@@ -1,10 +1,11 @@
import { JsonObject, JsonValue } from "./jsonValue.js"; import { JsonObject, JsonValue } from "./jsonValue.js";
import { RawCoID } from "./ids.js"; import { RawCoID } from "./ids.js";
import { CoMap } from "./coValues/coMap.js"; import { CoMap } from "./coValues/coMap.js";
import { CoStream } from "./coValues/coStream.js"; import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
import { Static } from "./coValues/static.js"; import { Static } from "./coValues/static.js";
import { CoList } from "./coValues/coList.js"; import { CoList } from "./coValues/coList.js";
import { CoValueCore, Group } from "./index.js"; import { CoValueCore } from "./coValueCore.js";
import { Group } from "./group.js";
export type CoID<T extends CoValueImpl> = RawCoID & { export type CoID<T extends CoValueImpl> = RawCoID & {
readonly __type: T; readonly __type: T;
@@ -49,6 +50,7 @@ export type CoValueImpl =
| CoMap<{ [key: string]: JsonValue }, JsonObject | null> | CoMap<{ [key: string]: JsonValue }, JsonObject | null>
| CoList<JsonValue, JsonObject | null> | CoList<JsonValue, JsonObject | null>
| CoStream<JsonValue, JsonObject | null> | CoStream<JsonValue, JsonObject | null>
| BinaryCoStream<BinaryCoStreamMeta>
| Static<JsonObject>; | Static<JsonObject>;
export function expectMap( export function expectMap(

View File

@@ -2,9 +2,13 @@ import { Transaction } from "./coValueCore.js";
import { LocalNode } from "./node.js"; import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js"; import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js"; import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
import { CoMap, MapOpPayload } from "./coValues/coMap.js"; import { MapOpPayload } from "./coValues/coMap.js";
import { AccountID } from "./index.js";
import { Role } from "./permissions.js"; import { Role } from "./permissions.js";
import { cojsonReady } from "./index.js";
beforeEach(async () => {
await cojsonReady;
});
test("Can create coValue with new agent credentials and add transaction to it", () => { test("Can create coValue with new agent credentials and add transaction to it", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID(); const [account, sessionID] = randomAnonymousAccountAndSessionID();

View File

@@ -1,7 +1,7 @@
import { randomBytes } from "@noble/hashes/utils"; import { randomBytes } from "@noble/hashes/utils";
import { CoValueImpl } from "./coValue.js"; import { CoValueImpl } from "./coValue.js";
import { Static } from "./coValues/static.js"; import { Static } from "./coValues/static.js";
import { CoStream } from "./coValues/coStream.js"; import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
import { CoMap } from "./coValues/coMap.js"; import { CoMap } from "./coValues/coMap.js";
import { import {
Encrypted, Encrypted,
@@ -100,6 +100,7 @@ export class CoValueCore {
_sessions: { [key: SessionID]: SessionLog }; _sessions: { [key: SessionID]: SessionLog };
_cachedContent?: CoValueImpl; _cachedContent?: CoValueImpl;
listeners: Set<(content?: CoValueImpl) => void> = new Set(); listeners: Set<(content?: CoValueImpl) => void> = new Set();
_decryptionCache: {[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined} = {}
constructor( constructor(
header: CoValueHeader, header: CoValueHeader,
@@ -186,10 +187,16 @@ export class CoValueCore {
return false; return false;
} }
// const beforeHash = performance.now();
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter( const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
sessionID, sessionID,
newTransactions newTransactions
); );
// const afterHash = performance.now();
// console.log(
// "Hashing took",
// afterHash - beforeHash
// );
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) { if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
console.warn("Invalid hash", { console.warn("Invalid hash", {
@@ -199,6 +206,7 @@ export class CoValueCore {
return false; return false;
} }
// const beforeVerify = performance.now();
if (!verify(newSignature, expectedNewHash, signerID)) { if (!verify(newSignature, expectedNewHash, signerID)) {
console.warn( console.warn(
"Invalid signature", "Invalid signature",
@@ -208,6 +216,11 @@ export class CoValueCore {
); );
return false; return false;
} }
// const afterVerify = performance.now();
// console.log(
// "Verify took",
// afterVerify - beforeVerify
// );
const transactions = this.sessions[sessionID]?.transactions ?? []; const transactions = this.sessions[sessionID]?.transactions ?? [];
@@ -222,10 +235,105 @@ export class CoValueCore {
this._cachedContent = undefined; this._cachedContent = undefined;
const content = this.getCurrentContent(); if (this.listeners.size > 0) {
const content = this.getCurrentContent();
for (const listener of this.listeners) {
listener(content);
}
}
for (const listener of this.listeners) { return true;
listener(content); }
async tryAddTransactionsAsync(
sessionID: SessionID,
newTransactions: Transaction[],
givenExpectedNewHash: Hash | undefined,
newSignature: Signature
): Promise<boolean> {
const signerID = getAgentSignerID(
this.node.resolveAccountAgent(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signer of transaction"
)
);
if (!signerID) {
console.warn(
"Unknown agent",
accountOrAgentIDfromSessionID(sessionID)
);
return false;
}
const nTxBefore = this.sessions[sessionID]?.transactions.length ?? 0;
// const beforeHash = performance.now();
const { expectedNewHash, newStreamingHash } = await this.expectedNewHashAfterAsync(
sessionID,
newTransactions
);
// const afterHash = performance.now();
// console.log(
// "Hashing took",
// afterHash - beforeHash
// );
const nTxAfter = this.sessions[sessionID]?.transactions.length ?? 0;
if (nTxAfter !== nTxBefore) {
const newTransactionLengthBefore = newTransactions.length;
newTransactions = newTransactions.slice((nTxAfter - nTxBefore));
console.warn("Transactions changed while async hashing", {
nTxBefore,
nTxAfter,
newTransactionLengthBefore,
remainingNewTransactions: newTransactions.length,
});
}
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
console.warn("Invalid hash", {
expectedNewHash,
givenExpectedNewHash,
});
return false;
}
// const beforeVerify = performance.now();
if (!verify(newSignature, expectedNewHash, signerID)) {
console.warn(
"Invalid signature",
newSignature,
expectedNewHash,
signerID
);
return false;
}
// const afterVerify = performance.now();
// console.log(
// "Verify took",
// afterVerify - beforeVerify
// );
const transactions = this.sessions[sessionID]?.transactions ?? [];
transactions.push(...newTransactions);
this._sessions[sessionID] = {
transactions,
lastHash: expectedNewHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this._cachedContent = undefined;
if (this.listeners.size > 0) {
const content = this.getCurrentContent();
for (const listener of this.listeners) {
listener(content);
}
} }
return true; return true;
@@ -259,6 +367,32 @@ export class CoValueCore {
}; };
} }
async expectedNewHashAfterAsync(
sessionID: SessionID,
newTransactions: Transaction[]
): Promise<{ expectedNewHash: Hash; newStreamingHash: StreamingHash }> {
const streamingHash =
this.sessions[sessionID]?.streamingHash.clone() ??
new StreamingHash();
let before = performance.now();
for (const transaction of newTransactions) {
streamingHash.update(transaction)
const after = performance.now();
if (after - before > 1) {
console.log("Hashing blocked for", after - before);
await new Promise((resolve) => setTimeout(resolve, 0));
before = performance.now();
}
}
const newStreamingHash = streamingHash.clone();
return {
expectedNewHash: streamingHash.digest(),
newStreamingHash,
};
}
makeTransaction( makeTransaction(
changes: JsonValue[], changes: JsonValue[],
privacy: "private" | "trusting" privacy: "private" | "trusting"
@@ -276,14 +410,18 @@ export class CoValueCore {
); );
} }
const encrypted = encryptForTransaction(changes, keySecret, {
in: this.id,
tx: this.nextTransactionID(),
});
this._decryptionCache[encrypted] = changes;
transaction = { transaction = {
privacy: "private", privacy: "private",
madeAt, madeAt,
keyUsed: keyID, keyUsed: keyID,
encryptedChanges: encryptForTransaction(changes, keySecret, { encryptedChanges: encrypted,
in: this.id,
tx: this.nextTransactionID(),
}),
}; };
} else { } else {
transaction = { transaction = {
@@ -328,7 +466,11 @@ export class CoValueCore {
} else if (this.header.type === "colist") { } else if (this.header.type === "colist") {
this._cachedContent = new CoList(this); this._cachedContent = new CoList(this);
} else if (this.header.type === "costream") { } else if (this.header.type === "costream") {
this._cachedContent = new CoStream(this); if (this.header.meta && this.header.meta.type === "binary") {
this._cachedContent = new BinaryCoStream(this);
} else {
this._cachedContent = new CoStream(this);
}
} else if (this.header.type === "static") { } else if (this.header.type === "static") {
this._cachedContent = new Static(this); this._cachedContent = new Static(this);
} else { } else {
@@ -355,14 +497,19 @@ export class CoValueCore {
if (!readKey) { if (!readKey) {
return undefined; return undefined;
} else { } else {
const decrytedChanges = decryptForTransaction( let decrytedChanges = this._decryptionCache[tx.encryptedChanges];
tx.encryptedChanges,
readKey, if (!decrytedChanges) {
{ decrytedChanges = decryptForTransaction(
in: this.id, tx.encryptedChanges,
tx: txID, readKey,
} {
); in: this.id,
tx: txID,
}
);
this._decryptionCache[tx.encryptedChanges] = decrytedChanges;
}
if (!decrytedChanges) { if (!decrytedChanges) {
console.error( console.error(

View File

@@ -2,8 +2,8 @@ import { JsonObject, JsonValue } from "../jsonValue.js";
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js"; import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js"; import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
import { SessionID, TransactionID } from "../ids.js"; import { SessionID, TransactionID } from "../ids.js";
import { AccountID, Group } from "../index.js"; import { Group } from "../group.js";
import { isAccountID } from "../account.js"; import { AccountID, isAccountID } from "../account.js";
type OpID = TransactionID & { changeIdx: number }; type OpID = TransactionID & { changeIdx: number };

View File

@@ -1,16 +1,53 @@
import { JsonObject, JsonValue } from '../jsonValue.js'; import { JsonObject, JsonValue } from "../jsonValue.js";
import { CoID, ReadableCoValue } from '../coValue.js'; import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
import { CoValueCore } from '../coValueCore.js'; import { CoValueCore } from "../coValueCore.js";
import { Group } from '../index.js'; import { Group } from "../group.js";
import { SessionID } from "../ids.js";
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null> implements ReadableCoValue { export type BinaryChunkInfo = {
mimeType: string;
fileName?: string;
totalSizeBytes?: number;
};
export type BinaryStreamStart = {
type: "start";
} & BinaryChunkInfo;
export type BinaryStreamChunk = {
type: "chunk";
chunk: `binary_U${string}`;
};
export type BinaryStreamEnd = {
type: "end";
};
export type BinaryCoStreamMeta = JsonObject & { type: "binary" };
export type BinaryStreamItem =
| BinaryStreamStart
| BinaryStreamChunk
| BinaryStreamEnd;
export class CoStream<
T extends JsonValue,
Meta extends JsonObject | null = null
> implements ReadableCoValue
{
id: CoID<CoStream<T, Meta>>; id: CoID<CoStream<T, Meta>>;
type = "costream" as const; type = "costream" as const;
core: CoValueCore; core: CoValueCore;
items: {
[key: SessionID]: T[];
};
constructor(core: CoValueCore) { constructor(core: CoValueCore) {
this.id = core.id as CoID<CoStream<T, Meta>>; this.id = core.id as CoID<CoStream<T, Meta>>;
this.core = core; this.core = core;
this.items = {};
this.fillFromCoValue();
} }
get meta(): Meta { get meta(): Meta {
@@ -21,8 +58,42 @@ export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null
return this.core.getGroup(); return this.core.getGroup();
} }
toJSON(): JsonObject { /** @internal */
throw new Error("Method not implemented."); protected fillFromCoValue() {
this.items = {};
for (const {
txID,
changes,
} of this.core.getValidSortedTransactions()) {
for (const changeUntyped of changes) {
const change = changeUntyped as T;
let entries = this.items[txID.sessionID];
if (!entries) {
entries = [];
this.items[txID.sessionID] = entries;
}
entries.push(change);
}
}
}
getSingleStream(): T[] | undefined {
if (Object.keys(this.items).length === 0) {
return undefined;
} else if (Object.keys(this.items).length !== 1) {
throw new Error(
"CoStream.getSingleStream() can only be called when there is exactly one stream"
);
}
return Object.values(this.items)[0];
}
toJSON(): {
[key: SessionID]: T[];
} {
return this.items;
} }
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void { subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
@@ -30,4 +101,164 @@ export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null
listener(content as CoStream<T, Meta>); listener(content as CoStream<T, Meta>);
}); });
} }
edit(
changer: (editable: WriteableCoStream<T, Meta>) => void
): CoStream<T, Meta> {
const editable = new WriteableCoStream<T, Meta>(this.core);
changer(editable);
return new CoStream(this.core);
}
}
const binary_U_prefixLength = 8; // "binary_U".length;
export class BinaryCoStream<
Meta extends BinaryCoStreamMeta = { type: "binary" }
>
extends CoStream<BinaryStreamItem, Meta>
implements ReadableCoValue
{
id!: CoID<BinaryCoStream<Meta>>;
getBinaryChunks():
| (BinaryChunkInfo & { chunks: Uint8Array[]; finished: boolean })
| undefined {
const before = performance.now();
const items = this.getSingleStream();
if (!items) return;
const start = items[0];
if (start?.type !== "start") {
console.error("Invalid binary stream start", start);
return;
}
const chunks: Uint8Array[] = [];
let finished = false;
let totalLength = 0;
for (const item of items.slice(1)) {
if (item.type === "end") {
finished = true;
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);
}
const after = performance.now();
console.log(
"getBinaryChunks bandwidth in MB/s",
(1000 * totalLength) / (after - before) / (1024 * 1024)
);
return {
mimeType: start.mimeType,
fileName: start.fileName,
totalSizeBytes: start.totalSizeBytes,
chunks,
finished,
};
}
edit(
changer: (editable: WriteableBinaryCoStream<Meta>) => void
): BinaryCoStream<Meta> {
const editable = new WriteableBinaryCoStream<Meta>(this.core);
changer(editable);
return new BinaryCoStream(this.core);
}
}
export class WriteableCoStream<
T extends JsonValue,
Meta extends JsonObject | null = null
>
extends CoStream<T, Meta>
implements WriteableCoValue
{
/** @internal */
edit(
_changer: (editable: WriteableCoStream<T, Meta>) => void
): CoStream<T, Meta> {
throw new Error("Already editing.");
}
push(item: T, privacy: "private" | "trusting" = "private") {
this.core.makeTransaction([item], privacy);
this.fillFromCoValue();
}
}
export class WriteableBinaryCoStream<
Meta extends BinaryCoStreamMeta = { type: "binary" }
>
extends BinaryCoStream<Meta>
implements WriteableCoValue
{
/** @internal */
edit(
_changer: (editable: WriteableBinaryCoStream<Meta>) => void
): BinaryCoStream<Meta> {
throw new Error("Already editing.");
}
/** @internal */
push(item: BinaryStreamItem, privacy: "private" | "trusting" = "private") {
WriteableCoStream.prototype.push.call(this, item, privacy);
}
startBinaryStream(
settings: BinaryChunkInfo,
privacy: "private" | "trusting" = "private"
) {
this.push(
{
type: "start",
...settings,
} satisfies BinaryStreamStart,
privacy
);
}
pushBinaryStreamChunk(
chunk: Uint8Array,
privacy: "private" | "trusting" = "private"
) {
const before = performance.now();
this.push(
{
type: "chunk",
chunk: `binary_U${bytesToBase64url(chunk)}`,
} satisfies BinaryStreamChunk,
privacy
);
const after = performance.now();
console.log(
"pushBinaryStreamChunk bandwidth in MB/s",
(1000 * chunk.length) / (after - before) / (1024 * 1024)
);
}
endBinaryStream(privacy: "private" | "trusting" = "private") {
this.push(
{
type: "end",
} satisfies BinaryStreamEnd,
privacy
);
}
} }

View File

@@ -21,6 +21,11 @@ import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
import { blake3 } from "@noble/hashes/blake3"; import { blake3 } from "@noble/hashes/blake3";
import stableStringify from "fast-json-stable-stringify"; import stableStringify from "fast-json-stable-stringify";
import { SessionID } from './ids.js'; import { SessionID } from './ids.js';
import { cojsonReady } from './index.js';
beforeEach(async () => {
await cojsonReady;
});
test("Signatures round-trip and use stable stringify", () => { test("Signatures round-trip and use stable stringify", () => {
const data = { b: "world", a: "hello" }; const data = { b: "world", a: "hello" };

View File

@@ -1,11 +1,39 @@
import { ed25519, x25519 } from "@noble/curves/ed25519"; import { ed25519, x25519 } from "@noble/curves/ed25519";
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa"; import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
import { JsonValue } from "./jsonValue.js"; import { JsonValue } from "./jsonValue.js";
import { base58, base64url } from "@scure/base"; import { base58 } from "@scure/base";
import stableStringify from "fast-json-stable-stringify";
import { blake3 } from "@noble/hashes/blake3";
import { randomBytes } from "@noble/ciphers/webcrypto/utils"; import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { AgentID, RawCoID, TransactionID } from "./ids.js"; import { AgentID, RawCoID, TransactionID } from "./ids.js";
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
import { createBLAKE3 } from 'hash-wasm';
import { stableStringify } from "./fastJsonStableStringify.js";
let blake3Instance: Awaited<ReturnType<typeof createBLAKE3>>;
let blake3HashOnce: (data: Uint8Array) => Uint8Array;
let blake3HashOnceWithContext: (data: Uint8Array, {context}: {context: Uint8Array}) => Uint8Array;
let blake3incrementalUpdateSLOW_WITH_DEVTOOLS: (state: Uint8Array, data: Uint8Array) => Uint8Array;
let blake3digestForState: (state: Uint8Array) => Uint8Array;
export const cryptoReady = new Promise<void>((resolve) => {
createBLAKE3().then(bl3 => {
blake3Instance = bl3;
blake3HashOnce = (data) => {
return bl3.init().update(data).digest('binary');
}
blake3HashOnceWithContext = (data, {context}) => {
return bl3.init().update(context).update(data).digest('binary');
}
blake3incrementalUpdateSLOW_WITH_DEVTOOLS = (state, data) => {
bl3.load(state).update(data);
return bl3.save();
}
blake3digestForState = (state) => {
return bl3.load(state).digest('binary');
}
resolve();
})
});
export type SignerSecret = `signerSecret_z${string}`; export type SignerSecret = `signerSecret_z${string}`;
export type SignerID = `signer_z${string}`; export type SignerID = `signer_z${string}`;
@@ -127,7 +155,7 @@ export function seal<T extends JsonValue>(
to: SealerID, to: SealerID,
nOnceMaterial: { in: RawCoID; tx: TransactionID } nOnceMaterial: { in: RawCoID; tx: TransactionID }
): Sealed<T> { ): Sealed<T> {
const nOnce = blake3( const nOnce = blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)) textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24); ).slice(0, 24);
@@ -143,7 +171,7 @@ export function seal<T extends JsonValue>(
plaintext plaintext
); );
return `sealed_U${base64url.encode(sealedBytes)}` as Sealed<T>; return `sealed_U${bytesToBase64url(sealedBytes)}` as Sealed<T>;
} }
export function unseal<T extends JsonValue>( export function unseal<T extends JsonValue>(
@@ -152,7 +180,7 @@ export function unseal<T extends JsonValue>(
from: SealerID, from: SealerID,
nOnceMaterial: { in: RawCoID; tx: TransactionID } nOnceMaterial: { in: RawCoID; tx: TransactionID }
): T | undefined { ): T | undefined {
const nOnce = blake3( const nOnce = blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)) textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24); ).slice(0, 24);
@@ -160,7 +188,7 @@ export function unseal<T extends JsonValue>(
const senderPub = base58.decode(from.substring("sealer_z".length)); const senderPub = base58.decode(from.substring("sealer_z".length));
const sealedBytes = base64url.decode(sealed.substring("sealed_U".length)); const sealedBytes = base64URLtoBytes(sealed.substring("sealed_U".length));
const sharedSecret = x25519.getSharedSecret(sealerPriv, senderPub); const sharedSecret = x25519.getSharedSecret(sealerPriv, senderPub);
@@ -180,28 +208,32 @@ export type Hash = `hash_z${string}`;
export function secureHash(value: JsonValue): Hash { export function secureHash(value: JsonValue): Hash {
return `hash_z${base58.encode( return `hash_z${base58.encode(
blake3(textEncoder.encode(stableStringify(value))) blake3HashOnce(textEncoder.encode(stableStringify(value)))
)}`; )}`;
} }
export class StreamingHash { export class StreamingHash {
state: ReturnType<typeof blake3.create>; state: Uint8Array;
constructor(fromClone?: ReturnType<typeof blake3.create>) { constructor(fromClone?: Uint8Array) {
this.state = fromClone || blake3.create({}); this.state = fromClone || blake3Instance.init().save();
} }
update(value: JsonValue) { update(value: JsonValue) {
this.state.update(textEncoder.encode(stableStringify(value))); const encoded = textEncoder.encode(stableStringify(value))
// const before = performance.now();
this.state = blake3incrementalUpdateSLOW_WITH_DEVTOOLS(this.state, encoded);
// const after = performance.now();
// console.log(`Hashing throughput in MB/s`, 1000 * (encoded.length / (after - before)) / (1024 * 1024));
} }
digest(): Hash { digest(): Hash {
const hash = this.state.digest(); const hash = blake3digestForState(this.state);
return `hash_z${base58.encode(hash)}`; return `hash_z${base58.encode(hash)}`;
} }
clone(): StreamingHash { clone(): StreamingHash {
return new StreamingHash(this.state.clone()); return new StreamingHash(new Uint8Array(this.state));
} }
} }
@@ -210,7 +242,10 @@ export const shortHashLength = 19;
export function shortHash(value: JsonValue): ShortHash { export function shortHash(value: JsonValue): ShortHash {
return `shortHash_z${base58.encode( return `shortHash_z${base58.encode(
blake3(textEncoder.encode(stableStringify(value))).slice(0, shortHashLength) blake3HashOnce(textEncoder.encode(stableStringify(value))).slice(
0,
shortHashLength
)
)}`; )}`;
} }
@@ -237,13 +272,13 @@ function encrypt<T extends JsonValue, N extends JsonValue>(
const keySecretBytes = base58.decode( const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length) keySecret.substring("keySecret_z".length)
); );
const nOnce = blake3( const nOnce = blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)) textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24); ).slice(0, 24);
const plaintext = textEncoder.encode(stableStringify(value)); const plaintext = textEncoder.encode(stableStringify(value));
const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext); const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
return `encrypted_U${base64url.encode(ciphertext)}` as Encrypted<T, N>; return `encrypted_U${bytesToBase64url(ciphertext)}` as Encrypted<T, N>;
} }
export function encryptForTransaction<T extends JsonValue>( export function encryptForTransaction<T extends JsonValue>(
@@ -289,11 +324,11 @@ function decrypt<T extends JsonValue, N extends JsonValue>(
const keySecretBytes = base58.decode( const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length) keySecret.substring("keySecret_z".length)
); );
const nOnce = blake3( const nOnce = blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)) textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24); ).slice(0, 24);
const ciphertext = base64url.decode( const ciphertext = base64URLtoBytes(
encrypted.substring("encrypted_U".length) encrypted.substring("encrypted_U".length)
); );
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext); const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
@@ -355,15 +390,17 @@ export function newRandomSecretSeed(): Uint8Array {
export function agentSecretFromSecretSeed(secretSeed: Uint8Array): AgentSecret { export function agentSecretFromSecretSeed(secretSeed: Uint8Array): AgentSecret {
if (secretSeed.length !== secretSeedLength) { if (secretSeed.length !== secretSeedLength) {
throw new Error(`Secret seed needs to be ${secretSeedLength} bytes long`); throw new Error(
`Secret seed needs to be ${secretSeedLength} bytes long`
);
} }
return `sealerSecret_z${base58.encode( return `sealerSecret_z${base58.encode(
blake3(secretSeed, { blake3HashOnceWithContext(secretSeed, {
context: textEncoder.encode("seal"), context: textEncoder.encode("seal"),
}) })
)}/signerSecret_z${base58.encode( )}/signerSecret_z${base58.encode(
blake3(secretSeed, { blake3HashOnceWithContext(secretSeed, {
context: textEncoder.encode("sign"), context: textEncoder.encode("sign"),
}) })
)}`; )}`;

View File

@@ -0,0 +1,54 @@
// adapted from fast-json-stable-stringify (https://github.com/epoberezkin/fast-json-stable-stringify)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function stableStringify(data: any): string | undefined {
const cycles = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const seen: any[] = [];
let node = data;
if (node && node.toJSON && typeof node.toJSON === "function") {
node = node.toJSON();
}
if (node === undefined) return;
if (typeof node == "number") return isFinite(node) ? "" + node : "null";
if (typeof node !== "object") {
if (typeof node === "string" && (node.startsWith("encrypted_U") || node.startsWith("binary_U"))) {
return `"${node}"`;
}
return JSON.stringify(node);
}
let i, out;
if (Array.isArray(node)) {
out = "[";
for (i = 0; i < node.length; i++) {
if (i) out += ",";
out += stableStringify(node[i]) || "null";
}
return out + "]";
}
if (node === null) return "null";
if (seen.indexOf(node) !== -1) {
if (cycles) return JSON.stringify("__cycle__");
throw new TypeError("Converting circular structure to JSON");
}
const seenIndex = seen.push(node) - 1;
const keys = Object.keys(node).sort();
out = "";
for (i = 0; i < keys.length; i++) {
const key = keys[i]!;
const value = stableStringify(node[key]);
if (!value) continue;
if (out) out += ",";
out += JSON.stringify(key) + ":" + value;
}
seen.splice(seenIndex, 1);
return "{" + out + "}";
}

View File

@@ -0,0 +1,51 @@
import { LocalNode, CoMap, CoList, CoStream, BinaryCoStream, cojsonReady } from "./index";
import { randomAnonymousAccountAndSessionID } from "./testUtils";
beforeEach(async () => {
await cojsonReady;
});
test("Can create a CoMap in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const map = group.createMap();
expect(map.core.getCurrentContent().type).toEqual("comap");
expect(map instanceof CoMap).toEqual(true);
});
test("Can create a CoList in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const list = group.createList();
expect(list.core.getCurrentContent().type).toEqual("colist");
expect(list instanceof CoList).toEqual(true);
})
test("Can create a CoStream in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const stream = group.createStream();
expect(stream.core.getCurrentContent().type).toEqual("costream");
expect(stream instanceof CoStream).toEqual(true);
});
test("Can create a BinaryCoStream in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const stream = group.createBinaryStream();
expect(stream.core.getCurrentContent().type).toEqual("costream");
expect(stream.meta.type).toEqual("binary");
expect(stream instanceof BinaryCoStream).toEqual(true);
})

View File

@@ -21,6 +21,7 @@ import { AccountID, GeneralizedControlledAccount, Profile } from "./account.js";
import { Role } from "./permissions.js"; import { Role } from "./permissions.js";
import { base58 } from "@scure/base"; import { base58 } from "@scure/base";
import { CoList } from "./coValues/coList.js"; import { CoList } from "./coValues/coList.js";
import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
export type GroupContent = { export type GroupContent = {
profile: CoID<Profile> | null; profile: CoID<Profile> | null;
@@ -271,6 +272,38 @@ export class Group {
.getCurrentContent() as L; .getCurrentContent() as L;
} }
createStream<C extends CoStream<JsonValue, JsonObject | null>>(
meta?: C["meta"]
): C {
return this.node
.createCoValue({
type: "costream",
ruleset: {
type: "ownedByGroup",
group: this.underlyingMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as C;
}
createBinaryStream<
C extends BinaryCoStream<BinaryCoStreamMeta>
>(meta: C["meta"] = { type: "binary" }): C {
return this.node
.createCoValue({
type: "costream",
ruleset: {
type: "ownedByGroup",
group: this.underlyingMap.id,
},
meta: meta,
...createdNowUnique(),
})
.getCurrentContent() as C;
}
/** @internal */ /** @internal */
testWithDifferentAccount( testWithDifferentAccount(
account: GeneralizedControlledAccount, account: GeneralizedControlledAccount,

View File

@@ -3,6 +3,12 @@ import { LocalNode } from "./node.js";
import type { CoValue, ReadableCoValue } from "./coValue.js"; import type { CoValue, ReadableCoValue } from "./coValue.js";
import { CoMap, WriteableCoMap } from "./coValues/coMap.js"; import { CoMap, WriteableCoMap } from "./coValues/coMap.js";
import { CoList, WriteableCoList } from "./coValues/coList.js"; import { CoList, WriteableCoList } from "./coValues/coList.js";
import {
CoStream,
WriteableCoStream,
BinaryCoStream,
WriteableBinaryCoStream,
} from "./coValues/coStream.js";
import { import {
agentSecretFromBytes, agentSecretFromBytes,
agentSecretToBytes, agentSecretToBytes,
@@ -12,14 +18,17 @@ import {
agentSecretFromSecretSeed, agentSecretFromSecretSeed,
secretSeedLength, secretSeedLength,
shortHashLength, shortHashLength,
cryptoReady
} from "./crypto.js"; } from "./crypto.js";
import { connectedPeers } from "./streamUtils.js"; import { connectedPeers } from "./streamUtils.js";
import { AnonymousControlledAccount, ControlledAccount } from "./account.js"; import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js"; import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
import { Group, expectGroupContent } from "./group.js"; import { Group, expectGroupContent } from "./group.js";
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
import type { SessionID, AgentID } from "./ids.js"; import type { SessionID, AgentID } from "./ids.js";
import type { CoID, CoValueImpl } from "./coValue.js"; import type { CoID, CoValueImpl } from "./coValue.js";
import type { BinaryChunkInfo, BinaryCoStreamMeta } from "./coValues/coStream.js";
import type { JsonValue } from "./jsonValue.js"; import type { JsonValue } from "./jsonValue.js";
import type { SyncMessage, Peer } from "./sync.js"; import type { SyncMessage, Peer } from "./sync.js";
import type { AgentSecret } from "./crypto.js"; import type { AgentSecret } from "./crypto.js";
@@ -43,6 +52,8 @@ export const cojsonInternals = {
secretSeedLength, secretSeedLength,
shortHashLength, shortHashLength,
expectGroupContent, expectGroupContent,
base64URLtoBytes,
bytesToBase64url
}; };
export { export {
@@ -52,9 +63,14 @@ export {
WriteableCoMap, WriteableCoMap,
CoList, CoList,
WriteableCoList, WriteableCoList,
CoStream,
WriteableCoStream,
BinaryCoStream,
WriteableBinaryCoStream,
CoValueCore, CoValueCore,
AnonymousControlledAccount, AnonymousControlledAccount,
ControlledAccount, ControlledAccount,
cryptoReady as cojsonReady,
}; };
export type { export type {
@@ -68,6 +84,8 @@ export type {
Profile, Profile,
SessionID, SessionID,
Peer, Peer,
BinaryChunkInfo,
BinaryCoStreamMeta,
AgentID, AgentID,
AgentSecret, AgentSecret,
InviteSecret, InviteSecret,

View File

@@ -32,7 +32,7 @@ import {
AccountContent, AccountContent,
AccountMap, AccountMap,
} from "./account.js"; } from "./account.js";
import { CoMap } from "./index.js"; import { CoMap } from "./coValues/coMap.js";
/** A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent). /** A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).

View File

@@ -17,7 +17,11 @@ import {
groupWithTwoAdmins, groupWithTwoAdmins,
groupWithTwoAdminsHighLevel, groupWithTwoAdminsHighLevel,
} from "./testUtils.js"; } from "./testUtils.js";
import { AnonymousControlledAccount } from "./index.js"; import { AnonymousControlledAccount, cojsonReady } from "./index.js";
beforeEach(async () => {
await cojsonReady;
});
test("Initial admin can add another admin to a group", () => { test("Initial admin can add another admin to a group", () => {
groupWithTwoAdmins(); groupWithTwoAdmins();

View File

@@ -1,14 +1,9 @@
import { newRandomSessionID } from "./coValueCore.js"; import { newRandomSessionID } from "./coValueCore.js";
import { LocalNode } from "./node.js"; import { LocalNode } from "./node.js";
import { Peer, PeerID, SyncMessage } from "./sync.js"; import { SyncMessage } from "./sync.js";
import { expectMap } from "./coValue.js"; import { expectMap } from "./coValue.js";
import { MapOpPayload } from "./coValues/coMap.js"; import { MapOpPayload } from "./coValues/coMap.js";
import { Group } from "./group.js"; import { Group } from "./group.js";
import {
ReadableStream,
WritableStream,
TransformStream,
} from "isomorphic-streams";
import { import {
randomAnonymousAccountAndSessionID, randomAnonymousAccountAndSessionID,
shouldNotResolve, shouldNotResolve,
@@ -18,6 +13,11 @@ import {
newStreamPair newStreamPair
} from "./streamUtils.js"; } from "./streamUtils.js";
import { AccountID } from "./account.js"; import { AccountID } from "./account.js";
import { cojsonReady } from "./index.js";
beforeEach(async () => {
await cojsonReady;
});
test("Node replies with initial tx and header to empty subscribe", async () => { test("Node replies with initial tx and header to empty subscribe", async () => {
const [admin, session] = randomAnonymousAccountAndSessionID(); const [admin, session] = randomAnonymousAccountAndSessionID();

View File

@@ -9,6 +9,7 @@ import {
WritableStreamDefaultWriter, WritableStreamDefaultWriter,
} from "isomorphic-streams"; } from "isomorphic-streams";
import { RawCoID, SessionID } from "./ids.js"; import { RawCoID, SessionID } from "./ids.js";
import { stableStringify } from "./fastJsonStableStringify.js";
export type CoValueKnownState = { export type CoValueKnownState = {
id: RawCoID; id: RawCoID;
@@ -445,12 +446,26 @@ export class SyncManager {
const newTransactions = const newTransactions =
newContentForSession.newTransactions.slice(alreadyKnownOffset); newContentForSession.newTransactions.slice(alreadyKnownOffset);
const success = coValue.tryAddTransactions( const before = performance.now();
const success = await coValue.tryAddTransactionsAsync(
sessionID, sessionID,
newTransactions, newTransactions,
undefined, undefined,
newContentForSession.lastSignature newContentForSession.lastSignature
); );
const after = performance.now();
if (after - before > 10) {
const totalTxLength = newTransactions.map(t => stableStringify(t)!.length).reduce((a, b) => a + b, 0);
console.log(
"Adding incoming transactions took",
after - before,
"ms",
totalTxLength,
"bytes = ",
"bandwidth: MB/s",
(1000 * totalTxLength / (after - before)) / (1024 * 1024)
);
}
if (!success) { if (!success) {
console.error("Failed to add transactions", newTransactions); console.error("Failed to add transactions", newTransactions);

View File

@@ -9,8 +9,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"esModuleInterop": true, "esModuleInterop": true
"stripInternal": true
}, },
"include": ["./src/**/*"], "include": ["./src/**/*"],
"exclude": ["./src/**/*.test.*"], "exclude": ["./src/**/*.test.*"],

View File

@@ -1,16 +1,17 @@
{ {
"name": "jazz-browser-auth-local", "name": "jazz-browser-auth-local",
"version": "0.1.9", "version": "0.1.12",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jazz-browser": "^0.1.9", "jazz-browser": "^0.1.12",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"scripts": { "scripts": {
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist", "build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -1,17 +1,18 @@
{ {
"name": "jazz-browser", "name": "jazz-browser",
"version": "0.1.9", "version": "0.1.12",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cojson": "^0.1.9", "cojson": "^0.1.12",
"jazz-storage-indexeddb": "^0.1.9", "jazz-storage-indexeddb": "^0.1.12",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"scripts": { "scripts": {
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist", "build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -1,4 +1,6 @@
import { InviteSecret } from "cojson"; import { BinaryCoStream, InviteSecret } from "cojson";
import { BinaryCoStreamMeta } from "cojson";
import { cojsonReady } from "cojson";
import { import {
LocalNode, LocalNode,
cojsonInternals, cojsonInternals,
@@ -7,7 +9,7 @@ import {
SessionID, SessionID,
SyncMessage, SyncMessage,
Peer, Peer,
ContentType, CoValueImpl,
Group, Group,
CoID, CoID,
} from "cojson"; } from "cojson";
@@ -29,6 +31,7 @@ export async function createBrowserNode({
syncAddress?: string; syncAddress?: string;
reconnectionTimeout?: number; reconnectionTimeout?: number;
}): Promise<BrowserNodeHandle> { }): Promise<BrowserNodeHandle> {
await cojsonReady;
let sessionDone: () => void; let sessionDone: () => void;
const firstWsPeer = createWebSocketPeer(syncAddress); const firstWsPeer = createWebSocketPeer(syncAddress);
@@ -90,9 +93,7 @@ export type SessionHandle = {
done: () => void; done: () => void;
}; };
function getSessionHandleFor( function getSessionHandleFor(accountID: AccountID | AgentID): SessionHandle {
accountID: AccountID | AgentID
): SessionHandle {
let done!: () => void; let done!: () => void;
const donePromise = new Promise<void>((resolve) => { const donePromise = new Promise<void>((resolve) => {
done = resolve; done = resolve;
@@ -175,15 +176,25 @@ function websocketReadableStream<T>(ws: WebSocket) {
pingTimeout = setTimeout(() => { pingTimeout = setTimeout(() => {
console.debug("Ping timeout"); console.debug("Ping timeout");
controller.close(); try {
ws.close(); controller.close();
ws.close();
} catch (e) {
console.error(
"Error while trying to close ws on ping timeout",
e
);
}
}, 2500); }, 2500);
return; return;
} }
controller.enqueue(msg); controller.enqueue(msg);
}; };
const closeListener = () => controller.close(); const closeListener = () => {
controller.close();
clearTimeout(pingTimeout);
};
ws.addEventListener("close", closeListener); ws.addEventListener("close", closeListener);
ws.addEventListener("error", () => { ws.addEventListener("error", () => {
controller.error(new Error("The WebSocket errored!")); controller.error(new Error("The WebSocket errored!"));
@@ -275,19 +286,19 @@ function websocketWritableStream<T>(ws: WebSocket) {
} }
export function createInviteLink( export function createInviteLink(
value: ContentType, value: CoValueImpl,
role: "reader" | "writer" | "admin", role: "reader" | "writer" | "admin",
// default to same address as window.location, but without hash // default to same address as window.location, but without hash
{ {
baseURL = window.location.href.replace(/#.*$/, ""), baseURL = window.location.href.replace(/#.*$/, ""),
}: { baseURL?: string } = {} }: { baseURL?: string } = {}
): string { ): string {
const coValue = value.coValue; const coValueCore = value.core;
const node = coValue.node; const node = coValueCore.node;
let currentCoValue = coValue; let currentCoValue = coValueCore;
while (currentCoValue.header.ruleset.type === "ownedByGroup") { while (currentCoValue.header.ruleset.type === "ownedByGroup") {
currentCoValue = currentCoValue.getGroup().groupMap.coValue; currentCoValue = currentCoValue.getGroup().underlyingMap.core;
} }
if (currentCoValue.header.ruleset.type !== "group") { if (currentCoValue.header.ruleset.type !== "group") {
@@ -304,7 +315,9 @@ export function createInviteLink(
return `${baseURL}#invitedTo=${value.id}&${inviteSecret}`; return `${baseURL}#invitedTo=${value.id}&${inviteSecret}`;
} }
export function parseInviteLink<C extends ContentType>(inviteURL: string): export function parseInviteLink<C extends CoValueImpl>(
inviteURL: string
):
| { | {
valueID: CoID<C>; valueID: CoID<C>;
inviteSecret: InviteSecret; inviteSecret: InviteSecret;
@@ -321,7 +334,9 @@ export function parseInviteLink<C extends ContentType>(inviteURL: string):
return { valueID, inviteSecret }; return { valueID, inviteSecret };
} }
export function consumeInviteLinkFromWindowLocation<C extends ContentType>(node: LocalNode): Promise< export function consumeInviteLinkFromWindowLocation<C extends CoValueImpl>(
node: LocalNode
): Promise<
| { | {
valueID: CoID<C>; valueID: CoID<C>;
inviteSecret: string; inviteSecret: string;
@@ -347,3 +362,72 @@ export function consumeInviteLinkFromWindowLocation<C extends ContentType>(node:
} }
}); });
} }
export async function createBinaryStreamFromBlob<
C extends BinaryCoStream<BinaryCoStreamMeta>
>(
blob: Blob | File,
inGroup: Group,
meta: C["meta"] = { type: "binary" }
): Promise<C> {
let stream = inGroup.createBinaryStream(meta);
const reader = new FileReader();
const done = new Promise<void>((resolve) => {
reader.onload = async () => {
const data = new Uint8Array(reader.result as ArrayBuffer);
stream = stream.edit((stream) => {
stream.startBinaryStream({
mimeType: blob.type,
totalSizeBytes: blob.size,
fileName: blob instanceof File ? blob.name : undefined,
});
}) as C;// TODO: fix this
const chunkSize = 256 * 1024;
for (let idx = 0; idx < data.length; idx += chunkSize) {
stream = stream.edit((stream) => {
stream.pushBinaryStreamChunk(
data.slice(idx, idx + chunkSize)
);
}) as C; // TODO: fix this
await new Promise((resolve) => setTimeout(resolve, 0));
}
stream = stream.edit((stream) => {
stream.endBinaryStream();
}) as C; // TODO: fix this
resolve();
};
});
reader.readAsArrayBuffer(blob);
await done;
return stream;
}
export async function readBlobFromBinaryStream<
C extends BinaryCoStream<BinaryCoStreamMeta>
>(
streamId: CoID<C>,
node: LocalNode,
allowUnfinished?: boolean
): Promise<Blob | undefined> {
const stream = await node.load<C>(streamId);
if (!stream) {
return undefined;
}
const chunks = stream.getBinaryChunks();
if (!chunks) {
return undefined;
}
if (!allowUnfinished && !chunks.finished) {
return undefined;
}
return new Blob(chunks.chunks, { type: chunks.mimeType });
}

View File

@@ -1,12 +1,12 @@
{ {
"name": "jazz-react-auth-local", "name": "jazz-react-auth-local",
"version": "0.1.11", "version": "0.1.14",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jazz-browser-auth-local": "^0.1.9", "jazz-browser-auth-local": "^0.1.12",
"jazz-react": "^0.1.11", "jazz-react": "^0.1.14",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
@@ -19,5 +19,6 @@
"lint": "eslint src/**/*.tsx", "lint": "eslint src/**/*.tsx",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist", "build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -1,12 +1,12 @@
{ {
"name": "jazz-react", "name": "jazz-react",
"version": "0.1.11", "version": "0.1.14",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cojson": "^0.1.9", "cojson": "^0.1.12",
"jazz-browser": "^0.1.9", "jazz-browser": "^0.1.12",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
@@ -19,5 +19,6 @@
"lint": "eslint src/**/*.tsx", "lint": "eslint src/**/*.tsx",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist", "build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -1,15 +1,22 @@
import { import {
LocalNode, LocalNode,
ContentType, CoValueImpl,
CoID, CoID,
ProfileContent,
ProfileMeta,
CoMap, CoMap,
AccountID, AccountID,
JsonValue, JsonValue,
CojsonInternalTypes,
BinaryCoStream,
BinaryCoStreamMeta,
Group,
} from "cojson"; } from "cojson";
import React, { useEffect, useState } from "react"; import React, { ChangeEvent, useEffect, useState } from "react";
import { AuthProvider, createBrowserNode } from "jazz-browser"; import {
AuthProvider,
createBinaryStreamFromBlob,
createBrowserNode,
} from "jazz-browser";
import { readBlobFromBinaryStream } from "jazz-browser";
export { export {
createInviteLink, createInviteLink,
@@ -90,7 +97,7 @@ export function useJazz() {
return context; return context;
} }
export function useTelepathicState<T extends ContentType>(id?: CoID<T>) { export function useTelepathicState<T extends CoValueImpl>(id?: CoID<T>) {
const [state, setState] = useState<T>(); const [state, setState] = useState<T>();
const { localNode } = useJazz(); const { localNode } = useJazz();
@@ -128,9 +135,14 @@ export function useTelepathicState<T extends ContentType>(id?: CoID<T>) {
} }
export function useProfile< export function useProfile<
P extends { [key: string]: JsonValue } & ProfileContent = ProfileContent P extends {
>(accountID?: AccountID): CoMap<P, ProfileMeta> | undefined { [key: string]: JsonValue;
const [profileID, setProfileID] = useState<CoID<CoMap<P, ProfileMeta>>>(); } & CojsonInternalTypes.ProfileContent = CojsonInternalTypes.ProfileContent
>(
accountID?: AccountID
): CoMap<P, CojsonInternalTypes.ProfileMeta> | undefined {
const [profileID, setProfileID] =
useState<CoID<CoMap<P, CojsonInternalTypes.ProfileMeta>>>();
const { localNode } = useJazz(); const { localNode } = useJazz();
@@ -144,3 +156,50 @@ export function useProfile<
return useTelepathicState(profileID); return useTelepathicState(profileID);
} }
export function useBinaryStream<C extends BinaryCoStream<BinaryCoStreamMeta>>(
streamID?: CoID<C>,
allowUnfinished?: boolean
): { blob: Blob; blobURL: string } | undefined {
const { localNode } = useJazz();
const stream = useTelepathicState(streamID);
const [blob, setBlob] = useState<
{ blob: Blob; blobURL: string } | undefined
>();
useEffect(() => {
if (!stream) return;
readBlobFromBinaryStream(stream.id, localNode, allowUnfinished)
.then((blob) =>
setBlob(
blob && {
blob,
blobURL: URL.createObjectURL(blob),
}
)
)
.catch((e) => console.error("Failed to read binary stream", e));
}, [stream, localNode]);
return blob;
}
export function createBinaryStreamHandler<
C extends BinaryCoStream<BinaryCoStreamMeta>
>(
onCreated: (createdStream: C) => void,
inGroup: Group,
meta: C["meta"] = {type: "binary"}
): (event: ChangeEvent) => void {
return (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
createBinaryStreamFromBlob(file, inGroup, meta)
.then(onCreated)
.catch((e) => console.error("Failed to create binary stream", e));
};
}

View File

@@ -1,11 +1,11 @@
{ {
"name": "jazz-storage-indexeddb", "name": "jazz-storage-indexeddb",
"version": "0.1.9", "version": "0.1.12",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cojson": "^0.1.9", "cojson": "^0.1.12",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
@@ -18,5 +18,6 @@
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist", "build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
} },
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
} }

View File

@@ -853,7 +853,7 @@
dependencies: dependencies:
"@noble/hashes" "1.3.1" "@noble/hashes" "1.3.1"
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1": "@noble/hashes@1.3.1":
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
@@ -3745,10 +3745,6 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
"fast-json-stable-stringify@https://github.com/tirithen/fast-json-stable-stringify#7a3dcf2":
version "2.0.0"
resolved "https://github.com/tirithen/fast-json-stable-stringify#7a3dcf2e086222fcee52d354d50a6a80dea97aed"
fast-levenshtein@^2.0.6: fast-levenshtein@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
@@ -4307,6 +4303,11 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
hash-wasm@^4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/hash-wasm/-/hash-wasm-4.9.0.tgz#7e9dcc9f7d6bd0cc802f2a58f24edce999744206"
integrity sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==
hdr-histogram-js@^2.0.1: hdr-histogram-js@^2.0.1:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz#0b860534655722b6e3f3e7dca7b78867cf43dcb5" resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz#0b860534655722b6e3f3e7dca7b78867cf43dcb5"
@@ -8092,6 +8093,11 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
use-debounce@^9.0.4:
version "9.0.4"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.4.tgz#51d25d856fbdfeb537553972ce3943b897f1ac85"
integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"