Compare commits
14 Commits
cojson-sto
...
cojson-sim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
909a101f99 | ||
|
|
df0b6fe138 | ||
|
|
0543756016 | ||
|
|
92eae0e180 | ||
|
|
9ccc97fcd3 | ||
|
|
120ba57274 | ||
|
|
0679a64002 | ||
|
|
e9d561adbd | ||
|
|
bb5fd24f6a | ||
|
|
18d5b9146f | ||
|
|
39850d465f | ||
|
|
27e0d6df46 | ||
|
|
6d0c820724 | ||
|
|
78a1d5a614 |
18
examples/pets/.eslintrc.cjs
Normal file
18
examples/pets/.eslintrc.cjs
Normal 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
24
examples/pets/.gitignore
vendored
Normal 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
4
examples/pets/Dockerfile
Normal 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
65
examples/pets/README.md
Normal 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).
|
||||
16
examples/pets/components.json
Normal file
16
examples/pets/components.json
Normal 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
13
examples/pets/index.html
Normal 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>
|
||||
56
examples/pets/job-template.nomad
Normal file
56
examples/pets/job-template.nomad
Normal 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
|
||||
45
examples/pets/package.json
Normal file
45
examples/pets/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
examples/pets/postcss.config.js
Normal file
6
examples/pets/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
examples/pets/public/jazz-logo.png
Normal file
BIN
examples/pets/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
38
examples/pets/src/0_main.tsx
Normal file
38
examples/pets/src/0_main.tsx
Normal 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 */
|
||||
27
examples/pets/src/1_types.ts
Normal file
27
examples/pets/src/1_types.ts
Normal 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 */
|
||||
50
examples/pets/src/2_App.tsx
Normal file
50
examples/pets/src/2_App.tsx
Normal 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 */
|
||||
103
examples/pets/src/4_CreatePetPostForm.tsx
Normal file
103
examples/pets/src/4_CreatePetPostForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
examples/pets/src/4_PetPostUI.tsx
Normal file
18
examples/pets/src/4_PetPostUI.tsx
Normal 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>);
|
||||
}
|
||||
7
examples/pets/src/basicComponents/TitleAndLogo.tsx
Normal file
7
examples/pets/src/basicComponents/TitleAndLogo.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
4
examples/pets/src/basicComponents/index.ts
Normal file
4
examples/pets/src/basicComponents/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Button } from "./ui/button";
|
||||
export { Input } from "./ui/input";
|
||||
export { TitleAndLogo } from "./TitleAndLogo";
|
||||
export { ThemeProvider } from "./themeProvider";
|
||||
6
examples/pets/src/basicComponents/lib/utils.ts
Normal file
6
examples/pets/src/basicComponents/lib/utils.ts
Normal 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))
|
||||
}
|
||||
72
examples/pets/src/basicComponents/themeProvider.tsx
Normal file
72
examples/pets/src/basicComponents/themeProvider.tsx
Normal 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;
|
||||
};
|
||||
56
examples/pets/src/basicComponents/ui/button.tsx
Normal file
56
examples/pets/src/basicComponents/ui/button.tsx
Normal 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 }
|
||||
25
examples/pets/src/basicComponents/ui/input.tsx
Normal file
25
examples/pets/src/basicComponents/ui/input.tsx
Normal 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 }
|
||||
48
examples/pets/src/components/Auth.tsx
Normal file
48
examples/pets/src/components/Auth.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
46
examples/pets/src/components/InviteButton.tsx
Normal file
46
examples/pets/src/components/InviteButton.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
46
examples/pets/src/components/NameBadge.tsx
Normal file
46
examples/pets/src/components/NameBadge.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
76
examples/pets/src/index.css
Normal file
76
examples/pets/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
37
examples/pets/src/router.ts
Normal file
37
examples/pets/src/router.ts
Normal 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
1
examples/pets/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
76
examples/pets/tailwind.config.js
Normal file
76
examples/pets/tailwind.config.js
Normal 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")],
|
||||
}
|
||||
29
examples/pets/tsconfig.json
Normal file
29
examples/pets/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
examples/pets/tsconfig.node.json
Normal file
10
examples/pets/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
examples/pets/vite.config.ts
Normal file
16
examples/pets/vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.28",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,8 +16,8 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.1.11",
|
||||
"jazz-react-auth-local": "^0.1.11",
|
||||
"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",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, ContentType } from "cojson";
|
||||
import { CoID, LocalNode, CoValueImpl } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends ContentType>(
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
|
||||
localNode: LocalNode
|
||||
) {
|
||||
const [currentValueId, setCurrentValueId] = useState<CoID<C>>();
|
||||
|
||||
@@ -180,16 +180,24 @@ async function main() {
|
||||
group.children
|
||||
?.map((memberId) => {
|
||||
const member = child.children!.find(
|
||||
(child) => child.id === memberId
|
||||
(member) => member.id === memberId
|
||||
)!;
|
||||
|
||||
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 (
|
||||
member.kind === 1024 ||
|
||||
member.kind === 262144
|
||||
) {
|
||||
return documentProperty(member, child);
|
||||
if (member.comment?.modifierTags?.includes("@internal")) {
|
||||
return ""
|
||||
} else {
|
||||
return documentProperty(member, child);
|
||||
}
|
||||
} else {
|
||||
return "Unknown member kind " + member.kind;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
@@ -16,8 +16,8 @@
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.9",
|
||||
"cojson-storage-sqlite": "^0.1.7",
|
||||
"cojson": "^0.1.12",
|
||||
"cojson-storage-sqlite": "^0.1.10",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -31,5 +31,6 @@
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.10",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.1.9",
|
||||
"cojson": "^0.1.12",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -17,5 +17,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.4"
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.12",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
@@ -19,9 +19,8 @@
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.1.3",
|
||||
"@noble/curves": "^1.1.0",
|
||||
"@noble/hashes": "^1.3.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"fast-json-stable-stringify": "https://github.com/tirithen/fast-json-stable-stringify#7a3dcf2",
|
||||
"hash-wasm": "^4.9.0",
|
||||
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -51,5 +50,6 @@
|
||||
"/node_modules/",
|
||||
"/dist/"
|
||||
]
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { cojsonReady } from "./index.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
});
|
||||
|
||||
test("Can create a node while creating a new account with profile", async () => {
|
||||
const { node, accountID, accountSecret, sessionID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
getAgentSignerSecret,
|
||||
} from "./crypto.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";
|
||||
|
||||
export function accountHeaderForInitialAgentSecret(
|
||||
|
||||
32
packages/cojson/src/base64url.test.ts
Normal file
32
packages/cojson/src/base64url.test.ts
Normal 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=");
|
||||
});
|
||||
68
packages/cojson/src/base64url.ts
Normal file
68
packages/cojson/src/base64url.ts
Normal 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);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { accountOrAgentIDfromSessionID } from "./coValueCore.js";
|
||||
import { BinaryCoStream } from "./coValues/coStream.js";
|
||||
import { createdNowUnique } from "./crypto.js";
|
||||
import { cojsonReady } from "./index.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
});
|
||||
|
||||
test("Empty CoMap works", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
@@ -281,4 +287,129 @@ test("Can push into empty list", () => {
|
||||
editable.push("hello", "trusting");
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { RawCoID } from "./ids.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 { 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 & {
|
||||
readonly __type: T;
|
||||
@@ -49,6 +50,7 @@ export type CoValueImpl =
|
||||
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
|
||||
| CoList<JsonValue, JsonObject | null>
|
||||
| CoStream<JsonValue, JsonObject | null>
|
||||
| BinaryCoStream<BinaryCoStreamMeta>
|
||||
| Static<JsonObject>;
|
||||
|
||||
export function expectMap(
|
||||
|
||||
@@ -2,9 +2,13 @@ import { Transaction } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { CoMap, MapOpPayload } from "./coValues/coMap.js";
|
||||
import { AccountID } from "./index.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.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", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { CoValueImpl } from "./coValue.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 {
|
||||
Encrypted,
|
||||
@@ -100,6 +100,7 @@ export class CoValueCore {
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: CoValueImpl;
|
||||
listeners: Set<(content?: CoValueImpl) => void> = new Set();
|
||||
_decryptionCache: {[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined} = {}
|
||||
|
||||
constructor(
|
||||
header: CoValueHeader,
|
||||
@@ -186,10 +187,16 @@ export class CoValueCore {
|
||||
return false;
|
||||
}
|
||||
|
||||
// const beforeHash = performance.now();
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
// const afterHash = performance.now();
|
||||
// console.log(
|
||||
// "Hashing took",
|
||||
// afterHash - beforeHash
|
||||
// );
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", {
|
||||
@@ -199,6 +206,7 @@ export class CoValueCore {
|
||||
return false;
|
||||
}
|
||||
|
||||
// const beforeVerify = performance.now();
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
@@ -208,6 +216,11 @@ export class CoValueCore {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// const afterVerify = performance.now();
|
||||
// console.log(
|
||||
// "Verify took",
|
||||
// afterVerify - beforeVerify
|
||||
// );
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
|
||||
@@ -222,10 +235,105 @@ export class CoValueCore {
|
||||
|
||||
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) {
|
||||
listener(content);
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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(
|
||||
changes: JsonValue[],
|
||||
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 = {
|
||||
privacy: "private",
|
||||
madeAt,
|
||||
keyUsed: keyID,
|
||||
encryptedChanges: encryptForTransaction(changes, keySecret, {
|
||||
in: this.id,
|
||||
tx: this.nextTransactionID(),
|
||||
}),
|
||||
encryptedChanges: encrypted,
|
||||
};
|
||||
} else {
|
||||
transaction = {
|
||||
@@ -328,7 +466,11 @@ export class CoValueCore {
|
||||
} else if (this.header.type === "colist") {
|
||||
this._cachedContent = new CoList(this);
|
||||
} 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") {
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
@@ -355,14 +497,19 @@ export class CoValueCore {
|
||||
if (!readKey) {
|
||||
return undefined;
|
||||
} else {
|
||||
const decrytedChanges = decryptForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
let decrytedChanges = this._decryptionCache[tx.encryptedChanges];
|
||||
|
||||
if (!decrytedChanges) {
|
||||
decrytedChanges = decryptForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
this._decryptionCache[tx.encryptedChanges] = decrytedChanges;
|
||||
}
|
||||
|
||||
if (!decrytedChanges) {
|
||||
console.error(
|
||||
|
||||
@@ -2,8 +2,8 @@ import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { AccountID, Group } from "../index.js";
|
||||
import { isAccountID } from "../account.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID, isAccountID } from "../account.js";
|
||||
|
||||
type OpID = TransactionID & { changeIdx: number };
|
||||
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { CoID, ReadableCoValue } from '../coValue.js';
|
||||
import { CoValueCore } from '../coValueCore.js';
|
||||
import { Group } from '../index.js';
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
|
||||
import { CoValueCore } from "../coValueCore.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>>;
|
||||
type = "costream" as const;
|
||||
core: CoValueCore;
|
||||
items: {
|
||||
[key: SessionID]: T[];
|
||||
};
|
||||
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<CoStream<T, Meta>>;
|
||||
this.core = core;
|
||||
this.items = {};
|
||||
this.fillFromCoValue();
|
||||
}
|
||||
|
||||
get meta(): Meta {
|
||||
@@ -21,8 +58,42 @@ export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
/** @internal */
|
||||
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 {
|
||||
@@ -30,4 +101,164 @@ export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { SessionID } from './ids.js';
|
||||
import { cojsonReady } from './index.js';
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
});
|
||||
|
||||
test("Signatures round-trip and use stable stringify", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import { base58, base64url } from "@scure/base";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { base58 } from "@scure/base";
|
||||
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
|
||||
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 SignerID = `signer_z${string}`;
|
||||
@@ -127,7 +155,7 @@ export function seal<T extends JsonValue>(
|
||||
to: SealerID,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): Sealed<T> {
|
||||
const nOnce = blake3(
|
||||
const nOnce = blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
@@ -143,7 +171,7 @@ export function seal<T extends JsonValue>(
|
||||
plaintext
|
||||
);
|
||||
|
||||
return `sealed_U${base64url.encode(sealedBytes)}` as Sealed<T>;
|
||||
return `sealed_U${bytesToBase64url(sealedBytes)}` as Sealed<T>;
|
||||
}
|
||||
|
||||
export function unseal<T extends JsonValue>(
|
||||
@@ -152,7 +180,7 @@ export function unseal<T extends JsonValue>(
|
||||
from: SealerID,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): T | undefined {
|
||||
const nOnce = blake3(
|
||||
const nOnce = blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
@@ -160,7 +188,7 @@ export function unseal<T extends JsonValue>(
|
||||
|
||||
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);
|
||||
|
||||
@@ -180,28 +208,32 @@ export type Hash = `hash_z${string}`;
|
||||
|
||||
export function secureHash(value: JsonValue): Hash {
|
||||
return `hash_z${base58.encode(
|
||||
blake3(textEncoder.encode(stableStringify(value)))
|
||||
blake3HashOnce(textEncoder.encode(stableStringify(value)))
|
||||
)}`;
|
||||
}
|
||||
|
||||
export class StreamingHash {
|
||||
state: ReturnType<typeof blake3.create>;
|
||||
state: Uint8Array;
|
||||
|
||||
constructor(fromClone?: ReturnType<typeof blake3.create>) {
|
||||
this.state = fromClone || blake3.create({});
|
||||
constructor(fromClone?: Uint8Array) {
|
||||
this.state = fromClone || blake3Instance.init().save();
|
||||
}
|
||||
|
||||
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 {
|
||||
const hash = this.state.digest();
|
||||
const hash = blake3digestForState(this.state);
|
||||
return `hash_z${base58.encode(hash)}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
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(
|
||||
keySecret.substring("keySecret_z".length)
|
||||
);
|
||||
const nOnce = blake3(
|
||||
const nOnce = blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(value));
|
||||
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>(
|
||||
@@ -289,11 +324,11 @@ function decrypt<T extends JsonValue, N extends JsonValue>(
|
||||
const keySecretBytes = base58.decode(
|
||||
keySecret.substring("keySecret_z".length)
|
||||
);
|
||||
const nOnce = blake3(
|
||||
const nOnce = blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
const ciphertext = base64url.decode(
|
||||
const ciphertext = base64URLtoBytes(
|
||||
encrypted.substring("encrypted_U".length)
|
||||
);
|
||||
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
|
||||
@@ -355,15 +390,17 @@ export function newRandomSecretSeed(): Uint8Array {
|
||||
|
||||
export function agentSecretFromSecretSeed(secretSeed: Uint8Array): AgentSecret {
|
||||
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(
|
||||
blake3(secretSeed, {
|
||||
blake3HashOnceWithContext(secretSeed, {
|
||||
context: textEncoder.encode("seal"),
|
||||
})
|
||||
)}/signerSecret_z${base58.encode(
|
||||
blake3(secretSeed, {
|
||||
blake3HashOnceWithContext(secretSeed, {
|
||||
context: textEncoder.encode("sign"),
|
||||
})
|
||||
)}`;
|
||||
|
||||
54
packages/cojson/src/fastJsonStableStringify.ts
Normal file
54
packages/cojson/src/fastJsonStableStringify.ts
Normal 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 + "}";
|
||||
}
|
||||
51
packages/cojson/src/group.test.ts
Normal file
51
packages/cojson/src/group.test.ts
Normal 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);
|
||||
})
|
||||
@@ -21,6 +21,7 @@ import { AccountID, GeneralizedControlledAccount, Profile } from "./account.js";
|
||||
import { Role } from "./permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
|
||||
|
||||
export type GroupContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
@@ -271,6 +272,38 @@ export class Group {
|
||||
.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 */
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
|
||||
@@ -3,6 +3,12 @@ import { LocalNode } from "./node.js";
|
||||
import type { CoValue, ReadableCoValue } from "./coValue.js";
|
||||
import { CoMap, WriteableCoMap } from "./coValues/coMap.js";
|
||||
import { CoList, WriteableCoList } from "./coValues/coList.js";
|
||||
import {
|
||||
CoStream,
|
||||
WriteableCoStream,
|
||||
BinaryCoStream,
|
||||
WriteableBinaryCoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
import {
|
||||
agentSecretFromBytes,
|
||||
agentSecretToBytes,
|
||||
@@ -12,14 +18,17 @@ import {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
cryptoReady
|
||||
} from "./crypto.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
|
||||
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, CoValueImpl } from "./coValue.js";
|
||||
import type { BinaryChunkInfo, BinaryCoStreamMeta } from "./coValues/coStream.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { SyncMessage, Peer } from "./sync.js";
|
||||
import type { AgentSecret } from "./crypto.js";
|
||||
@@ -43,6 +52,8 @@ export const cojsonInternals = {
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectGroupContent,
|
||||
base64URLtoBytes,
|
||||
bytesToBase64url
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -52,9 +63,14 @@ export {
|
||||
WriteableCoMap,
|
||||
CoList,
|
||||
WriteableCoList,
|
||||
CoStream,
|
||||
WriteableCoStream,
|
||||
BinaryCoStream,
|
||||
WriteableBinaryCoStream,
|
||||
CoValueCore,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
cryptoReady as cojsonReady,
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -68,6 +84,8 @@ export type {
|
||||
Profile,
|
||||
SessionID,
|
||||
Peer,
|
||||
BinaryChunkInfo,
|
||||
BinaryCoStreamMeta,
|
||||
AgentID,
|
||||
AgentSecret,
|
||||
InviteSecret,
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
AccountContent,
|
||||
AccountMap,
|
||||
} 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).
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
groupWithTwoAdmins,
|
||||
groupWithTwoAdminsHighLevel,
|
||||
} 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", () => {
|
||||
groupWithTwoAdmins();
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { Peer, PeerID, SyncMessage } from "./sync.js";
|
||||
import { SyncMessage } from "./sync.js";
|
||||
import { expectMap } from "./coValue.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { Group } from "./group.js";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
TransformStream,
|
||||
} from "isomorphic-streams";
|
||||
import {
|
||||
randomAnonymousAccountAndSessionID,
|
||||
shouldNotResolve,
|
||||
@@ -18,6 +13,11 @@ import {
|
||||
newStreamPair
|
||||
} from "./streamUtils.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 () => {
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WritableStreamDefaultWriter,
|
||||
} from "isomorphic-streams";
|
||||
import { RawCoID, SessionID } from "./ids.js";
|
||||
import { stableStringify } from "./fastJsonStableStringify.js";
|
||||
|
||||
export type CoValueKnownState = {
|
||||
id: RawCoID;
|
||||
@@ -445,12 +446,26 @@ export class SyncManager {
|
||||
const newTransactions =
|
||||
newContentForSession.newTransactions.slice(alreadyKnownOffset);
|
||||
|
||||
const success = coValue.tryAddTransactions(
|
||||
const before = performance.now();
|
||||
const success = await coValue.tryAddTransactionsAsync(
|
||||
sessionID,
|
||||
newTransactions,
|
||||
undefined,
|
||||
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) {
|
||||
console.error("Failed to add transactions", newTransactions);
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"stripInternal": true
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./src/**/*.test.*"],
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.12",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.1.9",
|
||||
"jazz-browser": "^0.1.12",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.12",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.9",
|
||||
"jazz-storage-indexeddb": "^0.1.9",
|
||||
"cojson": "^0.1.12",
|
||||
"jazz-storage-indexeddb": "^0.1.12",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { InviteSecret } from "cojson";
|
||||
import { BinaryCoStream, InviteSecret } from "cojson";
|
||||
import { BinaryCoStreamMeta } from "cojson";
|
||||
import { cojsonReady } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
cojsonInternals,
|
||||
@@ -7,7 +9,7 @@ import {
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
ContentType,
|
||||
CoValueImpl,
|
||||
Group,
|
||||
CoID,
|
||||
} from "cojson";
|
||||
@@ -29,6 +31,7 @@ export async function createBrowserNode({
|
||||
syncAddress?: string;
|
||||
reconnectionTimeout?: number;
|
||||
}): Promise<BrowserNodeHandle> {
|
||||
await cojsonReady;
|
||||
let sessionDone: () => void;
|
||||
|
||||
const firstWsPeer = createWebSocketPeer(syncAddress);
|
||||
@@ -90,9 +93,7 @@ export type SessionHandle = {
|
||||
done: () => void;
|
||||
};
|
||||
|
||||
function getSessionHandleFor(
|
||||
accountID: AccountID | AgentID
|
||||
): SessionHandle {
|
||||
function getSessionHandleFor(accountID: AccountID | AgentID): SessionHandle {
|
||||
let done!: () => void;
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
done = resolve;
|
||||
@@ -175,15 +176,25 @@ function websocketReadableStream<T>(ws: WebSocket) {
|
||||
|
||||
pingTimeout = setTimeout(() => {
|
||||
console.debug("Ping timeout");
|
||||
controller.close();
|
||||
ws.close();
|
||||
try {
|
||||
controller.close();
|
||||
ws.close();
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Error while trying to close ws on ping timeout",
|
||||
e
|
||||
);
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
return;
|
||||
}
|
||||
controller.enqueue(msg);
|
||||
};
|
||||
const closeListener = () => controller.close();
|
||||
const closeListener = () => {
|
||||
controller.close();
|
||||
clearTimeout(pingTimeout);
|
||||
};
|
||||
ws.addEventListener("close", closeListener);
|
||||
ws.addEventListener("error", () => {
|
||||
controller.error(new Error("The WebSocket errored!"));
|
||||
@@ -275,19 +286,19 @@ function websocketWritableStream<T>(ws: WebSocket) {
|
||||
}
|
||||
|
||||
export function createInviteLink(
|
||||
value: ContentType,
|
||||
value: CoValueImpl,
|
||||
role: "reader" | "writer" | "admin",
|
||||
// default to same address as window.location, but without hash
|
||||
{
|
||||
baseURL = window.location.href.replace(/#.*$/, ""),
|
||||
}: { baseURL?: string } = {}
|
||||
): string {
|
||||
const coValue = value.coValue;
|
||||
const node = coValue.node;
|
||||
let currentCoValue = coValue;
|
||||
const coValueCore = value.core;
|
||||
const node = coValueCore.node;
|
||||
let currentCoValue = coValueCore;
|
||||
|
||||
while (currentCoValue.header.ruleset.type === "ownedByGroup") {
|
||||
currentCoValue = currentCoValue.getGroup().groupMap.coValue;
|
||||
currentCoValue = currentCoValue.getGroup().underlyingMap.core;
|
||||
}
|
||||
|
||||
if (currentCoValue.header.ruleset.type !== "group") {
|
||||
@@ -304,7 +315,9 @@ export function createInviteLink(
|
||||
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>;
|
||||
inviteSecret: InviteSecret;
|
||||
@@ -321,7 +334,9 @@ export function parseInviteLink<C extends ContentType>(inviteURL: string):
|
||||
return { valueID, inviteSecret };
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation<C extends ContentType>(node: LocalNode): Promise<
|
||||
export function consumeInviteLinkFromWindowLocation<C extends CoValueImpl>(
|
||||
node: LocalNode
|
||||
): Promise<
|
||||
| {
|
||||
valueID: CoID<C>;
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.14",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser-auth-local": "^0.1.9",
|
||||
"jazz-react": "^0.1.11",
|
||||
"jazz-browser-auth-local": "^0.1.12",
|
||||
"jazz-react": "^0.1.14",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -19,5 +19,6 @@
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.14",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.9",
|
||||
"jazz-browser": "^0.1.9",
|
||||
"cojson": "^0.1.12",
|
||||
"jazz-browser": "^0.1.12",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -19,5 +19,6 @@
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import {
|
||||
LocalNode,
|
||||
ContentType,
|
||||
CoValueImpl,
|
||||
CoID,
|
||||
ProfileContent,
|
||||
ProfileMeta,
|
||||
CoMap,
|
||||
AccountID,
|
||||
JsonValue,
|
||||
CojsonInternalTypes,
|
||||
BinaryCoStream,
|
||||
BinaryCoStreamMeta,
|
||||
Group,
|
||||
} from "cojson";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AuthProvider, createBrowserNode } from "jazz-browser";
|
||||
import React, { ChangeEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
AuthProvider,
|
||||
createBinaryStreamFromBlob,
|
||||
createBrowserNode,
|
||||
} from "jazz-browser";
|
||||
import { readBlobFromBinaryStream } from "jazz-browser";
|
||||
|
||||
export {
|
||||
createInviteLink,
|
||||
@@ -90,7 +97,7 @@ export function useJazz() {
|
||||
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 { localNode } = useJazz();
|
||||
@@ -128,9 +135,14 @@ export function useTelepathicState<T extends ContentType>(id?: CoID<T>) {
|
||||
}
|
||||
|
||||
export function useProfile<
|
||||
P extends { [key: string]: JsonValue } & ProfileContent = ProfileContent
|
||||
>(accountID?: AccountID): CoMap<P, ProfileMeta> | undefined {
|
||||
const [profileID, setProfileID] = useState<CoID<CoMap<P, ProfileMeta>>>();
|
||||
P extends {
|
||||
[key: string]: JsonValue;
|
||||
} & CojsonInternalTypes.ProfileContent = CojsonInternalTypes.ProfileContent
|
||||
>(
|
||||
accountID?: AccountID
|
||||
): CoMap<P, CojsonInternalTypes.ProfileMeta> | undefined {
|
||||
const [profileID, setProfileID] =
|
||||
useState<CoID<CoMap<P, CojsonInternalTypes.ProfileMeta>>>();
|
||||
|
||||
const { localNode } = useJazz();
|
||||
|
||||
@@ -144,3 +156,50 @@ export function useProfile<
|
||||
|
||||
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));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-storage-indexeddb",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.12",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.9",
|
||||
"cojson": "^0.1.12",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -18,5 +18,6 @@
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@@ -853,7 +853,7 @@
|
||||
dependencies:
|
||||
"@noble/hashes" "1.3.1"
|
||||
|
||||
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1":
|
||||
"@noble/hashes@1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
|
||||
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
|
||||
@@ -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"
|
||||
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:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||
@@ -4307,6 +4303,11 @@ has@^1.0.3:
|
||||
dependencies:
|
||||
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:
|
||||
version "2.0.3"
|
||||
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:
|
||||
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:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
|
||||
Reference in New Issue
Block a user