Compare commits
239 Commits
cojson-sto
...
jazz-bette
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
748c2ff751 | ||
|
|
70938b0ab3 | ||
|
|
3c3acae803 | ||
|
|
9b9bf44e2b | ||
|
|
392aa88d95 | ||
|
|
7ce82cd934 | ||
|
|
0c8158b91c | ||
|
|
25c56146f5 | ||
|
|
12481e14c2 | ||
|
|
0ab4d7a20d | ||
|
|
88ebcf58ab | ||
|
|
f379a168be | ||
|
|
bde6ac7d45 | ||
|
|
d1609cdd55 | ||
|
|
b71ab3168a | ||
|
|
0c8f6e5039 | ||
|
|
6a93a1b8a3 | ||
|
|
9f654a2603 | ||
|
|
dbf735d9e1 | ||
|
|
c62abefb66 | ||
|
|
1453869a46 | ||
|
|
239da90c9f | ||
|
|
972791e7a8 | ||
|
|
0c0178764e | ||
|
|
928350b821 | ||
|
|
be3fd9c696 | ||
|
|
269c028df0 | ||
|
|
e4df837138 | ||
|
|
54fe6d93ba | ||
|
|
979689c6d8 | ||
|
|
859a37868f | ||
|
|
57bd32d77e | ||
|
|
e21cbccd4b | ||
|
|
a66ab7d174 | ||
|
|
78e91f4030 | ||
|
|
7a915c198e | ||
|
|
c9b0420746 | ||
|
|
2303f3e70a | ||
|
|
a7bc9569a3 | ||
|
|
f351ba0fcd | ||
|
|
d3e554f491 | ||
|
|
b5e31456ad | ||
|
|
42d07ba7b4 | ||
|
|
b81b6ba69b | ||
|
|
1bc1759bb4 | ||
|
|
225bc1f63f | ||
|
|
5d94564f99 | ||
|
|
9633d0187f | ||
|
|
b82ecaa3ca | ||
|
|
111ec8d351 | ||
|
|
512aacdbc2 | ||
|
|
7ad843aa3e | ||
|
|
071128339b | ||
|
|
688ced499d | ||
|
|
ac91c8e7c2 | ||
|
|
c3b303c310 | ||
|
|
fc027a56db | ||
|
|
959a7a3927 | ||
|
|
2548085b59 | ||
|
|
b27bb3e65b | ||
|
|
937284f7e9 | ||
|
|
e999727c70 | ||
|
|
2197766624 | ||
|
|
d1efde468f | ||
|
|
4d4fd0beaa | ||
|
|
2b61e853a7 | ||
|
|
6f79b45544 | ||
|
|
2e1ff99579 | ||
|
|
7361854ee4 | ||
|
|
4a775fada3 | ||
|
|
3fe53a3a4a | ||
|
|
fe37516786 | ||
|
|
4beafb7cf3 | ||
|
|
82a592e08a | ||
|
|
4c6926153a | ||
|
|
c51b088243 | ||
|
|
867cb6b7a5 | ||
|
|
3a1fdd7600 | ||
|
|
3fdbb43b54 | ||
|
|
02969ee89b | ||
|
|
9b4988a514 | ||
|
|
8aa4b59d49 | ||
|
|
ac782674de | ||
|
|
5eb406d54d | ||
|
|
a3be832414 | ||
|
|
7ca8dd960e | ||
|
|
62c8aff73f | ||
|
|
7731109a28 | ||
|
|
0401fcf2a8 | ||
|
|
139a649279 | ||
|
|
9acccb5df2 | ||
|
|
fd90cdb49a | ||
|
|
4a29999c6a | ||
|
|
df487d5335 | ||
|
|
1efe84c691 | ||
|
|
73b99c6c1a | ||
|
|
039b92c839 | ||
|
|
618af5f1e3 | ||
|
|
dfc4286694 | ||
|
|
970ff0d813 | ||
|
|
65eee0ef01 | ||
|
|
eee221f563 | ||
|
|
063553090e | ||
|
|
97f6bcedbd | ||
|
|
7c63e6bb0f | ||
|
|
08aedcf517 | ||
|
|
3e12ee127f | ||
|
|
2283d375ef | ||
|
|
202e763380 | ||
|
|
52bbdb37a9 | ||
|
|
96f743b2f4 | ||
|
|
f5c47feeb6 | ||
|
|
6dffe73bd2 | ||
|
|
6f9ee31179 | ||
|
|
52f324ffc4 | ||
|
|
2d86f53575 | ||
|
|
68cb357a94 | ||
|
|
56ccf9ab9d | ||
|
|
b8b0851433 | ||
|
|
2bbb07b0bf | ||
|
|
4f7bc91502 | ||
|
|
d3053955d8 | ||
|
|
f61a120560 | ||
|
|
2f1307a0ba | ||
|
|
fa15ea56d1 | ||
|
|
1e58ecb3ac | ||
|
|
ceeabfaf89 | ||
|
|
70ce7c5736 | ||
|
|
f40484eca9 | ||
|
|
d581a59aa1 | ||
|
|
0ca09f75c1 | ||
|
|
e8fcd101f2 | ||
|
|
cf43fa7529 | ||
|
|
df1cdda4e8 | ||
|
|
be46042cdc | ||
|
|
6afdb16739 | ||
|
|
7a60d7bb76 | ||
|
|
f8263a8358 | ||
|
|
f6da966922 | ||
|
|
b0b2b85a6f | ||
|
|
8a2ab51543 | ||
|
|
28c19c134f | ||
|
|
0924c9baaa | ||
|
|
8bfaa0a18b | ||
|
|
b2712e18a2 | ||
|
|
66894b63d7 | ||
|
|
147be76399 | ||
|
|
36770bed52 | ||
|
|
466e6c44ee | ||
|
|
5bd8277161 | ||
|
|
b1a05143e3 | ||
|
|
fb761ce66d | ||
|
|
07a6c340dc | ||
|
|
8b4261f7d8 | ||
|
|
0ec917e453 | ||
|
|
6326d0fc45 | ||
|
|
d746b1279a | ||
|
|
0fea904dd0 | ||
|
|
373aef313f | ||
|
|
c09dcdfc76 | ||
|
|
a584590ed8 | ||
|
|
0a830e29a9 | ||
|
|
4402c553b6 | ||
|
|
e76fe343da | ||
|
|
dc183a19b2 | ||
|
|
fef55a4cd6 | ||
|
|
ddef54048f | ||
|
|
a2626a0f38 | ||
|
|
ec579bcaf7 | ||
|
|
8aa2d2a789 | ||
|
|
a39d009b87 | ||
|
|
6b662b0efe | ||
|
|
efff4d0f4f | ||
|
|
ea2b01d8a2 | ||
|
|
e9af90c841 | ||
|
|
2b7c6f5aa7 | ||
|
|
d73a3d9d46 | ||
|
|
8af39077a3 | ||
|
|
54bd487818 | ||
|
|
f01dab5c8f | ||
|
|
a8b3ec7bb0 | ||
|
|
a7f6870048 | ||
|
|
3b294f6994 | ||
|
|
a420b43029 | ||
|
|
a57268de32 | ||
|
|
6b2c4ed280 | ||
|
|
8d4e0027be | ||
|
|
a4141da1b7 | ||
|
|
c9ca5202f9 | ||
|
|
7b50a2e06d | ||
|
|
43dabccb57 | ||
|
|
b6d04f56ef | ||
|
|
628195b678 | ||
|
|
9a5d769717 | ||
|
|
e30a3f66bf | ||
|
|
6327fce933 | ||
|
|
a650da4184 | ||
|
|
6e4a94f6ce | ||
|
|
b73bec64bc | ||
|
|
50ae2f47c2 | ||
|
|
724d8e7f30 | ||
|
|
7b285ab110 | ||
|
|
01ac9b8c4c | ||
|
|
4e2e1ac73e | ||
|
|
94960c1f65 | ||
|
|
b5af58347b | ||
|
|
46a84558c5 | ||
|
|
f93566c045 | ||
|
|
d97ed603a3 | ||
|
|
8d33103182 | ||
|
|
aaa1ff978b | ||
|
|
82655ea7a7 | ||
|
|
8afe3a2e02 | ||
|
|
ae2adcbd15 | ||
|
|
eb0460d330 | ||
|
|
55cb83e6e0 | ||
|
|
6290088fec | ||
|
|
b9c17b37db | ||
|
|
6c76ff8fbf | ||
|
|
3c6a2a6092 | ||
|
|
e8a950e61a | ||
|
|
e2cbf035de | ||
|
|
47599b6307 | ||
|
|
901d0762ee | ||
|
|
d1c1b0c5cc | ||
|
|
cf4ad7285d | ||
|
|
255a947ea6 | ||
|
|
530a263d35 | ||
|
|
2983c7bd58 | ||
|
|
745020b7a8 | ||
|
|
991aebf7a7 | ||
|
|
9b75880b10 | ||
|
|
98fe72ed42 | ||
|
|
1f5c81c2ea | ||
|
|
fc41aa165b | ||
|
|
41466ea399 | ||
|
|
3b91594d10 | ||
|
|
265a6405af | ||
|
|
a4d23d527b |
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
@@ -55,6 +55,7 @@ jobs:
|
||||
"examples/inspector"
|
||||
"examples/music-player"
|
||||
"examples/organization"
|
||||
"examples/server-worker-http"
|
||||
"starters/react-passkey-auth"
|
||||
"starters/svelte-passkey-auth"
|
||||
"tests/jazz-svelte"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"tests/jazz-svelte/src/**",
|
||||
"examples/*svelte*/**",
|
||||
"starters/*svelte*/**",
|
||||
"examples/jazz-paper-scissors/src/routeTree.gen.ts",
|
||||
"examples/server-worker-inbox/src/routeTree.gen.ts",
|
||||
"homepage/homepage/**",
|
||||
"**/package.json"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.108
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-tools@0.16.2
|
||||
|
||||
## 0.0.107
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c62abef]
|
||||
- jazz-tools@0.16.1
|
||||
|
||||
## 0.0.106
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2bbb07b: Introduce a cleaner separation between Zod and CoValue schemas:
|
||||
- Zod schemas and CoValue schemas are fully separated. Zod schemas can only be composed with other Zod schemas. CoValue schemas can be composed with either Zod or other CoValue schemas.
|
||||
- `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas. Use `co.optional()` and `co.discriminatedUnion()` instead.
|
||||
- Internal schema access is now simpler. You no longer need to use Zod’s `.def` to access internals. Use properties like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType` directly.
|
||||
- CoValue schema types are now namespaced under `co.`. Non-namespaced exports have been removed
|
||||
- CoMap schemas no longer incorrectly inherit from Zod. Previously, methods like `.extend()` and `.partial()` appeared available but could cause unexpected behavior. These methods are now disabled. In their place, `.optional()` has been added, and more Zod-like methods will be introduced in future releases.
|
||||
- Upgraded Zod from `3.25.28` to `3.25.76`.
|
||||
- Removed deprecated `withHelpers` method from CoValue schemas
|
||||
- Removed deprecated `createCoValueObservable` function
|
||||
- Updated dependencies [c09dcdf]
|
||||
- Updated dependencies [2bbb07b]
|
||||
- jazz-tools@0.16.0
|
||||
|
||||
## 0.0.105
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9633d01]
|
||||
- Updated dependencies [4beafb7]
|
||||
- jazz-tools@0.15.16
|
||||
|
||||
## 0.0.104
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3fe53a3]
|
||||
- jazz-tools@0.15.15
|
||||
|
||||
## 0.0.103
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a584590]
|
||||
- Updated dependencies [9acccb5]
|
||||
- jazz-tools@0.15.14
|
||||
|
||||
## 0.0.102
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c76ff8]
|
||||
- jazz-tools@0.15.13
|
||||
|
||||
## 0.0.101
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d1c1b0c]
|
||||
- Updated dependencies [cf4ad72]
|
||||
- jazz-tools@0.15.12
|
||||
|
||||
## 0.0.100
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.100",
|
||||
"version": "0.0.108",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { co, z } from 'jazz-tools';
|
||||
import { co } from 'jazz-tools';
|
||||
|
||||
export const Message = co.map({
|
||||
text: co.plainText(),
|
||||
image: z.optional(co.image())
|
||||
image: co.optional(co.image())
|
||||
});
|
||||
|
||||
export const Chat = co.list(Message);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"lucide-react": "^0.274.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.28"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DraftBubbleTeaOrder,
|
||||
JazzAccount,
|
||||
ListOfBubbleTeaAddOns,
|
||||
validateDraftOrder,
|
||||
} from "./schema.ts";
|
||||
|
||||
export function CreateOrder() {
|
||||
@@ -22,8 +23,7 @@ export function CreateOrder() {
|
||||
if (!me?.root) return;
|
||||
|
||||
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
|
||||
// validate if the draft is a valid order
|
||||
const validation = DraftBubbleTeaOrder.validate(draft);
|
||||
const validation = validateDraftOrder(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.length > 0) {
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { DraftBubbleTeaOrder, JazzAccount } from "./schema";
|
||||
import { JazzAccount, hasChanges } from "./schema";
|
||||
export function DraftIndicator() {
|
||||
const { me } = useAccount(JazzAccount, {
|
||||
resolve: { root: { draft: true } },
|
||||
});
|
||||
|
||||
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
|
||||
if (hasChanges(me?.root.draft)) {
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-blue-500 border-2 border-white w-3 h-3 rounded-full dark:border-stone-925">
|
||||
<span className="sr-only">You have a draft</span>
|
||||
|
||||
@@ -15,13 +15,13 @@ export const BubbleTeaBaseTeaTypes = [
|
||||
"Thai",
|
||||
] as const;
|
||||
|
||||
export const ListOfBubbleTeaAddOns = co
|
||||
.list(z.literal([...BubbleTeaAddOnTypes]))
|
||||
.withHelpers((Self) => ({
|
||||
hasChanges(list?: Loaded<typeof Self> | null) {
|
||||
return list && Object.entries(list._raw.insertions).length > 0;
|
||||
},
|
||||
}));
|
||||
export const ListOfBubbleTeaAddOns = co.list(
|
||||
z.literal([...BubbleTeaAddOnTypes]),
|
||||
);
|
||||
|
||||
function hasAddOnsChanges(list?: Loaded<typeof ListOfBubbleTeaAddOns> | null) {
|
||||
return list && Object.entries(list._raw.insertions).length > 0;
|
||||
}
|
||||
|
||||
export const BubbleTeaOrder = co.map({
|
||||
baseTea: z.literal([...BubbleTeaBaseTeaTypes]),
|
||||
@@ -31,36 +31,33 @@ export const BubbleTeaOrder = co.map({
|
||||
instructions: co.optional(co.plainText()),
|
||||
});
|
||||
|
||||
export const DraftBubbleTeaOrder = co
|
||||
.map({
|
||||
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
|
||||
addOns: co.optional(ListOfBubbleTeaAddOns),
|
||||
deliveryDate: z.optional(z.date()),
|
||||
withMilk: z.optional(z.boolean()),
|
||||
instructions: co.optional(co.plainText()),
|
||||
})
|
||||
.withHelpers((Self) => ({
|
||||
hasChanges(order: Loaded<typeof Self> | undefined) {
|
||||
return (
|
||||
!!order &&
|
||||
(Object.keys(order._edits).length > 1 ||
|
||||
ListOfBubbleTeaAddOns.hasChanges(order.addOns))
|
||||
);
|
||||
},
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
|
||||
addOns: co.optional(ListOfBubbleTeaAddOns),
|
||||
deliveryDate: z.optional(z.date()),
|
||||
withMilk: z.optional(z.boolean()),
|
||||
instructions: co.optional(co.plainText()),
|
||||
});
|
||||
|
||||
validate(order: Loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!order.baseTea) {
|
||||
errors.push("Please select your preferred base tea.");
|
||||
}
|
||||
if (!order.deliveryDate) {
|
||||
errors.push("Plese select a delivery date.");
|
||||
}
|
||||
if (!order.baseTea) {
|
||||
errors.push("Please select your preferred base tea.");
|
||||
}
|
||||
if (!order.deliveryDate) {
|
||||
errors.push("Plese select a delivery date.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export function hasChanges(order?: Loaded<typeof DraftBubbleTeaOrder> | null) {
|
||||
return (
|
||||
!!order &&
|
||||
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
|
||||
);
|
||||
}
|
||||
|
||||
/** The root is an app-specific per-user private `CoMap`
|
||||
* where you can store top-level objects for that user */
|
||||
|
||||
@@ -11,9 +11,9 @@ export const Issue = co.map({
|
||||
status: z.enum(["open", "closed"]),
|
||||
labels: co.list(z.string()),
|
||||
reactions: ReactionsList,
|
||||
file: z.optional(co.fileStream()),
|
||||
image: z.optional(co.image()),
|
||||
lead: z.optional(co.account()),
|
||||
file: co.optional(co.fileStream()),
|
||||
image: co.optional(co.image()),
|
||||
lead: co.optional(co.account()),
|
||||
});
|
||||
|
||||
export const Project = co.map({
|
||||
|
||||
1
examples/jazz-nextjs/src/apiKey.ts
Normal file
1
examples/jazz-nextjs/src/apiKey.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const apiKey = "jazz-nextjs@garden.co";
|
||||
@@ -1,12 +1,13 @@
|
||||
import { JazzInspector } from "jazz-tools/inspector";
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
import { apiKey } from "./apiKey";
|
||||
|
||||
export function Jazz({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<JazzReactProvider
|
||||
enableSSR
|
||||
sync={{
|
||||
peer: `wss://cloud.jazz.tools/`,
|
||||
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.28"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -31,4 +31,4 @@
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "3.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
JazzAccount,
|
||||
Organization,
|
||||
Project,
|
||||
validateDraftOrganization,
|
||||
} from "../schema.ts";
|
||||
import { Errors } from "./Errors.tsx";
|
||||
import { OrganizationForm } from "./OrganizationForm.tsx";
|
||||
@@ -21,8 +22,7 @@ export function CreateOrganization() {
|
||||
if (!me?.root?.organizations) return;
|
||||
|
||||
const onSave = (draft: Loaded<typeof DraftOrganization>) => {
|
||||
// validate if the draft is a valid organization
|
||||
const validation = DraftOrganization.validate(draft);
|
||||
const validation = validateDraftOrganization(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.length > 0) {
|
||||
return;
|
||||
|
||||
@@ -10,24 +10,24 @@ export const Organization = co.map({
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
export const DraftOrganization = co
|
||||
.map({
|
||||
name: z.optional(z.string()),
|
||||
projects: co.list(Project),
|
||||
})
|
||||
.withHelpers((Self) => ({
|
||||
validate(org: Loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftOrganization = co.map({
|
||||
name: z.optional(z.string()),
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
if (!org.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrganization(
|
||||
org: Loaded<typeof DraftOrganization>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
},
|
||||
}));
|
||||
if (!org.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export const JazzAccountRoot = co.map({
|
||||
organizations: co.list(Organization),
|
||||
|
||||
47
examples/server-worker-http/.gitignore
vendored
Normal file
47
examples/server-worker-http/.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
105
examples/server-worker-http/README.md
Normal file
105
examples/server-worker-http/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Rock Paper Scissors with Jazz HTTP API
|
||||
|
||||
This example demonstrates how to use the **Jazz HTTP API** with **Next.js** to implement updates in a trusted environment. The application implements a multiplayer Rock Paper Scissors game where the server validates game actions and reveals player intentions only after both players have made their moves.
|
||||
|
||||
## 🎯 Key Concepts Demonstrated
|
||||
|
||||
### Trusted Environment Updates
|
||||
- **Server-side validation**: All game actions are validated by the server before being applied
|
||||
- **Secure reveal mechanism**: Player choices are hidden until both players have acted, built with Jazz group permissions
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- pnpm (recommended) or npm
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Generate environment variables**:
|
||||
```bash
|
||||
pnpm generate-env
|
||||
```
|
||||
|
||||
3. **Start the development server**:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. **Open your browser** to [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
### Environment Setup
|
||||
|
||||
The `generate-env.ts` script creates the necessary environment variables for Jazz:
|
||||
- `NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT`: Server worker account ID
|
||||
- `JAZZ_WORKER_SECRET`: Server worker secret key
|
||||
|
||||
## 🔧 Key Implementation Details
|
||||
|
||||
### Server API Definition
|
||||
```typescript
|
||||
const playRequest = experimental_defineRequest({
|
||||
url: "/api/play",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
game: Game,
|
||||
selection: z.literal(["rock", "paper", "scissors"]),
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1: { account: true, playSelection: { group: true } },
|
||||
player2: { account: true, playSelection: { group: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: { game: Game },
|
||||
resolve: { game: true },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Secure Move Handling
|
||||
```typescript
|
||||
// Create restricted group for the move
|
||||
const group = Group.create({ owner: jazzServerAccount.worker });
|
||||
group.addMember(madeBy, "reader");
|
||||
|
||||
// Store move with restricted access
|
||||
const playSelection = PlaySelection.create(
|
||||
{ value: selection, group },
|
||||
group,
|
||||
);
|
||||
|
||||
// Reveal moves only after both players have acted
|
||||
if (player1PlaySelection && player2PlaySelection) {
|
||||
player1PlaySelection.group.addMember(game.player2.account, "reader");
|
||||
player2PlaySelection.group.addMember(game.player1.account, "reader");
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
This pattern is ideal for applications requiring:
|
||||
|
||||
- **Fair play guarantees**: Preventing cheating in games
|
||||
- **Simultaneous reveals**: Auctions, voting, or sealed-bid systems
|
||||
- **Trusted computation**: Server-side validation of complex business logic
|
||||
- **Real-time collaboration**: Multi-user applications with strict rules
|
||||
|
||||
## 📚 Learn More
|
||||
|
||||
- [Jazz Documentation](https://jazz.tools/docs)
|
||||
- [Next.js App Router](https://nextjs.org/docs/app)
|
||||
- [Groups & Permissions](https://jazz.tools/docs/react/groups/intro)
|
||||
- [HTTP API with experimental_defineRequest](https://jazz.tools/docs/react/server-side/http-requests)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This example is part of the Jazz framework. Feel free to submit issues and enhancement requests!
|
||||
21
examples/server-worker-http/components.json
Normal file
21
examples/server-worker-http/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
26
examples/server-worker-http/generate-env.ts
Normal file
26
examples/server-worker-http/generate-env.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as fs from "fs";
|
||||
import { createWorkerAccount } from "jazz-run/createWorkerAccount";
|
||||
|
||||
if (fs.existsSync(".env")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const account = await createWorkerAccount({
|
||||
name: "jazz-paper-scissors-worker",
|
||||
peer: "wss://cloud.jazz.tools/?key=jazz-paper-scissors@garden.co",
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
".env",
|
||||
`
|
||||
JAZZ_WORKER_ACCOUNT=${account.accountID}
|
||||
NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT=${account.accountID}
|
||||
JAZZ_WORKER_SECRET=${account.agentSecret}
|
||||
`,
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
5
examples/server-worker-http/next.config.ts
Normal file
5
examples/server-worker-http/next.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
40
examples/server-worker-http/package.json
Normal file
40
examples/server-worker-http/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "server-worker-http",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"generate-env": "tsx generate-env.ts",
|
||||
"build": "pnpm generate-env && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jazz-run": "workspace:*",
|
||||
"tsx": "^4.19.3",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
55
examples/server-worker-http/playwright.config.ts
Normal file
55
examples/server-worker-http/playwright.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import isCI from "is-ci";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: isCI,
|
||||
/* Retry on CI only */
|
||||
retries: isCI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: isCI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
|
||||
timeout: 20_000,
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:3000/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: [
|
||||
{
|
||||
command: "pnpm start",
|
||||
url: "http://localhost:3000/",
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
],
|
||||
});
|
||||
2
examples/server-worker-http/postcss.config.mjs
Normal file
2
examples/server-worker-http/postcss.config.mjs
Normal file
@@ -0,0 +1,2 @@
|
||||
const config = { plugins: { "@tailwindcss/postcss": {} } };
|
||||
export default config;
|
||||
1
examples/server-worker-http/src/apiKey.ts
Normal file
1
examples/server-worker-http/src/apiKey.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const apiKey = "server-side-validation@garden.co";
|
||||
23
examples/server-worker-http/src/app/api/create-game/route.ts
Normal file
23
examples/server-worker-http/src/app/api/create-game/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { WaitingRoom } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Group } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = await serverApi.createGame.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async (_, madeBy) => {
|
||||
const waitingRoom = WaitingRoom.create(
|
||||
{ creator: madeBy },
|
||||
Group.create(jazzServerAccount.worker).makePublic(),
|
||||
);
|
||||
|
||||
return {
|
||||
waitingRoom,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
49
examples/server-worker-http/src/app/api/join-game/route.ts
Normal file
49
examples/server-worker-http/src/app/api/join-game/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { Game, createGameState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Account, Group, JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return serverApi.joinGame.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async ({ waitingRoom }, madeBy) => {
|
||||
if (madeBy.id === waitingRoom.creator.id) {
|
||||
throw new JazzRequestError("You can't join your own waiting room", 400);
|
||||
}
|
||||
|
||||
waitingRoom.game = createGame({
|
||||
account1: waitingRoom.creator,
|
||||
account2: madeBy,
|
||||
worker: jazzServerAccount.worker,
|
||||
});
|
||||
|
||||
return {
|
||||
waitingRoom,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateGameParams {
|
||||
account1: Account;
|
||||
account2: Account;
|
||||
worker: Account;
|
||||
}
|
||||
|
||||
function createGame({ account1, account2, worker }: CreateGameParams) {
|
||||
const gameGroup = Group.create({ owner: worker });
|
||||
gameGroup.addMember(account1, "reader");
|
||||
gameGroup.addMember(account2, "reader");
|
||||
|
||||
const game = Game.create(
|
||||
{
|
||||
...createGameState({ account1, account2, worker }),
|
||||
player1Score: 0,
|
||||
player2Score: 0,
|
||||
},
|
||||
gameGroup,
|
||||
);
|
||||
|
||||
return game;
|
||||
}
|
||||
35
examples/server-worker-http/src/app/api/new-game/route.ts
Normal file
35
examples/server-worker-http/src/app/api/new-game/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { createGameState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = await serverApi.newGame.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async ({ game }, madeBy) => {
|
||||
const isPlayer1 = game.player1.account.id === madeBy.id;
|
||||
const isPlayer2 = game.player2.account.id === madeBy.id;
|
||||
|
||||
if (!isPlayer1 && !isPlayer2) {
|
||||
throw new JazzRequestError("You are not a player in this game", 400);
|
||||
}
|
||||
|
||||
if (game.outcome) {
|
||||
game.applyDiff(
|
||||
createGameState({
|
||||
account1: game.player1.account,
|
||||
account2: game.player2.account,
|
||||
worker: jazzServerAccount.worker,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
game,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
89
examples/server-worker-http/src/app/api/play/route.ts
Normal file
89
examples/server-worker-http/src/app/api/play/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { PlaySelection } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Group, JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = await serverApi.play.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async ({ game, selection }, madeBy) => {
|
||||
const isPlayer1 = game.player1.account.id === madeBy.id;
|
||||
const isPlayer2 = game.player2.account.id === madeBy.id;
|
||||
|
||||
if (!isPlayer1 && !isPlayer2) {
|
||||
throw new JazzRequestError("You are not a player in this game", 400);
|
||||
}
|
||||
|
||||
const group = Group.create({ owner: jazzServerAccount.worker });
|
||||
group.addMember(madeBy, "reader");
|
||||
|
||||
if (isPlayer1 && game.player1.playSelection) {
|
||||
throw new JazzRequestError("You have already played", 400);
|
||||
} else if (isPlayer2 && game.player2.playSelection) {
|
||||
throw new JazzRequestError("You have already played", 400);
|
||||
}
|
||||
|
||||
const playSelection = PlaySelection.create(
|
||||
{ value: selection, group },
|
||||
group,
|
||||
);
|
||||
|
||||
if (isPlayer1) {
|
||||
game.player1.playSelection = playSelection;
|
||||
} else {
|
||||
game.player2.playSelection = playSelection;
|
||||
}
|
||||
|
||||
if (game.player1.playSelection && game.player2.playSelection) {
|
||||
game.outcome = determineOutcome(
|
||||
game.player1.playSelection.value,
|
||||
game.player2.playSelection.value,
|
||||
);
|
||||
|
||||
// Reveal the play selections to the other player
|
||||
game.player1.playSelection.group.addMember(
|
||||
game.player2.account,
|
||||
"reader",
|
||||
);
|
||||
game.player2.playSelection.group.addMember(
|
||||
game.player1.account,
|
||||
"reader",
|
||||
);
|
||||
|
||||
if (game.outcome === "player1") {
|
||||
game.player1Score++;
|
||||
} else if (game.outcome === "player2") {
|
||||
game.player2Score++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
game,
|
||||
result: "success",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a player selections, returns the winner of the current game.
|
||||
*/
|
||||
function determineOutcome(
|
||||
player1Choice: "rock" | "paper" | "scissors",
|
||||
player2Choice: "rock" | "paper" | "scissors",
|
||||
) {
|
||||
if (player1Choice === player2Choice) {
|
||||
return "draw";
|
||||
} else if (
|
||||
(player1Choice === "rock" && player2Choice === "scissors") ||
|
||||
(player1Choice === "paper" && player2Choice === "rock") ||
|
||||
(player1Choice === "scissors" && player2Choice === "paper")
|
||||
) {
|
||||
return "player1";
|
||||
} else {
|
||||
return "player2";
|
||||
}
|
||||
}
|
||||
388
examples/server-worker-http/src/app/game/[id]/page.tsx
Normal file
388
examples/server-worker-http/src/app/game/[id]/page.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Game, PlaySelection, PlayerState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Group, isJazzRequestError } from "jazz-tools";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Gamepad2,
|
||||
Minus,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const playIcon = (
|
||||
selection: "rock" | "paper" | "scissors" | undefined,
|
||||
size: "sm" | "lg" = "sm",
|
||||
) => {
|
||||
const emojiSize = size === "lg" ? "text-4xl" : "text-2xl";
|
||||
|
||||
switch (selection) {
|
||||
case "rock":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${emojiSize} `} style={{ animationDelay: "0ms" }}>
|
||||
🪨
|
||||
</span>
|
||||
<span className="font-semibold">Rock</span>
|
||||
</div>
|
||||
);
|
||||
case "paper":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${emojiSize} `} style={{ animationDelay: "150ms" }}>
|
||||
📄
|
||||
</span>
|
||||
<span className="font-semibold">Paper</span>
|
||||
</div>
|
||||
);
|
||||
case "scissors":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${emojiSize} `} style={{ animationDelay: "300ms" }}>
|
||||
✂️
|
||||
</span>
|
||||
<span className="font-semibold">Scissors</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className={size === "lg" ? "w-8 h-8" : "w-6 h-6"} />
|
||||
<span>Waiting for selection</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getOutcomeIcon = (outcome: string | undefined, player: string) => {
|
||||
if (outcome === player) {
|
||||
return <Trophy className="w-6 h-6 text-yellow-500" />;
|
||||
} else if (outcome === "draw") {
|
||||
return <Minus className="w-6 h-6 text-blue-500" />;
|
||||
} else {
|
||||
return <XCircle className="w-6 h-6 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function RouteComponent() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const game = useCoState(Game, params.id, {
|
||||
resolve: {
|
||||
player1State: {
|
||||
$onError: null,
|
||||
},
|
||||
player2State: {
|
||||
$onError: null,
|
||||
},
|
||||
player1: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
$onError: null,
|
||||
},
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
$onError: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPlayer1 = game?.player1?.account?.isMe;
|
||||
const player = isPlayer1 ? "player1" : "player2";
|
||||
|
||||
if (!game) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
|
||||
<div className="animate-pulse">
|
||||
<Gamepad2 className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gameComplete = game.outcome !== undefined;
|
||||
|
||||
const opponent = isPlayer1 ? "player2" : "player1";
|
||||
|
||||
const currentPlayer = game[player];
|
||||
const opponentPlayer = game[opponent];
|
||||
|
||||
const currentPlayerState = game[isPlayer1 ? "player1State" : "player2State"];
|
||||
|
||||
const opponentSelection = opponentPlayer?.playSelection;
|
||||
const opponentHasSelected = Boolean(opponentPlayer._refs.playSelection);
|
||||
|
||||
const handleSelection = (selection: "rock" | "paper" | "scissors") => {
|
||||
if (!currentPlayerState) return;
|
||||
if (currentPlayerState.submitted) return;
|
||||
|
||||
currentPlayerState.currentSelection = selection;
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
playSelection: "rock" | "paper" | "scissors" | undefined,
|
||||
) => {
|
||||
if (!playSelection) return;
|
||||
if (!currentPlayerState) return;
|
||||
|
||||
currentPlayerState.submitted = true;
|
||||
|
||||
try {
|
||||
await serverApi.play.send({
|
||||
game,
|
||||
selection: playSelection,
|
||||
});
|
||||
} catch (error) {
|
||||
currentPlayerState.submitted = false;
|
||||
if (isJazzRequestError(error)) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewGame = async () => {
|
||||
if (!currentPlayerState) return;
|
||||
|
||||
currentPlayerState.resetRequested = true;
|
||||
|
||||
try {
|
||||
await serverApi.newGame.send({
|
||||
game,
|
||||
});
|
||||
} catch (error) {
|
||||
currentPlayerState.resetRequested = false;
|
||||
|
||||
if (isJazzRequestError(error)) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const playSelection = currentPlayerState?.currentSelection;
|
||||
|
||||
const submitDisabled =
|
||||
!currentPlayerState?.currentSelection || currentPlayerState?.submitted;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Gamepad2 className="w-8 h-8 text-primary" />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
|
||||
Rock, Paper, Scissors
|
||||
</h1>
|
||||
<Sparkles className="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
|
||||
{/* Player Info */}
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<Badge variant="secondary" className="px-4 py-2">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
{isPlayer1 ? "Player 1" : "Player 2"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Score Board */}
|
||||
<Card className="inline-block bg-white/80 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-6 text-2xl font-bold">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">Player 1</span>
|
||||
<span className="text-3xl">{game?.player1Score ?? 0}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">-</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-3xl">{game?.player2Score ?? 0}</span>
|
||||
<span className="text-purple-600">Player 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Game Outcome */}
|
||||
{gameComplete && (
|
||||
<Card className="mb-8 bg-gradient-to-r from-yellow-50 to-orange-50 border-yellow-200">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
{getOutcomeIcon(game.outcome, player)}
|
||||
<h2 className="text-2xl font-bold">
|
||||
{game?.outcome === player
|
||||
? "🎉 You Win! 🎉"
|
||||
: game?.outcome === "draw"
|
||||
? "🤝 It's a Draw! 🤝"
|
||||
: "😔 You Lose! 😔"}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
disabled={currentPlayerState?.resetRequested}
|
||||
onClick={handleNewGame}
|
||||
className="bg-gradient-to-r from-primary to-purple-600 hover:from-primary/90 hover:to-purple-600/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
<Gamepad2 className="w-5 h-5 mr-2" />
|
||||
Start New Game
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Game Board */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Your Selection */}
|
||||
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl font-semibold text-blue-600">
|
||||
Your Selection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<div className="mb-6">{playIcon(playSelection, "lg")}</div>
|
||||
|
||||
{!gameComplete && (
|
||||
<>
|
||||
{/* Choice Buttons */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Button
|
||||
variant={playSelection === "rock" ? "default" : "outline"}
|
||||
size="lg"
|
||||
className={`h-20 transition-all duration-200 ${
|
||||
playSelection === "rock"
|
||||
? "bg-gradient-to-br from-gray-400 to-gray-600 text-white shadow-lg scale-105"
|
||||
: "hover:scale-105"
|
||||
} text-3xl`}
|
||||
onClick={() => handleSelection("rock")}
|
||||
aria-label="Select Rock"
|
||||
aria-selected={playSelection === "rock"}
|
||||
>
|
||||
🪨
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
playSelection === "paper" ? "default" : "outline"
|
||||
}
|
||||
size="lg"
|
||||
className={`h-20 transition-all duration-200 ${
|
||||
playSelection === "paper"
|
||||
? "bg-gradient-to-br from-blue-400 to-blue-600 text-white shadow-lg scale-105"
|
||||
: "hover:scale-105"
|
||||
} text-3xl`}
|
||||
onClick={() => handleSelection("paper")}
|
||||
aria-label="Select Paper"
|
||||
aria-selected={playSelection === "paper"}
|
||||
>
|
||||
📄
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
playSelection === "scissors" ? "default" : "outline"
|
||||
}
|
||||
size="lg"
|
||||
className={`h-20 transition-all duration-200 ${
|
||||
playSelection === "scissors"
|
||||
? "bg-gradient-to-br from-red-400 to-red-600 text-white shadow-lg scale-105"
|
||||
: "hover:scale-105"
|
||||
} text-3xl`}
|
||||
onClick={() => handleSelection("scissors")}
|
||||
aria-label="Select Scissors"
|
||||
aria-selected={playSelection === "scissors"}
|
||||
>
|
||||
✂️
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
disabled={submitDisabled}
|
||||
onClick={() => handleSubmit(playSelection)}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-3 text-lg"
|
||||
size="lg"
|
||||
>
|
||||
{currentPlayerState?.submitted ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5 mr-2" />
|
||||
Selection Made!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Gamepad2 className="w-5 h-5 mr-2" />
|
||||
Make Your Move!
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Opponent Selection */}
|
||||
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl font-semibold text-purple-600">
|
||||
Opponent's Selection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
{opponentSelection && (
|
||||
<div className="mb-6">
|
||||
{playIcon(opponentSelection?.value, "lg")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!opponentSelection && !gameComplete ? (
|
||||
opponentHasSelected ? (
|
||||
<div className="text-muted-foreground">
|
||||
<Clock className="w-8 h-8 mx-auto mb-2 animate-pulse" />
|
||||
<p>The opponent has made their move</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
<Clock className="w-8 h-8 mx-auto mb-2 animate-pulse" />
|
||||
<p>Waiting for opponent...</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Game Status */}
|
||||
{!gameComplete && (
|
||||
<Card className="mt-8 bg-white/60 backdrop-blur-sm border-0">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-sm">
|
||||
{currentPlayer?.playSelection && !opponentSelection
|
||||
? "Waiting for opponent to make their move..."
|
||||
: "Make your selection to start the game"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
examples/server-worker-http/src/app/globals.css
Normal file
120
examples/server-worker-http/src/app/globals.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
37
examples/server-worker-http/src/app/layout.tsx
Normal file
37
examples/server-worker-http/src/app/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Jazz } from "../jazz";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jazz Server Side Validation",
|
||||
description: "An example of server side validation with Jazz",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Jazz>{children}</Jazz>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
97
examples/server-worker-http/src/app/page.tsx
Normal file
97
examples/server-worker-http/src/app/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function HomeComponent() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const onNewGameClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { waitingRoom } = await serverApi.createGame.send({});
|
||||
|
||||
router.push(`/waiting-room/${waitingRoom.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-900 flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 text-center space-y-8 max-w-2xl mx-auto">
|
||||
{/* Game title and emojis */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center items-center space-x-4 text-6xl mb-6">
|
||||
<span className="animate-bounce" style={{ animationDelay: "0ms" }}>
|
||||
🪨
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
>
|
||||
📄
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
✂️
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-white mb-2">
|
||||
Rock, Paper, Scissors
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 max-w-md mx-auto">
|
||||
Challenge your friends in this classic multiplayer game powered by
|
||||
Jazz
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Game card */}
|
||||
<Card className="w-full max-w-md mx-auto bg-white/10 backdrop-blur-lg border-white/20 shadow-2xl">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-bold text-white">
|
||||
Ready to Play?
|
||||
</CardTitle>
|
||||
<p className="text-gray-300 text-sm mt-2">
|
||||
Create a new game and invite your friends
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button
|
||||
onClick={onNewGameClick}
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-lg font-semibold bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 border-0 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>Creating game...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>🎮</span>
|
||||
<span>Start New Game</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-gray-400 text-sm mt-12">
|
||||
<p>Built with Jazz Framework</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
examples/server-worker-http/src/app/waiting-room/[id]/page.tsx
Normal file
173
examples/server-worker-http/src/app/waiting-room/[id]/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WaitingRoom } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Account, JazzRequestError, co } from "jazz-tools";
|
||||
import { useCoState } from "jazz-tools/react-core";
|
||||
import { ClipboardCopyIcon, Loader2Icon } from "lucide-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function useWindowLocation() {
|
||||
const [location, setLocation] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setLocation(window.location.href);
|
||||
}, []);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
async function askToJoinGame(
|
||||
waitingRoom: co.loaded<typeof WaitingRoom, { creator: true }>,
|
||||
) {
|
||||
if (waitingRoom.creator.isMe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await serverApi.joinGame.send({
|
||||
waitingRoom,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof JazzRequestError) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function RouteComponent() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const waitingRoom = useCoState(WaitingRoom, params.id, {
|
||||
resolve: {
|
||||
creator: true,
|
||||
game: true,
|
||||
},
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
const router = useRouter();
|
||||
const location = useWindowLocation();
|
||||
const joinLocation = location + "?join=true";
|
||||
|
||||
const isJoining = !waitingRoom
|
||||
? searchParams.get("join") === "true"
|
||||
: Account.getMe().id !== waitingRoom.creator.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingRoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
askToJoinGame(waitingRoom);
|
||||
}, [waitingRoom?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingRoom?.game?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/game/${waitingRoom.game.id}`);
|
||||
}, [waitingRoom?.game?.id]);
|
||||
|
||||
const onCopyClick = () => {
|
||||
navigator.clipboard.writeText(joinLocation);
|
||||
setCopied(true);
|
||||
toast.success("Link copied to clipboard!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-900 flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 text-center space-y-8 max-w-2xl mx-auto">
|
||||
{/* Game title and emojis */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center items-center space-x-4 text-6xl mb-6">
|
||||
<span className="animate-bounce" style={{ animationDelay: "0ms" }}>
|
||||
🪨
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
>
|
||||
📄
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
✂️
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-white mb-2">Waiting Room</h1>
|
||||
{!isJoining && (
|
||||
<p className="text-xl text-gray-300 max-w-md mx-auto">
|
||||
Share this link with your friend to join the game
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Waiting room card */}
|
||||
<Card className="w-full max-w-md mx-auto bg-white/10 backdrop-blur-lg border-white/20 shadow-2xl">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-bold text-white flex items-center justify-center">
|
||||
<Loader2Icon className="animate-spin inline h-8 w-8 mr-3" />
|
||||
{isJoining ? "Joining the game" : "Waiting for opponent"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-300 text-sm mt-2">
|
||||
The game will automatically start once{" "}
|
||||
{isJoining ? "ready" : "they join"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{!isJoining && (
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex">
|
||||
<Input
|
||||
className="w-full border-white/20 bg-white/5 text-white placeholder:text-gray-400 rounded-e-none focus:border-white/40 focus:ring-white/20"
|
||||
readOnly
|
||||
value={joinLocation}
|
||||
/>
|
||||
<Button
|
||||
onClick={onCopyClick}
|
||||
className="rounded-s-none w-25 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 border-0 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
{copied ? (
|
||||
"Copied!"
|
||||
) : (
|
||||
<>
|
||||
<ClipboardCopyIcon className="w-5 h-5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-gray-400 text-sm mt-12">
|
||||
<p>Built with Jazz Framework</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
examples/server-worker-http/src/components/ui/alert.tsx
Normal file
66
examples/server-worker-http/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
46
examples/server-worker-http/src/components/ui/badge.tsx
Normal file
46
examples/server-worker-http/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
59
examples/server-worker-http/src/components/ui/button.tsx
Normal file
59
examples/server-worker-http/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
examples/server-worker-http/src/components/ui/card.tsx
Normal file
92
examples/server-worker-http/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
24
examples/server-worker-http/src/components/ui/label.tsx
Normal file
24
examples/server-worker-http/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
28
examples/server-worker-http/src/components/ui/separator.tsx
Normal file
28
examples/server-worker-http/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
25
examples/server-worker-http/src/components/ui/sonner.tsx
Normal file
25
examples/server-worker-http/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
15
examples/server-worker-http/src/jazz.tsx
Normal file
15
examples/server-worker-http/src/jazz.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
import { apiKey } from "./apiKey";
|
||||
|
||||
export function Jazz({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<JazzReactProvider
|
||||
enableSSR
|
||||
sync={{
|
||||
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</JazzReactProvider>
|
||||
);
|
||||
}
|
||||
5
examples/server-worker-http/src/jazzServerAccount.ts
Normal file
5
examples/server-worker-http/src/jazzServerAccount.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { startWorker } from "jazz-tools/worker";
|
||||
|
||||
export const jazzServerAccount = await startWorker({
|
||||
syncServer: "wss://cloud.jazz.tools/?key=jazz-paper-scissors@garden.co ",
|
||||
});
|
||||
6
examples/server-worker-http/src/lib/utils.ts
Normal file
6
examples/server-worker-http/src/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));
|
||||
}
|
||||
94
examples/server-worker-http/src/schema.ts
Normal file
94
examples/server-worker-http/src/schema.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Account, Group, co, z } from "jazz-tools";
|
||||
|
||||
export const PlayerState = co.map({
|
||||
currentSelection: z.literal(["rock", "paper", "scissors"]).optional(),
|
||||
submitted: z.boolean(),
|
||||
resetRequested: z.boolean(),
|
||||
});
|
||||
export type PlayerState = co.loaded<typeof PlayerState>;
|
||||
|
||||
export const PlaySelection = co.map({
|
||||
value: z.literal(["rock", "paper", "scissors"]),
|
||||
group: Group,
|
||||
});
|
||||
export type PlaySelection = co.loaded<typeof PlaySelection>;
|
||||
|
||||
export const Player = co.map({
|
||||
account: co.account(),
|
||||
playSelection: PlaySelection.optional(),
|
||||
});
|
||||
export type Player = co.loaded<typeof Player>;
|
||||
|
||||
export const Game = co.map({
|
||||
player1: Player,
|
||||
player2: Player,
|
||||
player1State: PlayerState,
|
||||
player2State: PlayerState,
|
||||
outcome: z.literal(["player1", "player2", "draw"]).optional(),
|
||||
player1Score: z.number(),
|
||||
player2Score: z.number(),
|
||||
});
|
||||
export type Game = co.loaded<typeof Game>;
|
||||
|
||||
export const WaitingRoom = co.map({
|
||||
creator: co.account(),
|
||||
game: co.optional(Game),
|
||||
});
|
||||
export type WaitingRoom = co.loaded<typeof WaitingRoom>;
|
||||
|
||||
export function createGameState(params: {
|
||||
account1: Account;
|
||||
account2: Account;
|
||||
worker: Account;
|
||||
}) {
|
||||
const { account1, account2, worker } = params;
|
||||
const gameGroup = Group.create({ owner: worker });
|
||||
gameGroup.addMember(account1, "reader");
|
||||
gameGroup.addMember(account2, "reader");
|
||||
|
||||
const player1 = Player.create(
|
||||
{
|
||||
account: account1,
|
||||
},
|
||||
gameGroup,
|
||||
);
|
||||
|
||||
const player2 = Player.create(
|
||||
{
|
||||
account: account2,
|
||||
},
|
||||
gameGroup,
|
||||
);
|
||||
|
||||
const player1StateGroup = Group.create(worker);
|
||||
player1StateGroup.addMember(account1, "writer");
|
||||
|
||||
const player1State = PlayerState.create(
|
||||
{
|
||||
currentSelection: undefined,
|
||||
submitted: false,
|
||||
resetRequested: false,
|
||||
},
|
||||
player1StateGroup,
|
||||
);
|
||||
|
||||
const player2StateGroup = Group.create(worker);
|
||||
player2StateGroup.addMember(account2, "writer");
|
||||
|
||||
const player2State = PlayerState.create(
|
||||
{
|
||||
currentSelection: undefined,
|
||||
submitted: false,
|
||||
resetRequested: false,
|
||||
},
|
||||
player2StateGroup,
|
||||
);
|
||||
|
||||
return {
|
||||
player1,
|
||||
player2,
|
||||
player1State,
|
||||
player2State,
|
||||
outcome: undefined,
|
||||
};
|
||||
}
|
||||
125
examples/server-worker-http/src/serverApi.ts
Normal file
125
examples/server-worker-http/src/serverApi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { experimental_defineRequest, z } from "jazz-tools";
|
||||
import { Game, WaitingRoom } from "./schema";
|
||||
|
||||
const workerId = process.env.NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT!;
|
||||
|
||||
const createGameRequest = experimental_defineRequest({
|
||||
url: "/api/create-game",
|
||||
workerId,
|
||||
request: {},
|
||||
response: {
|
||||
schema: { waitingRoom: WaitingRoom },
|
||||
resolve: { waitingRoom: { creator: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const joinGameRequest = experimental_defineRequest({
|
||||
url: "/api/join-game",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
waitingRoom: WaitingRoom,
|
||||
},
|
||||
resolve: {
|
||||
waitingRoom: {
|
||||
creator: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
waitingRoom: WaitingRoom,
|
||||
},
|
||||
resolve: {
|
||||
waitingRoom: {
|
||||
game: {
|
||||
player1: {
|
||||
account: true,
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newGameRequest = experimental_defineRequest({
|
||||
url: "/api/new-game",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
game: Game,
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1: {
|
||||
account: true,
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
game: Game,
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1State: {
|
||||
$onError: null,
|
||||
},
|
||||
player2State: {
|
||||
$onError: null,
|
||||
},
|
||||
player1: true,
|
||||
player2: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const playRequest = experimental_defineRequest({
|
||||
url: "/api/play",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
game: Game,
|
||||
selection: z.literal(["rock", "paper", "scissors"]),
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
game: Game,
|
||||
},
|
||||
resolve: {
|
||||
game: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const serverApi = {
|
||||
createGame: createGameRequest,
|
||||
joinGame: joinGameRequest,
|
||||
newGame: newGameRequest,
|
||||
play: playRequest,
|
||||
};
|
||||
37
examples/server-worker-http/tests/play.spec.ts
Normal file
37
examples/server-worker-http/tests/play.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("start a new game and play", async ({ page: marioPage, browser }) => {
|
||||
await marioPage.goto("/");
|
||||
|
||||
await marioPage.getByRole("button", { name: /Start New Game/ }).click();
|
||||
|
||||
await expect(marioPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
|
||||
const url = await marioPage.url();
|
||||
|
||||
const luigiContext = await browser.newContext();
|
||||
const luigiPage = await luigiContext.newPage();
|
||||
await luigiPage.goto(url);
|
||||
|
||||
await expect(marioPage.getByText("Waiting for selection")).toBeVisible();
|
||||
await expect(luigiPage.getByText("Waiting for selection")).toBeVisible();
|
||||
await expect(luigiPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
|
||||
await marioPage.getByLabel("Select Rock").click();
|
||||
await marioPage.getByRole("button", { name: /Make your move!/i }).click();
|
||||
|
||||
await expect(
|
||||
luigiPage.getByText("The opponent has made their move"),
|
||||
).toBeVisible();
|
||||
|
||||
await luigiPage.getByLabel("Select Paper").click();
|
||||
await luigiPage.getByRole("button", { name: /Make your move!/i }).click();
|
||||
|
||||
await expect(luigiPage.getByText(/🎉 You Win! 🎉/)).toBeVisible();
|
||||
await expect(marioPage.getByText(/😔 You Lose! 😔/)).toBeVisible();
|
||||
|
||||
await marioPage.getByRole("button", { name: /Start New Game/ }).click();
|
||||
|
||||
await expect(marioPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
await expect(luigiPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
});
|
||||
27
examples/server-worker-http/tsconfig.json
Normal file
27
examples/server-worker-http/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
21
examples/server-worker-inbox/src/components/ui/input.tsx
Normal file
21
examples/server-worker-inbox/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
3
examples/server-worker-inbox/vercel.json
Normal file
3
examples/server-worker-inbox/vercel.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignoreCommand": "npx turbo-ignore"
|
||||
}
|
||||
@@ -41,7 +41,9 @@ export function Footer({
|
||||
</Link>
|
||||
</div>
|
||||
<p className="col-span-full sm:col-span-6 md:col-span-4 text-sm sm:text-base">
|
||||
Playful software for serious problems.
|
||||
Computers are magic.
|
||||
<br />
|
||||
Time to make them less complex.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-y-8 grid-cols-12">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChatDemoSection } from "@/components/home/ChatDemoSection";
|
||||
import { CollaborationFeaturesSection } from "@/components/home/CollaborationFeaturesSection";
|
||||
import { ComingSoonSection } from "@/components/home/ComingSoonSection";
|
||||
import { EarlyAdopterSection } from "@/components/home/EarlyAdopterSection";
|
||||
import { EncryptionSection } from "@/components/home/EncryptionSection";
|
||||
import { FeaturesSection } from "@/components/home/FeaturesSection";
|
||||
@@ -15,9 +14,9 @@ import { Testimonial } from "@garden-co/design-system/src/components/molecules/T
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
|
||||
<HeroSection />
|
||||
<div className="container flex flex-col gap-12 lg:gap-20">
|
||||
|
||||
<GetStartedSnippetSelect />
|
||||
<SupportedEnvironmentsSection />
|
||||
<HowJazzWorksSection />
|
||||
@@ -54,8 +53,6 @@ export default function Home() {
|
||||
|
||||
<FeaturesSection />
|
||||
|
||||
<ComingSoonSection />
|
||||
|
||||
<EarlyAdopterSection />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -431,16 +431,16 @@ const reactExamples: Example[] = [
|
||||
demoUrl: "https://music.demo.jazz.tools",
|
||||
illustration: <MusicIllustration />,
|
||||
},
|
||||
// {
|
||||
// name: "Jazz paper scissors",
|
||||
// slug: "jazz-paper-scissors",
|
||||
// description:
|
||||
// "A game that shows how to communicate with other accounts through the experimental Inbox API.",
|
||||
// tech: [tech.react],
|
||||
// features: [features.serverWorker, features.inbox],
|
||||
// illustration: <JazzPaperScissorsIllustration />,
|
||||
// demoUrl: "https://jazz-paper-scissors.demo.jazz.tools",
|
||||
// },
|
||||
{
|
||||
name: "Server-side HTTP requests",
|
||||
slug: "server-worker-http",
|
||||
description:
|
||||
"A game that shows how to manage state in a trusted environment through the experimental HTTP API.",
|
||||
tech: [tech.react],
|
||||
features: [features.serverWorker],
|
||||
illustration: <JazzPaperScissorsIllustration />,
|
||||
demoUrl: "https://jazz-paper-scissors.demo.jazz.tools",
|
||||
},
|
||||
{
|
||||
name: "Clerk",
|
||||
slug: "clerk",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import CoPlainTextDescription from "@/app/(others)/(home)/coValueDescriptions/coPlainTextDescription.mdx";
|
||||
import CursorsAndCaretsDescription from "@/app/(others)/(home)/toolkit/cursorsAndCarets.mdx";
|
||||
import TwoWaySyncDescription from "@/app/(others)/(home)/toolkit/twoWaySync.mdx";
|
||||
import VideoPresenceCallsDescription from "@/app/(others)/(home)/toolkit/videoPresenceCalls.mdx";
|
||||
import { CodeRef } from "@garden-co/design-system/src/components/atoms/CodeRef";
|
||||
import { P } from "@garden-co/design-system/src/components/atoms/Paragraph";
|
||||
import { FeatureCard } from "@garden-co/design-system/src/components/molecules/FeatureCard";
|
||||
import { GappedGrid } from "@garden-co/design-system/src/components/molecules/GappedGrid";
|
||||
import { Prose } from "@garden-co/design-system/src/components/molecules/Prose";
|
||||
import { SectionHeader } from "@garden-co/design-system/src/components/molecules/SectionHeader";
|
||||
|
||||
export function ComingSoonSection() {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="More features coming soon" />
|
||||
|
||||
<GappedGrid cols={4}>
|
||||
<FeatureCard className="p-4" label={<h3>Cursors & carets</h3>}>
|
||||
<P>Ready-made spatial presence.</P>
|
||||
<Prose size="sm">
|
||||
<CursorsAndCaretsDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard className="p-4" label={<h3>Two-way sync to your DB</h3>}>
|
||||
<P>Add Jazz to an existing app.</P>
|
||||
<Prose size="sm">
|
||||
<TwoWaySyncDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard className="p-4" label={<h3>Video presence & calls</h3>}>
|
||||
<P>Stream and record audio & video.</P>
|
||||
<Prose size="sm">
|
||||
<VideoPresenceCallsDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
className="p-4"
|
||||
label={
|
||||
<h3>
|
||||
<CodeRef>CoPlainText</CodeRef> & <CodeRef>CoRichText</CodeRef>
|
||||
</h3>
|
||||
}
|
||||
>
|
||||
<Prose size="sm">
|
||||
<CoPlainTextDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
</GappedGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,81 +16,70 @@ const features: Array<{
|
||||
title: string;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{
|
||||
title: "Instant updates",
|
||||
icon: "instant",
|
||||
},
|
||||
{
|
||||
title: "Real-time sync",
|
||||
icon: "devices",
|
||||
},
|
||||
{
|
||||
title: "Multiplayer",
|
||||
icon: "spatialPresence",
|
||||
},
|
||||
{
|
||||
title: "File uploads",
|
||||
icon: "upload",
|
||||
},
|
||||
{
|
||||
title: "Social features",
|
||||
icon: "social",
|
||||
},
|
||||
{
|
||||
title: "Permissions",
|
||||
icon: "permissions",
|
||||
},
|
||||
{
|
||||
title: "E2E encryption",
|
||||
icon: "encryption",
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
icon: "auth",
|
||||
},
|
||||
];
|
||||
{
|
||||
title: "Instant updates",
|
||||
icon: "instant",
|
||||
},
|
||||
{
|
||||
title: "Real-time sync",
|
||||
icon: "devices",
|
||||
},
|
||||
{
|
||||
title: "Multiplayer",
|
||||
icon: "spatialPresence",
|
||||
},
|
||||
{
|
||||
title: "File uploads",
|
||||
icon: "upload",
|
||||
},
|
||||
{
|
||||
title: "Social features",
|
||||
icon: "social",
|
||||
},
|
||||
{
|
||||
title: "Permissions",
|
||||
icon: "permissions",
|
||||
},
|
||||
{
|
||||
title: "E2E encryption",
|
||||
icon: "encryption",
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
icon: "auth",
|
||||
},
|
||||
];
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<div className="container grid items-center gap-x-8 gap-y-12 my-12 md:my-16 lg:my-24 lg:gap-x-10 lg:grid-cols-12">
|
||||
<div className="container grid items-center gap-x-8 gap-y-12 mt-12 md:mt-16 lg:mt-24 mb-12 lg:gap-x-10 lg:grid-cols-12">
|
||||
<div className="flex flex-col justify-center gap-5 lg:col-span-11 lg:gap-8">
|
||||
<Kicker>Toolkit for backendless apps</Kicker>
|
||||
<Kicker>Reactive, distributed, secure</Kicker>
|
||||
<H1>
|
||||
<span className="inline-block text-highlight">
|
||||
{marketingCopy.headline}
|
||||
</span>
|
||||
</H1>
|
||||
|
||||
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200">
|
||||
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200 prose-p:leading-normal">
|
||||
<p>
|
||||
Jazz gives you data without needing a database — plus auth,
|
||||
permissions, files and multiplayer without needing a backend.
|
||||
Jazz is a new kind of database that's distributed across your frontend, containers, serverless functions and its own storage cloud.
|
||||
</p>
|
||||
<p>It syncs structured data, files and LLM streams instantly.<br/>It looks like local reactive JSON state.</p>
|
||||
<p>And you get auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.</p>
|
||||
<p>
|
||||
Do everything right from the frontend and ship better apps, faster.
|
||||
This lets you get rid of 90% of the traditional backend, and most of your frontend state juggling.
|
||||
You'll ship better apps, faster.
|
||||
</p>
|
||||
<p>
|
||||
Open source. Self-host or use{" "}
|
||||
<p className="text-base">
|
||||
Self-host or use{" "}
|
||||
<Link className="text-reset" href="/cloud">
|
||||
Jazz Cloud
|
||||
</Link>{" "}
|
||||
for zero-config magic.
|
||||
for a zero-deploy globally-scaled DB.
|
||||
<br/>Open source (MIT)
|
||||
</p>
|
||||
</Prose>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 max-w-3xl sm:grid-cols-4 sm:gap-4">
|
||||
{features.map(({ title, icon }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="flex text-xs sm:text-sm gap-2 items-center"
|
||||
>
|
||||
<span className="p-1.5 rounded-lg bg-primary-transparent">
|
||||
<Icon size="xs" name={icon} intent="primary" />
|
||||
</span>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,7 @@ export function HowJazzWorksSection() {
|
||||
<div className="grid gap-3">
|
||||
<Kicker>How it works</Kicker>
|
||||
|
||||
<H2>Build entire apps using only client-side code</H2>
|
||||
<H2>Build entire apps with collaborative state</H2>
|
||||
</div>
|
||||
<GappedGrid>
|
||||
<Step
|
||||
|
||||
@@ -53,7 +53,7 @@ export function LocalFirstFeaturesSection() {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="The best of all worlds"
|
||||
title="Local-first state with global sync"
|
||||
slogan={
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function ProblemStatementSection() {
|
||||
<div className="grid gap-4 lg:gap-8">
|
||||
<SectionHeader
|
||||
className="sm:text-center sm:mx-auto"
|
||||
title={"Powered by the first “flat stack”"}
|
||||
title={"A database that does what's actually needed"}
|
||||
slogan="A perspective shift worth 10,000 hours"
|
||||
/>
|
||||
|
||||
@@ -41,8 +41,7 @@ export default function ProblemStatementSection() {
|
||||
<Prose>
|
||||
<p>
|
||||
For each new app you tackle a{" "}
|
||||
<strong>mess of moving parts and infra worries.</strong> Or, you
|
||||
haven't even tried because "you're not full-stack".
|
||||
<strong>mess of moving parts and infra worries.</strong> Your backend is responsible for shuffling data around in a myriad of ways.
|
||||
</p>
|
||||
<p>
|
||||
Want to build a <strong>modern app</strong> with multiplayer or
|
||||
@@ -68,7 +67,7 @@ export default function ProblemStatementSection() {
|
||||
<strong>With users & permissions built-in.</strong>
|
||||
</p>
|
||||
<p>
|
||||
With completely <strong>app-independent infra,</strong> you get to
|
||||
With a <strong>DB and infra made for modern apps</strong> you get to
|
||||
focus on <strong>building the app your users want.</strong> You'll
|
||||
notice that <strong>90% of the work is now the UI.</strong>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BunLogo } from "@/components/icons/BunLogo";
|
||||
import { CloudflareWorkerLogo } from "@/components/icons/CloudflareWorkerLogo";
|
||||
import { VercelLogo } from "@/components/icons/VercelLogo";
|
||||
import { ExpoLogo } from "@/components/icons/ExpoLogo";
|
||||
import { JavascriptLogo } from "@/components/icons/JavascriptLogo";
|
||||
import { NodejsLogo } from "@/components/icons/NodejsLogo";
|
||||
@@ -44,14 +45,18 @@ const serverWorkers = [
|
||||
icon: NodejsLogo,
|
||||
href: "/docs/react/server-workers",
|
||||
},
|
||||
{
|
||||
name: "Cloudflare Workers",
|
||||
icon: CloudflareWorkerLogo,
|
||||
},
|
||||
{
|
||||
name: "Bun",
|
||||
icon: BunLogo,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
icon: VercelLogo,
|
||||
},
|
||||
{
|
||||
name: "CF Workers",
|
||||
icon: CloudflareWorkerLogo,
|
||||
}
|
||||
];
|
||||
|
||||
export function SupportedEnvironmentsSection() {
|
||||
|
||||
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export function VercelLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="1.5em"
|
||||
height="1.5em"
|
||||
viewBox="0 0 76 65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function AuthStateIndicator() {
|
||||
const isGuest = agent._type !== "Account"
|
||||
|
||||
// Anonymous authentication: has an account but not fully authenticated
|
||||
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
||||
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
||||
return (
|
||||
<div>
|
||||
{isGuest && <span>Guest Mode</span>}
|
||||
@@ -94,7 +94,7 @@ const MusicaAccountRoot = co.map({
|
||||
|
||||
const MusicaAccount = co.account({
|
||||
root: MusicaAccountRoot,
|
||||
profile: co.profile({}),
|
||||
profile: co.profile(),
|
||||
});
|
||||
type MusicaAccount = co.loaded<typeof MusicaAccount>
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ export function CreateOrder() {
|
||||
|
||||
In a `BubbleTeaOrder`, the `name` field is required, so it would be a good idea to validate this before turning the draft into a real order.
|
||||
|
||||
Update the schema to include a `validate` helper.
|
||||
Update the schema to include a `validateDraftOrder` helper.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
@@ -253,17 +253,17 @@ import { co, z } from "jazz-tools";
|
||||
// schema.ts
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({ // [!code ++:11]
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
});
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:9]
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -282,17 +282,17 @@ export const BubbleTeaOrder = co.map({
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
});
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -340,7 +340,7 @@ export function CreateOrder() {
|
||||
e.preventDefault();
|
||||
if (!draft) return;
|
||||
|
||||
const validation = DraftBubbleTeaOrder.validate(draft); // [!code ++:5]
|
||||
const validation = validateDraftOrder(draft); // [!code ++:5]
|
||||
if (validation.errors.length > 0) {
|
||||
console.log(validation.errors);
|
||||
return;
|
||||
@@ -455,17 +455,17 @@ export const BubbleTeaOrder = co.map({
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
});
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -485,7 +485,7 @@ export const JazzAccount = co.account({
|
||||
// @filename: CreateOrder.tsx
|
||||
import * as React from "react";
|
||||
import { useCoState, useAccount } from "jazz-tools/react";
|
||||
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount } from "schema";
|
||||
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount, validateDraftOrder } from "schema";
|
||||
import { co } from "jazz-tools";
|
||||
|
||||
export function OrderForm({
|
||||
@@ -527,7 +527,7 @@ export function CreateOrder() {
|
||||
const draft = me.root.draft; // [!code ++:2]
|
||||
if (!draft) return;
|
||||
|
||||
const validation = DraftBubbleTeaOrder.validate(draft);
|
||||
const validation = validateDraftOrder(draft);
|
||||
if (validation.errors.length > 0) {
|
||||
console.log(validation.errors);
|
||||
return;
|
||||
@@ -579,21 +579,21 @@ import { co, z } from "jazz-tools";
|
||||
// schema.ts
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
});
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Plese enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
},
|
||||
}));
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -611,21 +611,21 @@ export const BubbleTeaOrder = co.map({
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
});
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Plese enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
},
|
||||
}));
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -649,7 +649,7 @@ export function DraftIndicator() {
|
||||
resolve: { root: { draft: true } },
|
||||
});
|
||||
|
||||
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
|
||||
if (hasChanges(me?.root.draft)) {
|
||||
return (
|
||||
<p>You have a draft</p>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Organization = co.map({
|
||||
name: z.string(),
|
||||
|
||||
// shared data between users of each organization
|
||||
projects: co.list(Project),
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
export const ListOfOrganizations = co.list(Organization);
|
||||
@@ -67,7 +67,7 @@ export const JazzAccountRoot = co.map({
|
||||
export const JazzAccount = co
|
||||
.account({
|
||||
root: JazzAccountRoot,
|
||||
profile: co.profile({}),
|
||||
profile: co.profile(),
|
||||
})
|
||||
.withMigration((account) => {
|
||||
if (account.root === undefined) {
|
||||
@@ -115,7 +115,7 @@ import * as React from "react";
|
||||
import { useAcceptInvite, useAccount } from "jazz-tools/react";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Project = z.object({
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ const JazzAccountRoot = co.map({
|
||||
|
||||
const JazzAccount = co.account({
|
||||
root: JazzAccountRoot,
|
||||
profile: co.profile({}),
|
||||
profile: co.profile(),
|
||||
});
|
||||
|
||||
// ---cut---
|
||||
|
||||
@@ -47,11 +47,6 @@ export const docNavigationItems = [
|
||||
href: "/docs/sync-and-storage",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Node.JS / server workers",
|
||||
href: "/docs/project-setup/server-side",
|
||||
done: 80,
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
href: "/docs/project-setup/providers",
|
||||
@@ -84,11 +79,41 @@ export const docNavigationItems = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Server-side",
|
||||
items: [
|
||||
{
|
||||
name: "Setup",
|
||||
href: "/docs/server-side/setup",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Communicating with workers",
|
||||
href: "/docs/server-side/communicating-with-workers",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "HTTP requests",
|
||||
href: "/docs/server-side/http-requests",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Inbox",
|
||||
href: "/docs/server-side/inbox",
|
||||
done: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Upgrade guides",
|
||||
// collapse: true,
|
||||
prefix: "/docs/upgrade",
|
||||
items: [
|
||||
{
|
||||
name: "0.16.0 - Cleaner separation between Zod and CoValue schemas",
|
||||
href: "/docs/upgrade/0-16-0",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "0.15.0 - Everything inside `jazz-tools`",
|
||||
href: "/docs/upgrade/0-15-0",
|
||||
|
||||
@@ -7,9 +7,11 @@ export const metadata = {
|
||||
|
||||
# Learn some <span className="sr-only">Jazz</span> <JazzLogo className="h-[41px] -ml-0.5 -mt-[3px] inline" />
|
||||
|
||||
**Jazz is a toolkit for building backendless apps**. You get data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Jazz lets you do everything right from the frontend and you'll ship better apps, faster.
|
||||
**Jazz is a new kind of database** that's **distributed** across your frontend, containers, serverless functions and its own storage cloud.
|
||||
|
||||
Instead of wrestling with databases, APIs, and server infrastructure, you work with **CoValues** ("collaborative values") — your new cloud-synced building blocks that feel like local state but automatically sync across all devices and users in real-time.
|
||||
It syncs structured data, files and LLM streams instantly, and looks like local reactive JSON state.
|
||||
|
||||
It also provides auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,7 +21,7 @@ You can use [`create-jazz-app`](/docs/tools/create-jazz-app) to create a new Jaz
|
||||
|
||||
<CodeGroup>
|
||||
```sh
|
||||
npx create-jazz-app@latest --api-key you@example.com
|
||||
npx create-jazz-app@latest --api-key you@example.com
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -30,21 +32,10 @@ Or you can follow this [React step-by-step guide](/docs/react/guide) where we wa
|
||||
|
||||
</ContentByFramework> */}
|
||||
|
||||
## Why Jazz is different
|
||||
|
||||
Most apps rebuild the same thing: shared state that syncs between users and devices. Jazz starts from that shared state, giving you:
|
||||
|
||||
- **No backend required** — Focus on building features, not infrastructure
|
||||
- **Real-time sync** — Changes appear everywhere immediately
|
||||
- **Multiplayer by default** — Collaboration just works
|
||||
- **Local-first** — Your app works offline and feels instant
|
||||
|
||||
Think Figma, Notion, or Linear — but you don't need years to build a custom stack.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Define your data** with CoValues schemas
|
||||
2. **Connect to sync infrastructure** (Jazz Cloud or self-hosted)
|
||||
2. **Connect to storage infrastructure** (Jazz Cloud or self-hosted)
|
||||
3. **Create and edit CoValues** like normal objects
|
||||
4. **Get automatic sync and persistence** across all devices and users
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
export const metadata = {
|
||||
description: "Use Jazz server-side through Server Workers which act like Jazz accounts."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Node.JS / server workers
|
||||
|
||||
The main detail to understand when using Jazz server-side is that Server Workers have Jazz `Accounts`, just like normal users do.
|
||||
|
||||
This lets you share CoValues with Server Workers, having precise access control by adding the Worker to `Groups` with specific roles just like you would with other users.
|
||||
|
||||
[See the full example here.](https://github.com/garden-co/jazz/tree/main/examples/jazz-paper-scissors)
|
||||
|
||||
<Alert variant="info" className="mt-4 flex gap-2 items-center">Requires at least Node.js v20.</Alert>
|
||||
|
||||
## Generating credentials
|
||||
|
||||
Server Workers typically have static credentials, consisting of a public Account ID and a private Account Secret.
|
||||
|
||||
To generate new credentials for a Server Worker, you can run:
|
||||
|
||||
<CodeGroup>
|
||||
```sh
|
||||
npx jazz-run account create --name "My Server Worker"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The name will be put in the public profile of the Server Worker's `Account`, which can be helpful when inspecting metadata of CoValue edits that the Server Worker has done.
|
||||
|
||||
## Storing & providing credentials
|
||||
|
||||
Server Worker credentials are typically stored and provided as environmental variables.
|
||||
|
||||
**Take extra care with the Account Secret — handle it like any other secret environment variable such as a DB password.**
|
||||
|
||||
## Starting a server worker
|
||||
|
||||
You can use `startWorker` from `jazz-nodejs` to start a Server Worker. Similarly to setting up a client-side Jazz context, it:
|
||||
|
||||
- takes a custom `AccountSchema` if you have one (for example, because the worker needs to store information in it's private account root)
|
||||
- takes a URL for a sync & storage server
|
||||
|
||||
`startWorker` expects credentials in the `JAZZ_WORKER_ACCOUNT` and `JAZZ_WORKER_SECRET` environment variables by default (as printed by `npx account create ...`), but you can also pass them manually as `accountID` and `accountSecret` parameters if you get them from elsewhere.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co } from "jazz-tools";
|
||||
const MyWorkerAccount = co.account();
|
||||
type MyWorkerAccount = co.loaded<typeof MyWorkerAccount>;
|
||||
// ---cut---
|
||||
import { startWorker } from 'jazz-tools/worker';
|
||||
|
||||
const { worker } = await startWorker({
|
||||
AccountSchema: MyWorkerAccount,
|
||||
syncServer: 'wss://cloud.jazz.tools/?key=you@example.com',
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`worker` acts like `me` (as returned by `useAccount` on the client) - you can use it to:
|
||||
|
||||
- load/subscribe to CoValues: `MyCoValue.subscribe(id, worker, {...})`
|
||||
- create CoValues & Groups `const val = MyCoValue.create({...}, { owner: worker })`
|
||||
|
||||
## 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.
|
||||
|
||||
What's less clear is how you can trigger this work to happen.
|
||||
|
||||
- One option is to define traditional HTTP API handlers that use the Jazz Worker internally. This is helpful if you need to mutate Jazz state in response to HTTP requests such as for webhooks or non-Jazz API clients
|
||||
- The other option is to have the Jazz Worker subscribe to CoValues which they will then collaborate on with clients.
|
||||
- A common pattern is to implement a state machine represented by a CoValue, where the client will do some state transitions (such as `draft -> ready`), which the worker will notice and then do some work in response, feeding the result back in a further state transition (such as `ready -> success & data`, or `ready -> failure & error details`).
|
||||
- This way, client and worker don't have to explicitly know about each other or communicate directly, but can rely on Jazz as a communication mechanism - with computation progressing in a distributed manner wherever and whenever possible.
|
||||
@@ -53,9 +53,7 @@ import { co, z } from "jazz-tools";
|
||||
// ...somewhere in jazz-tools itself...
|
||||
const Account = co.account({
|
||||
root: co.map({}),
|
||||
profile: co.profile({
|
||||
name: z.string(),
|
||||
}),
|
||||
profile: co.profile(),
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -91,7 +89,7 @@ export const MyAppRoot = co.map({
|
||||
|
||||
export const MyAppProfile = co.profile({ // [!code ++:4]
|
||||
name: z.string(), // compatible with default Profile schema
|
||||
avatar: z.optional(co.image()),
|
||||
avatar: co.optional(co.image()),
|
||||
});
|
||||
|
||||
export const MyAppAccount = co.account({
|
||||
@@ -243,7 +241,7 @@ const MyAppProfile = co.profile({
|
||||
// ---cut---
|
||||
const MyAppRoot = co.map({
|
||||
myChats: co.list(Chat),
|
||||
myBookmarks: z.optional(co.list(Bookmark)), // [!code ++:1]
|
||||
myBookmarks: co.optional(co.list(Bookmark)), // [!code ++:1]
|
||||
});
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user