Compare commits

...

6 Commits

Author SHA1 Message Date
Guido D'Orsi
11f1a9d5ba docs: add docs about worker storage 2025-01-30 13:05:53 +01:00
Guido D'Orsi
91265d62dd feat: add storage peer option to worker 2025-01-30 13:03:32 +01:00
Trisha Lim
0cf789622c Remove dropdown component 2025-01-30 12:02:45 +08:00
Trisha Lim
d63f5eec5e Move kicker to a separate component 2025-01-30 12:02:45 +08:00
Trisha Lim
42bd8b76a1 Create a more flexible component for headings 2025-01-30 12:02:45 +08:00
Trisha Lim
73742656ae Design system improvements 2025-01-30 12:02:45 +08:00
14 changed files with 223 additions and 119 deletions

View File

@@ -0,0 +1,5 @@
---
"jazz-nodejs": patch
---
Add storage peer option

View File

@@ -1,11 +1,11 @@
import { clsx } from "clsx";
import Link from "next/link";
import { forwardRef } from "react";
import { Icon } from "../atoms/Icon";
import { Icon } from "./Icon";
import { Spinner } from "./Spinner";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "tertiary";
variant?: "primary" | "secondary" | "tertiary" | "destructive" | "plain";
size?: "sm" | "md" | "lg";
href?: string;
newTab?: boolean;
@@ -42,6 +42,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
loading,
loadingText,
icon,
type = "button",
...buttonProps
},
ref,
@@ -58,16 +59,21 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
secondary:
"text-stone-900 border font-medium hover:border-stone-300 hover:dark:border-stone-700 dark:text-white",
tertiary: "text-blue underline underline-offset-4",
destructive:
"bg-red-600 border-red-600 text-white font-medium hover:bg-red-700 hover:border-red-700",
};
const classNames = clsx(
className,
"inline-flex items-center justify-center gap-2 rounded-lg text-center transition-colors",
"disabled:pointer-events-none disabled:opacity-70",
sizeClasses[size],
variantClasses[variant],
disabled && "opacity-50 cursor-not-allowed pointer-events-none",
);
const classNames =
variant === "plain"
? className
: clsx(
className,
"inline-flex items-center justify-center gap-2 rounded-lg text-center transition-colors",
"disabled:pointer-events-none disabled:opacity-70",
sizeClasses[size],
variantClasses[variant],
disabled && "opacity-50 cursor-not-allowed pointer-events-none",
);
if (href) {
return (
@@ -95,6 +101,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{...buttonProps}
disabled={disabled || loading}
className={classNames}
type={type}
>
<ButtonIcon icon={icon} loading={loading} />

View File

@@ -0,0 +1,48 @@
import clsx from "clsx";
type HeadingProps = {
level?: 1 | 2 | 3 | 4 | 5 | 6;
size?: 1 | 2 | 3 | 4 | 5 | 6;
} & React.ComponentPropsWithoutRef<"h1" | "h2" | "h3" | "h4" | "h5" | "h6">;
const classes = {
1: [
"font-display",
"text-stone-950 dark:text-white",
"text-5xl lg:text-6xl",
"mb-3",
"font-medium",
"tracking-tighter",
],
2: [
"font-display",
"text-stone-950 dark:text-white",
"text-2xl md:text-4xl",
"mb-2",
"font-semibold",
"tracking-tight",
],
3: [
"font-display",
"text-stone-950 dark:text-white",
"text-xl md:text-2xl",
"mb-2",
"font-semibold",
"tracking-tight",
],
4: ["text-bold"],
5: [],
6: [],
};
export function Heading({
className,
level = 1,
size: customSize,
...props
}: HeadingProps) {
let Element: `h${typeof level}` = `h${level}`;
const size = customSize || level;
return <Element {...props} className={clsx(classes[size])} />;
}

View File

@@ -1,85 +1,38 @@
import clsx from "clsx";
import { Heading } from "./Heading";
interface HeadingProps {
children: React.ReactNode;
className?: string;
id?: string;
export function H1(
props: React.ComponentPropsWithoutRef<"h1"> & React.PropsWithChildren,
) {
return <Heading level={1} {...props} />;
}
export function H1({ children, className, id }: HeadingProps) {
return (
<h1
id={id}
className={clsx(
className,
"font-display",
"text-stone-950 dark:text-white",
"text-5xl lg:text-6xl",
"mb-3",
"font-medium",
"tracking-tighter",
)}
>
{children}
</h1>
);
export function H2(
props: React.ComponentPropsWithoutRef<"h2"> & React.PropsWithChildren,
) {
return <Heading level={2} {...props} />;
}
export function H2({ children, className, id }: HeadingProps) {
return (
<h2
id={id}
className={clsx(
className,
"font-display",
"text-stone-950 dark:text-white",
"text-2xl md:text-4xl",
"mb-2",
"font-semibold",
"tracking-tight",
)}
>
{children}
</h2>
);
export function H3(
props: React.ComponentPropsWithoutRef<"h3"> & React.PropsWithChildren,
) {
return <Heading level={3} {...props} />;
}
export function H3({ children, className, id }: HeadingProps) {
return (
<h3
id={id}
className={clsx(
className,
"font-display",
"text-stone-950 dark:text-white",
"text-xl md:text-2xl",
"mb-2",
"font-semibold",
"tracking-tight",
)}
>
{children}
</h3>
);
export function H4(
props: React.ComponentPropsWithoutRef<"h4"> & React.PropsWithChildren,
) {
return <Heading level={4} {...props} />;
}
export function H4({ children, className, id }: HeadingProps) {
return (
<h4 id={id} className={clsx(className, "text-bold")}>
{children}
</h4>
);
export function H5(
props: React.ComponentPropsWithoutRef<"h5"> & React.PropsWithChildren,
) {
return <Heading level={5} {...props} />;
}
export function Kicker({ children, className }: HeadingProps) {
return (
<p
className={clsx(
className,
"uppercase text-blue tracking-widest text-sm font-medium dark:text-stone-400",
)}
>
{children}
</p>
);
export function H6(
props: React.ComponentPropsWithoutRef<"h6"> & React.PropsWithChildren,
) {
return <Heading level={6} {...props} />;
}

View File

@@ -0,0 +1,21 @@
import clsx from "clsx";
export function Kicker({
children,
className,
as,
}: React.ComponentPropsWithoutRef<"p"> & {
as?: React.ElementType;
}) {
const Element = as ?? "p";
return (
<Element
className={clsx(
className,
"uppercase text-blue tracking-widest text-sm font-medium dark:text-stone-400",
)}
>
{children}
</Element>
);
}

View File

@@ -1,32 +1,33 @@
import { clsx } from "clsx";
import { useId } from "react";
import { forwardRef, useId } from "react";
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
// label is required for a11y, but you can hide it with a "label:sr-only" className
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
// label can be hidden with a "label:sr-only" className
label: string;
type?: "text" | "email" | "number";
className?: string;
id?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, className, id: customId, ...inputProps }, ref) => {
const generatedId = useId();
const id = customId || generatedId;
export function Input(props: Props) {
const { label, id: customId, className, type = "text" } = props;
const generatedId = useId();
const id = customId || generatedId;
const inputClassName = clsx(
"w-full rounded-md border px-3.5 py-2 shadow-sm",
"font-medium text-stone-900",
"dark:text-white dark:bg-stone-925",
);
const inputClassName = clsx(
"w-full rounded-md border px-3.5 py-2 shadow-sm",
"font-medium text-stone-900",
"dark:text-white",
);
const containerClassName = clsx("grid gap-1", className);
const containerClassName = clsx("grid gap-1", className);
return (
<div className={containerClassName}>
<label htmlFor={id} className="text-stone-600 dark:text-stone-300">
{label}
</label>
return (
<div className={containerClassName}>
<label htmlFor={id} className="text-stone-600 dark:text-stone-300">
{label}
</label>
<input {...props} type={type} id={id} className={inputClassName} />
</div>
);
}
<input ref={ref} {...inputProps} id={id} className={inputClassName} />
</div>
);
},
);

View File

@@ -1,6 +1,7 @@
import clsx from "clsx";
import { ReactNode } from "react";
import { H2, Kicker } from "../atoms/Headings";
import { H2 } from "../atoms/Headings";
import { Kicker } from "../atoms/Kicker";
import { Prose } from "./Prose";
function H2Sub({ children }: { children: React.ReactNode }) {

View File

@@ -14,10 +14,9 @@ export function Select(
const containerClassName = clsx("grid gap-1", className);
const selectClassName = clsx(
"g-select",
"w-full rounded-md border shadow-sm px-2 py-1.5 text-sm",
"font-medium text-stone-900",
"dark:text-white",
"dark:text-white dark:bg-stone-925",
"appearance-none",
);

View File

@@ -55,6 +55,22 @@ const { worker } = await startWorker({
- load/subscribe to CoValues: `MyCoValue.subscribe(id, worker, {...})`
- create CoValues & Groups `const val = MyCoValue.create({...}, { owner: worker })`
To make the worker state survive between restarts even on spotty connections, you can pass a `storage` parameter to `startWorker`:
<CodeGroup>
{/* prettier-ignore */}
```ts
import { startWorker } from 'jazz-nodejs';
import { SQLiteStorage } from 'cojson-storage-sqlite';
const { worker } = await startWorker({
AccountSchema: MyWorkerAccount,
syncServer: 'wss://cloud.jazz.tools/?key=you@example.com',
storage: await SQLiteStorage.asPeer({ filename: dbPath }),
});
```
</CodeGroup>
## Using CoValues instead of requests
Just like traditional backend functions, you can use Server Workers to do useful stuff (computations, calls to third-party APIs etc.) and put the results back into CoValues, which subscribed clients automatically get notified about.

View File

@@ -1,3 +1,4 @@
import { H1 } from "gcmp-design-system/src/app/components/atoms/Headings";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import { Prose } from "gcmp-design-system/src/app/components/molecules/Prose";
import Link from "next/link";
@@ -44,10 +45,10 @@ export function HeroSection() {
<p className="uppercase text-blue tracking-widest text-sm font-medium dark:text-stone-400">
Local-first development toolkit
</p>
<h1 className="font-display text-stone-950 dark:text-white text-4xl md:text-5xl lg:text-6xl font-medium tracking-tighter">
<H1>
<span className="inline-block">Ship top-tier apps</span>{" "}
<span className="inline-block">at high tempo.</span>
</h1>
</H1>
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200">
<p>

View File

@@ -14,6 +14,7 @@
},
"devDependencies": {
"@types/ws": "8.5.10",
"cojson-storage-sqlite": "workspace:*",
"jazz-run": "workspace:*",
"typescript": "~5.6.2"
},

View File

@@ -1,4 +1,4 @@
import { AgentSecret, LocalNode, WasmCrypto } from "cojson";
import { AgentSecret, LocalNode, Peer, WasmCrypto } from "cojson";
import {
Account,
AccountClass,
@@ -14,6 +14,7 @@ type WorkerOptions<Acc extends Account> = {
accountID?: string;
accountSecret?: string;
syncServer?: string;
storage?: Peer;
AccountSchema?: AccountClass<Acc>;
};
@@ -46,6 +47,12 @@ export async function startWorker<Acc extends Account>(
throw new Error("Invalid accountSecret");
}
const peersToLoadFrom = [wsPeer.peer];
if (options.storage) {
peersToLoadFrom.push(options.storage);
}
const context = await createJazzContext({
auth: fixedCredentialsAuth({
accountID: accountID as ID<Acc>,
@@ -54,7 +61,7 @@ export async function startWorker<Acc extends Account>(
AccountSchema,
// TODO: locked sessions similar to browser
sessionProvider: randomSessionProvider,
peersToLoadFrom: [wsPeer.peer],
peersToLoadFrom,
crypto: await WasmCrypto.create(),
});
@@ -69,7 +76,6 @@ export async function startWorker<Acc extends Account>(
async function done() {
await context.account.waitForAllCoValuesSync();
wsPeer.done();
context.done();
}

View File

@@ -1,13 +1,15 @@
import { randomUUID } from "crypto";
import { tmpdir } from "os";
import { join } from "path";
import { SQLiteStorage } from "cojson-storage-sqlite";
import { createWorkerAccount } from "jazz-run/createWorkerAccount";
import { startSyncServer } from "jazz-run/startSyncServer";
import { CoMap, Group, InboxSender, co } from "jazz-tools";
import { CoMap, Group, InboxSender, Peer, co } from "jazz-tools";
import { describe, expect, onTestFinished, test } from "vitest";
import { startWorker } from "../index";
async function setup() {
const { server, port } = await setupSyncServer();
const syncServer = `ws://localhost:${port}`;
const { server, port, syncServer } = await setupSyncServer();
const { worker, done } = await setupWorker(syncServer);
@@ -27,7 +29,9 @@ async function setupSyncServer(defaultPort = "0") {
server.close();
});
return { server, port };
const syncServer = `ws://localhost:${port}`;
return { server, port, syncServer };
}
async function setupWorker(syncServer: string) {
@@ -180,4 +184,42 @@ describe("startWorker integration", () => {
await worker2.done();
newServer.close();
});
test("can use a persistent storage", async () => {
// Create a temporary database file
const dbPath = join(tmpdir(), `test-${randomUUID()}.db`);
const { syncServer, server, port } = await setupSyncServer();
const { accountID, agentSecret } = await createWorkerAccount({
name: "test-worker",
peer: syncServer,
});
const { worker, done } = await startWorker({
accountID: accountID,
accountSecret: agentSecret,
syncServer,
storage: await SQLiteStorage.asPeer({ filename: dbPath }),
});
const map = TestMap.create({ value: "test" }, { owner: worker });
await done();
server.close();
const { worker: worker2, done: done2 } = await startWorker({
accountID: accountID,
accountSecret: agentSecret,
syncServer,
storage: await SQLiteStorage.asPeer({ filename: dbPath }),
});
const mapOnWorker2 = await TestMap.load(map.id, worker2, {});
expect(mapOnWorker2?.value).toBe("test");
await setupSyncServer(port); // Starting a new sync server on the same port so the final waitForSync will work
await done2();
});
});

3
pnpm-lock.yaml generated
View File

@@ -1691,6 +1691,9 @@ importers:
'@types/ws':
specifier: 8.5.10
version: 8.5.10
cojson-storage-sqlite:
specifier: workspace:*
version: link:../cojson-storage-sqlite
jazz-run:
specifier: workspace:*
version: link:../jazz-run