Compare commits
167 Commits
jazz-run@0
...
jazz-run@0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225bc1f63f | ||
|
|
5d94564f99 | ||
|
|
9633d0187f | ||
|
|
b82ecaa3ca | ||
|
|
111ec8d351 | ||
|
|
071128339b | ||
|
|
688ced499d | ||
|
|
ac91c8e7c2 | ||
|
|
c3b303c310 | ||
|
|
937284f7e9 | ||
|
|
e999727c70 | ||
|
|
2197766624 | ||
|
|
4d4fd0beaa | ||
|
|
7361854ee4 | ||
|
|
4a775fada3 | ||
|
|
3fe53a3a4a | ||
|
|
fe37516786 | ||
|
|
4beafb7cf3 | ||
|
|
82a592e08a | ||
|
|
4c6926153a | ||
|
|
c51b088243 | ||
|
|
867cb6b7a5 | ||
|
|
3a1fdd7600 | ||
|
|
3fdbb43b54 | ||
|
|
02969ee89b | ||
|
|
9b4988a514 | ||
|
|
8aa4b59d49 | ||
|
|
0401fcf2a8 | ||
|
|
139a649279 | ||
|
|
9acccb5df2 | ||
|
|
fd90cdb49a | ||
|
|
4a29999c6a | ||
|
|
df487d5335 | ||
|
|
1efe84c691 | ||
|
|
73b99c6c1a | ||
|
|
039b92c839 | ||
|
|
618af5f1e3 | ||
|
|
65eee0ef01 | ||
|
|
063553090e | ||
|
|
97f6bcedbd | ||
|
|
7c63e6bb0f | ||
|
|
08aedcf517 | ||
|
|
3e12ee127f | ||
|
|
96f743b2f4 | ||
|
|
6dffe73bd2 | ||
|
|
6f9ee31179 | ||
|
|
52f324ffc4 | ||
|
|
2d86f53575 | ||
|
|
68cb357a94 | ||
|
|
56ccf9ab9d | ||
|
|
4f7bc91502 | ||
|
|
f61a120560 | ||
|
|
2f1307a0ba | ||
|
|
fa15ea56d1 | ||
|
|
1e58ecb3ac | ||
|
|
ceeabfaf89 | ||
|
|
70ce7c5736 | ||
|
|
be46042cdc | ||
|
|
6afdb16739 | ||
|
|
b0b2b85a6f | ||
|
|
28c19c134f | ||
|
|
0924c9baaa | ||
|
|
8bfaa0a18b | ||
|
|
b2712e18a2 | ||
|
|
66894b63d7 | ||
|
|
147be76399 | ||
|
|
36770bed52 | ||
|
|
b1a05143e3 | ||
|
|
fb761ce66d | ||
|
|
07a6c340dc | ||
|
|
8b4261f7d8 | ||
|
|
0fea904dd0 | ||
|
|
373aef313f | ||
|
|
a584590ed8 | ||
|
|
0a830e29a9 | ||
|
|
dc183a19b2 | ||
|
|
fef55a4cd6 | ||
|
|
ddef54048f | ||
|
|
8aa2d2a789 | ||
|
|
a39d009b87 | ||
|
|
efff4d0f4f | ||
|
|
ea2b01d8a2 | ||
|
|
f01dab5c8f | ||
|
|
a7f6870048 | ||
|
|
3b294f6994 | ||
|
|
55cb83e6e0 | ||
|
|
6290088fec | ||
|
|
b9c17b37db | ||
|
|
6c76ff8fbf | ||
|
|
3c6a2a6092 | ||
|
|
e8a950e61a | ||
|
|
e2cbf035de | ||
|
|
47599b6307 | ||
|
|
901d0762ee | ||
|
|
d1c1b0c5cc | ||
|
|
cf4ad7285d | ||
|
|
255a947ea6 | ||
|
|
530a263d35 | ||
|
|
2983c7bd58 | ||
|
|
745020b7a8 | ||
|
|
ab6328f767 | ||
|
|
e0555debde | ||
|
|
247f4556e7 | ||
|
|
7903c737f4 | ||
|
|
6145da5525 | ||
|
|
fc0a2e77a3 | ||
|
|
334fbbbb7f | ||
|
|
eaac1e6580 | ||
|
|
991aebf7a7 | ||
|
|
cbc3f0cc65 | ||
|
|
29c487e288 | ||
|
|
0b0590a364 | ||
|
|
1eb01997d8 | ||
|
|
0dc8d511a1 | ||
|
|
9b75880b10 | ||
|
|
98fe72ed42 | ||
|
|
1f5c81c2ea | ||
|
|
fc41aa165b | ||
|
|
2b043abffa | ||
|
|
e136e1b696 | ||
|
|
2475a46578 | ||
|
|
41466ea399 | ||
|
|
44f653a64b | ||
|
|
f8437042a6 | ||
|
|
3b91594d10 | ||
|
|
db23582b4c | ||
|
|
4b0b6d8a69 | ||
|
|
d450b394fa | ||
|
|
0abc96e400 | ||
|
|
7562354b29 | ||
|
|
6c085a3919 | ||
|
|
6afff848bc | ||
|
|
47059845cc | ||
|
|
a1735a8232 | ||
|
|
1f5750d8c4 | ||
|
|
f756ce26b5 | ||
|
|
84f5bdda74 | ||
|
|
ee7aefa97c | ||
|
|
b0895981ba | ||
|
|
94f636b2ee | ||
|
|
331ab070f6 | ||
|
|
13e73adfb9 | ||
|
|
265a6405af | ||
|
|
f1552b8262 | ||
|
|
6826ad8e45 | ||
|
|
7c1b757b62 | ||
|
|
326e1734a4 | ||
|
|
0cf027c91b | ||
|
|
e358881b76 | ||
|
|
cee8010918 | ||
|
|
cc877139ef | ||
|
|
56a9b89538 | ||
|
|
199c463e28 | ||
|
|
50e523d19c | ||
|
|
796ea24288 | ||
|
|
c8be86e823 | ||
|
|
41b7054aab | ||
|
|
101adcd024 | ||
|
|
a2854aeec9 | ||
|
|
bdc9aee689 | ||
|
|
2f53ae0ab8 | ||
|
|
f76c05448c | ||
|
|
585e7e8177 | ||
|
|
82d8d1d873 | ||
|
|
2c523c86ff | ||
|
|
6616668d4a | ||
|
|
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,50 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 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
|
||||
|
||||
- Updated dependencies [bdc9aee]
|
||||
- jazz-tools@0.15.11
|
||||
|
||||
## 0.0.99
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.99",
|
||||
"version": "0.0.105",
|
||||
"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 type Message = co.loaded<typeof Message>;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { co, z } from "jazz-tools";
|
||||
import { co } from "jazz-tools";
|
||||
|
||||
export const JazzProfile = co.profile({
|
||||
file: z.optional(co.fileStream()),
|
||||
file: co.optional(co.fileStream()),
|
||||
});
|
||||
|
||||
export const JazzAccount = co.account({
|
||||
|
||||
@@ -28,16 +28,16 @@ export const BubbleTeaOrder = co.map({
|
||||
addOns: ListOfBubbleTeaAddOns,
|
||||
deliveryDate: z.date(),
|
||||
withMilk: z.boolean(),
|
||||
instructions: z.optional(co.plainText()),
|
||||
instructions: co.optional(co.plainText()),
|
||||
});
|
||||
|
||||
export const DraftBubbleTeaOrder = co
|
||||
.map({
|
||||
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
|
||||
addOns: z.optional(ListOfBubbleTeaAddOns),
|
||||
addOns: co.optional(ListOfBubbleTeaAddOns),
|
||||
deliveryDate: z.optional(z.date()),
|
||||
withMilk: z.optional(z.boolean()),
|
||||
instructions: z.optional(co.plainText()),
|
||||
instructions: co.optional(co.plainText()),
|
||||
})
|
||||
.withHelpers((Self) => ({
|
||||
hasChanges(order: Loaded<typeof Self> | undefined) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { co, z } from "jazz-tools";
|
||||
import { co } from "jazz-tools";
|
||||
|
||||
export const JazzProfile = co.profile({
|
||||
image: z.optional(co.image()),
|
||||
image: co.optional(co.image()),
|
||||
});
|
||||
|
||||
export const JazzAccount = co.account({
|
||||
|
||||
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}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const MusicaAccountRoot = co.map({
|
||||
// track and playlist
|
||||
// You can also add the position in time if you want make it possible
|
||||
// to resume the song
|
||||
activeTrack: z.optional(MusicTrack),
|
||||
activeTrack: co.optional(MusicTrack),
|
||||
activePlaylist: Playlist,
|
||||
|
||||
exampleDataLoaded: z.optional(z.boolean()),
|
||||
|
||||
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();
|
||||
7
examples/server-worker-http/next.config.ts
Normal file
7
examples/server-worker-http/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
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, Player, PlayerState, 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: z.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 };
|
||||
@@ -8,7 +8,7 @@ export type Player = co.loaded<typeof Player>;
|
||||
|
||||
export const Game = co.map({
|
||||
player1: Player,
|
||||
player2: z.optional(Player),
|
||||
player2: co.optional(Player),
|
||||
outcome: z.optional(z.literal(["player1", "player2", "draw"])),
|
||||
player1Score: z.number(),
|
||||
player2Score: z.number(),
|
||||
@@ -17,8 +17,8 @@ export type Game = co.loaded<typeof Game>;
|
||||
|
||||
export const WaitingRoom = co.map({
|
||||
account1: co.account(),
|
||||
account2: z.optional(co.account()),
|
||||
game: z.optional(Game),
|
||||
account2: co.optional(co.account()),
|
||||
game: co.optional(Game),
|
||||
});
|
||||
export type WaitingRoom = co.loaded<typeof WaitingRoom>;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const JoinGameRequest = co.map({
|
||||
});
|
||||
export type JoinGameRequest = co.loaded<typeof JoinGameRequest>;
|
||||
|
||||
export const InboxMessage = z.discriminatedUnion("type", [
|
||||
export const InboxMessage = co.discriminatedUnion("type", [
|
||||
PlayIntent,
|
||||
NewGameIntent,
|
||||
CreateGameRequest,
|
||||
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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -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,6 +79,31 @@ 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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -202,7 +202,7 @@ See the corresponding sections for [creating](/docs/using-covalues/filestreams#c
|
||||
|
||||
### Unions of CoMaps (declaration)
|
||||
|
||||
You can declare unions of CoMaps that have discriminating fields, using `z.discriminatedUnion()`.
|
||||
You can declare unions of CoMaps that have discriminating fields, using `co.discriminatedUnion()`.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
@@ -220,7 +220,7 @@ const SliderWidget = co.map({
|
||||
max: z.number(),
|
||||
});
|
||||
|
||||
const WidgetUnion = z.discriminatedUnion("type", [ButtonWidget, SliderWidget]);
|
||||
const WidgetUnion = co.discriminatedUnion("type", [ButtonWidget, SliderWidget]);
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -321,7 +321,7 @@ const Company = co.map({
|
||||
|
||||
#### Optional References
|
||||
|
||||
You can make references optional with `z.optional()`:
|
||||
You can make references optional with `co.optional()`:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
@@ -331,7 +331,7 @@ const Pet = co.map({
|
||||
});
|
||||
// ---cut---
|
||||
const Person = co.map({
|
||||
pet: z.optional(Pet),
|
||||
pet: co.optional(Pet),
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -372,7 +372,7 @@ const ListOfPeople = co.list(Person);
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Note: similarly, if you use modifiers like `z.optional()` you'll need to help TypeScript along:
|
||||
Note: similarly, if you use modifiers like `co.optional()` you'll need to help TypeScript along:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
@@ -381,7 +381,7 @@ import { co, z } from "jazz-tools";
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get bestFriend(): z.ZodOptional<typeof Person> {
|
||||
return z.optional(Person);
|
||||
return co.optional(Person);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const metadata = {
|
||||
description: "How to send data to Server Workers, set permissions and subscriptions."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Communicating with Server Workers
|
||||
|
||||
Server Workers in Jazz can receive data from clients through two different APIs, each with their own characteristics and use cases.
|
||||
This guide covers the key properties of each approach to help you choose the right one for your application.
|
||||
|
||||
## Overview
|
||||
|
||||
Jazz provides two ways to communicate with Server Workers:
|
||||
|
||||
1. **HTTP Requests** - The easiest to work with and deploy, uses standard Request/Response objects
|
||||
2. **Inbox** - Fully built using the Jazz data model with offline support
|
||||
|
||||
## HTTP Requests (Recommended)
|
||||
|
||||
HTTP requests are the most straightforward way to communicate with Server Workers. They work well with any framework or runtime that supports standard Request and Response objects, can be scaled horizontally, and put clients and workers in direct communication.
|
||||
|
||||
### When to use HTTP Requests
|
||||
|
||||
Use HTTP requests when you need immediate responses, are deploying to serverless environments, need horizontal scaling, or are working with standard web frameworks.
|
||||
|
||||
It's also a good solution when using full-stack frameworks like Next.js, where you can use the API routes to handle the server-side logic.
|
||||
|
||||
[Learn more about HTTP Requests →](/docs/server-side/http-requests)
|
||||
|
||||
## Inbox
|
||||
|
||||
The Inbox API is fully built using the Jazz data model and provides offline support. Requests and responses are synced as soon as the device becomes online, but require the Worker to always be online to work properly.
|
||||
|
||||
### When to use Inbox
|
||||
|
||||
Use Inbox when you need offline support, want to leverage the Jazz data model, can ensure the worker stays online, need persistent message storage, or want to review message history.
|
||||
|
||||
It works great when you don't want to expose your server with a public address, because it uses Jazz's sync to make the communication happen.
|
||||
|
||||
Since Jazz handles all the network communication, the entire class of network errors that usually come with traditional HTTP requests are not a problem when
|
||||
using the Inbox API.
|
||||
|
||||
[Learn more about Inbox →](/docs/server-side/inbox)
|
||||
264
homepage/homepage/content/docs/server-side/http-requests.mdx
Normal file
264
homepage/homepage/content/docs/server-side/http-requests.mdx
Normal file
@@ -0,0 +1,264 @@
|
||||
export const metadata = {
|
||||
description: "How to use HTTP requests to communicate with Server Workers using experimental_defineRequest."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# HTTP Requests with Server Workers
|
||||
|
||||
HTTP requests are the easiest way to communicate with Server Workers in Jazz. They work well with any framework or runtime that supports standard Request and Response objects, can be scaled horizontally, and put clients and workers in direct communication.
|
||||
|
||||
## Setting up HTTP requests
|
||||
|
||||
### Defining request schemas
|
||||
|
||||
Use `experimental_defineRequest` to define your API schema:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { experimental_defineRequest, z } from "jazz-tools";
|
||||
import { Event, Ticket } from "./schema";
|
||||
|
||||
const workerId = process.env.NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT!;
|
||||
|
||||
export const bookEventTicket = experimental_defineRequest({
|
||||
url: "/api/book-event-ticket",
|
||||
// The id of the worker Account or Group
|
||||
workerId,
|
||||
// The schema definition of the data we send to the server
|
||||
request: {
|
||||
schema: {
|
||||
event: Event,
|
||||
},
|
||||
// The data that will be considered as "loaded" in the server input
|
||||
resolve: {
|
||||
event: { reservations: true },
|
||||
},
|
||||
},
|
||||
// The schema definition of the data we expect to receive from the server
|
||||
response: {
|
||||
schema: { ticket: Ticket },
|
||||
// The data that will be considered as "loaded" in the client response
|
||||
// It defines the content that the server directly sends to the client, without involving the sync server
|
||||
resolve: { ticket: true },
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Setting up the Server Worker
|
||||
|
||||
We need to start a Server Worker instance that will be able to sync data with the sync server, and handle the requests.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { startWorker } from "jazz-tools/worker";
|
||||
|
||||
export const jazzServer = await startWorker({
|
||||
syncServer: "wss://cloud.jazz.tools/?key=your-api-key",
|
||||
accountID: process.env.JAZZ_WORKER_ACCOUNT,
|
||||
accountSecret: process.env.JAZZ_WORKER_SECRET,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Handling requests on the server
|
||||
|
||||
### Creating API routes
|
||||
|
||||
Create API routes to handle the defined requests. Here's an example using Next.js API routes:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { jazzServer } from "@/jazzServer";
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return bookEventTicket.handle(
|
||||
request,
|
||||
jazzServer.worker,
|
||||
async ({ event }, madeBy) => {
|
||||
const ticketGroup = Group.create(jazzServer.worker);
|
||||
const ticket = Ticket.create({
|
||||
account: madeBy,
|
||||
event,
|
||||
});
|
||||
|
||||
// Give access to the ticket to the client
|
||||
ticketGroup.addMember(madeBy, "reader");
|
||||
|
||||
event.reservations.push(ticket);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Making requests from the client
|
||||
|
||||
### Using the defined API
|
||||
|
||||
Make requests from the client using the defined API:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { isJazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function sendEventBookingRequest(event: Event) {
|
||||
const { ticket } = await bookEventTicket.send({ event });
|
||||
|
||||
return ticket;
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Error handling
|
||||
|
||||
### Server-side error handling
|
||||
|
||||
Use `JazzRequestError` to return proper HTTP error responses:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { jazzServer } from "@/jazzServer";
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return bookEventTicket.handle(
|
||||
request,
|
||||
jazzServer.worker,
|
||||
async ({ event }, madeBy) => {
|
||||
// Check if the event is full
|
||||
if (event.reservations.length >= event.capacity) {
|
||||
// The JazzRequestError is propagated to the client, use it for any validation errors
|
||||
throw new JazzRequestError("Event is full", 400);
|
||||
}
|
||||
|
||||
const ticketGroup = Group.create(jazzServer.worker);
|
||||
const ticket = Ticket.create({
|
||||
account: madeBy,
|
||||
event,
|
||||
});
|
||||
|
||||
// Give access to the ticket to the client
|
||||
ticketGroup.addMember(madeBy, "reader");
|
||||
|
||||
event.reservations.push(ticket);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Alert variant="info" className="mt-4">
|
||||
**Note**: To ensure that the limit is correctly enforced, the handler should be deployed in a single worker instance (e.g. a single Cloudflare DurableObject).
|
||||
|
||||
Details on how to deploy a single instance Worker are available in the [Deployments & Transactionality](#deployments--transactionality) section.
|
||||
</Alert>
|
||||
|
||||
### Client-side error handling
|
||||
|
||||
Handle errors on the client side:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { isJazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function sendEventBookingRequest(event: Event) {
|
||||
try {
|
||||
const { ticket } = await bookEventTicket.send({ event });
|
||||
|
||||
return ticket;
|
||||
} catch (error) {
|
||||
// This works as a type guard, so you can easily get the error message and details
|
||||
if (isJazzRequestError(error)) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Alert variant="info" className="mt-4">
|
||||
**Note**: The `experimental_defineRequest` API is still experimental and may change in future versions. For production applications, consider the stability implications.
|
||||
</Alert>
|
||||
|
||||
## Security safeguards provided by Jazz
|
||||
|
||||
Jazz HTTP requests include several built-in security measures to protect against common attacks:
|
||||
|
||||
### Cryptographic Authentication
|
||||
- **Digital Signatures**: Each request is cryptographically signed using the sender's private key
|
||||
- **Signature Verification**: The server verifies the signature using the sender's public key to ensure message authenticity and to identify the sender account
|
||||
- **Tamper Protection**: Any modification to the request payload will invalidate the signature
|
||||
|
||||
### Replay Attack Prevention
|
||||
- **Unique Message IDs**: Each request has a unique identifier (`co_z${string}`)
|
||||
- **Duplicate Detection**: incoming messages ids are tracked to prevent replay attacks
|
||||
- **Message Expiration**: Requests expire after 60 seconds to provide additional protection
|
||||
|
||||
These safeguards ensure that HTTP requests in Jazz are secure, authenticated, and protected against common attack vectors while maintaining the simplicity of standard HTTP communication.
|
||||
|
||||
## Deployments & Transactionality
|
||||
|
||||
### Single Instance Requirements
|
||||
|
||||
Some operations need to happen one at a time and in the same place, otherwise the data can get out of sync.
|
||||
|
||||
For example, if you are checking capacity for an event and creating tickets, you must ensure only one server is doing it.
|
||||
If multiple servers check at the same time, they might all think there is space and allow too many tickets.
|
||||
|
||||
Jazz uses eventual consistency (data takes a moment to sync between regions), so this problem is worse if you run multiple server copies in different locations.
|
||||
|
||||
Until Jazz supports transactions across regions, the solution is to deploy a single server instance for these sensitive operations.
|
||||
|
||||
Examples of when you must deploy on a single instance are:
|
||||
1. Distribute a limited number of tickets
|
||||
* Limiting ticket sales so that only 100 tickets are sold for an event.
|
||||
* The check (“is there space left?”) and ticket creation must happen together, or you risk overselling.
|
||||
2. Inventory stock deduction
|
||||
* Managing a product stock count (e.g., 5 items left in store).
|
||||
* Multiple instances could let multiple buyers purchase the last item at the same time.
|
||||
3. Sequential ID or token generation
|
||||
* Generating unique incremental order numbers (e.g., #1001, #1002).
|
||||
* Multiple instances could produce duplicates if not coordinated.
|
||||
|
||||
Single servers are necessary to enforce invariants or provide a consistent view of the data.
|
||||
|
||||
As a rule of thumb, when the output of the request depends on the state of the database, you should probably deploy on a single instance.
|
||||
|
||||
### Multi-Region Deployment
|
||||
|
||||
If your code doesn’t need strict rules to keep data in sync (no counters, no limits, no “check‑then‑update” logic), you can run your workers in many regions at the same time.
|
||||
|
||||
This way:
|
||||
* Users connect to the closest server (faster).
|
||||
* If one region goes down, others keep running (more reliable).
|
||||
|
||||
Examples of when it's acceptable to deploy across multiple regions are:
|
||||
1. Sending confirmation emails
|
||||
* After an action is complete, sending an email to the user does not depend on current database state.
|
||||
2. Pushing notifications
|
||||
* Broadcasting “event booked” notifications to multiple users can be done from any region.
|
||||
3. Logging or analytics events
|
||||
* Recording “user clicked this button” or “page viewed” events, since these are additive and don’t require strict ordering.
|
||||
4. Calling external APIs (e.g., LLMs, payment confirmations)
|
||||
* If the response does not modify shared counters or limits, it can be done from any region.
|
||||
5. Pre-computing cached data or summaries
|
||||
* Generating read-only previews or cached summaries where stale data is acceptable and does not affect core logic.
|
||||
|
||||
Generally speaking, if the output of the request does not depend on the state of the database, you can deploy across multiple regions.
|
||||
166
homepage/homepage/content/docs/server-side/inbox.mdx
Normal file
166
homepage/homepage/content/docs/server-side/inbox.mdx
Normal file
@@ -0,0 +1,166 @@
|
||||
export const metadata = {
|
||||
description: "How to use the Inbox API to communicate with Server Workers using experimental_useInboxSender and inbox.subscribe."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Inbox API with Server Workers
|
||||
|
||||
The Inbox API provides a message-based communication system for Server Workers in Jazz.
|
||||
|
||||
It works on top of the Jazz APIs and uses sync to transfer messages between the client and the server.
|
||||
|
||||
## Setting up the Inbox API
|
||||
|
||||
### Define the inbox message schema
|
||||
|
||||
Define the inbox message schema in your schema file:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
export const BookTicketMessage = co.map({
|
||||
type: co.literal("bookTicket"),
|
||||
event: Event,
|
||||
})
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Any kind of CoMap is valid as an inbox message.
|
||||
|
||||
### Setting up the Server Worker
|
||||
|
||||
Run a server worker and subscribe to the `inbox`:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { startWorker } from "jazz-tools/worker";
|
||||
import { BookTicketMessage } from "@/schema";
|
||||
|
||||
const {
|
||||
worker,
|
||||
experimental: { inbox },
|
||||
} = await startWorker({
|
||||
accountID: process.env.JAZZ_WORKER_ACCOUNT,
|
||||
accountSecret: process.env.JAZZ_WORKER_SECRET,
|
||||
syncServer: "wss://cloud.jazz.tools/?key=your-api-key",
|
||||
});
|
||||
|
||||
inbox.subscribe(
|
||||
BookTicketMessage,
|
||||
async (message, senderID) => {
|
||||
const madeBy = await co.account().load(senderID, { loadAs: worker });
|
||||
|
||||
const { event } = await message.ensureLoaded({
|
||||
resolve: {
|
||||
event: {
|
||||
reservations: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ticketGroup = Group.create(jazzServer.worker);
|
||||
const ticket = Ticket.create({
|
||||
account: madeBy,
|
||||
event,
|
||||
});
|
||||
|
||||
// Give access to the ticket to the client
|
||||
ticketGroup.addMember(madeBy, "reader");
|
||||
|
||||
event.reservations.push(ticket);
|
||||
|
||||
return ticket;
|
||||
},
|
||||
);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Handling multiple message types
|
||||
|
||||
`inbox.subscribe` should be called once per worker instance.
|
||||
|
||||
If you need to handle multiple message types, you can use the `co.discriminatedUnion` function to create a union of the message types.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
const CancelReservationMessage = co.map({
|
||||
type: co.literal("cancelReservation"),
|
||||
event: Event,
|
||||
ticket: Ticket,
|
||||
});
|
||||
|
||||
export const InboxMessage = co.discriminatedUnion("type", [
|
||||
BookTicketMessage,
|
||||
CancelReservationMessage
|
||||
]);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
And check the message type in the handler:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { InboxMessage } from "@/schema";
|
||||
|
||||
inbox.subscribe(
|
||||
InboxMessage,
|
||||
async (message, senderID) => {
|
||||
switch (message.type) {
|
||||
case "bookTicket":
|
||||
return await handleBookTicket(message, senderID);
|
||||
case "cancelReservation":
|
||||
return await handleCancelReservation(message, senderID);
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Sending messages from the client
|
||||
|
||||
### Using the Inbox Sender hook
|
||||
|
||||
Use `experimental_useInboxSender` to send messages from React components:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { experimental_useInboxSender } from "jazz-tools/react";
|
||||
import { BookTicketMessage, Event } from "@/schema";
|
||||
|
||||
function EventComponent({ event }: { event: Event }) {
|
||||
const sendInboxMessage = experimental_useInboxSender(WORKER_ID);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onBookTicketClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const ticketId = await sendInboxMessage(
|
||||
BookTicketMessage.create({
|
||||
type: "bookTicket",
|
||||
event: event,
|
||||
}),
|
||||
);
|
||||
|
||||
alert(`Ticket booked: ${ticketId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={onBookTicketClick} loading={isLoading}>
|
||||
Book Ticket
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
|
||||
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
|
||||
The value returned will be the id of the CoValue returned in the `inbox.subscribe` resolved promise.
|
||||
|
||||
|
||||
## Deployment considerations
|
||||
|
||||
Multi-region deployments are not supported when using the Inbox API.
|
||||
|
||||
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests.mdx) instead.
|
||||
78
homepage/homepage/content/docs/server-side/setup.mdx
Normal file
78
homepage/homepage/content/docs/server-side/setup.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
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";
|
||||
|
||||
# Running Jazz on the server
|
||||
|
||||
Jazz is a distributed database that can be used on both clients or servers without any distinction.
|
||||
|
||||
You can use servers to:
|
||||
- perform operations that can't be done on the client (e.g. sending emails, making HTTP requests, etc.)
|
||||
- validate actions that require a central authority (e.g. a payment gateway, booking a hotel, etc.)
|
||||
|
||||
We call the code that runs on the server a "Server Worker".
|
||||
|
||||
The main difference to keep in mind when working with Jazz compared to traditional systems is that server code doesn't have any
|
||||
special or privileged access to the user data. You need to be explicit about what you want to share with the server.
|
||||
|
||||
This means that your server workers will have their own accounts, and they need to be explicitly given access to the CoValues they need to work on.
|
||||
|
||||
## 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.
|
||||
|
||||
<Alert variant="info" className="mt-4">
|
||||
**Note**: By default the account will be stored in Jazz Cloud. You can use the `--peer` flag to store the account on a different sync server.
|
||||
</Alert>
|
||||
|
||||
## Running a server worker
|
||||
|
||||
You can use `startWorker` to run 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 its private account root)
|
||||
- takes a URL for a sync & storage server
|
||||
|
||||
The migration defined in the `AccountSchema` will be executed every time the worker starts, the same way as it would be for a client-side Jazz context.
|
||||
|
||||
<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',
|
||||
accountID: process.env.JAZZ_WORKER_ACCOUNT,
|
||||
accountSecret: process.env.JAZZ_WORKER_SECRET,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`worker` is an instance of the `Account` schema provided, and acts like `me` (as returned by `useAccount` on the client).
|
||||
|
||||
It will implicitly become the current account, and you can avoid mentioning it in most cases.
|
||||
|
||||
For this reason we also recommend running a single worker instance per server, because it makes your code much more predictable.
|
||||
|
||||
## Storing & providing credentials
|
||||
|
||||
Server Worker credentials are typically stored and provided as environment variables.
|
||||
|
||||
**Take extra care with the Account Secret — handle it like any other secret environment variable such as a DB password.**
|
||||
@@ -24,7 +24,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
export type Project = co.loaded<typeof Project>;
|
||||
```
|
||||
@@ -54,7 +54,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
const Inventory = co.record(z.string(), z.number());
|
||||
// ---cut---
|
||||
@@ -90,7 +90,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
|
||||
// ---cut---
|
||||
@@ -134,7 +134,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
const project = Project.create(
|
||||
{
|
||||
@@ -165,7 +165,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
const project = Project.create(
|
||||
{
|
||||
@@ -197,7 +197,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
get subProject() {
|
||||
return Project.optional();
|
||||
}
|
||||
@@ -219,9 +219,9 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
get subProjects(): z.ZodOptional<CoListSchema<typeof Project>> {
|
||||
return z.optional(co.list(Project));
|
||||
return co.optional(co.list(Project));
|
||||
}
|
||||
});
|
||||
export type Project = co.loaded<typeof Project>;
|
||||
@@ -265,7 +265,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
const Inventory = co.record(z.string(), z.number());
|
||||
const project = Project.create(
|
||||
@@ -297,7 +297,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
const Inventory = co.record(z.string(), z.number());
|
||||
const project = Project.create(
|
||||
@@ -347,7 +347,7 @@ const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: z.optional(Member),
|
||||
coordinator: co.optional(Member),
|
||||
});
|
||||
const Inventory = co.record(z.string(), z.number());
|
||||
const project = Project.create(
|
||||
@@ -445,7 +445,7 @@ const TaskV2 = co.map({
|
||||
|
||||
// Export the discriminated union; because some users might
|
||||
// not be able to run the migration
|
||||
export const Task = z.discriminatedUnion("version", [
|
||||
export const Task = co.discriminatedUnion("version", [
|
||||
TaskV1,
|
||||
TaskV2,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.15.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.16
|
||||
|
||||
## 0.15.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.15
|
||||
|
||||
## 0.15.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [70ce7c5]
|
||||
- cojson@0.15.14
|
||||
|
||||
## 0.15.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.13
|
||||
|
||||
## 0.15.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.12
|
||||
|
||||
## 0.15.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.11
|
||||
|
||||
## 0.15.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.15.10",
|
||||
"version": "0.15.16",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.15.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.16
|
||||
|
||||
## 0.15.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.15
|
||||
|
||||
## 0.15.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [70ce7c5]
|
||||
- cojson@0.15.14
|
||||
|
||||
## 0.15.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.13
|
||||
|
||||
## 0.15.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.12
|
||||
|
||||
## 0.15.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.11
|
||||
|
||||
## 0.15.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.15.10",
|
||||
"version": "0.15.16",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 0.15.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.16
|
||||
|
||||
## 0.15.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.15
|
||||
|
||||
## 0.15.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 70ce7c5: Introduce the persistent peers. Used to mark the WebSocket connections to the server as persistent, and wait for reconnection before failing load.
|
||||
- Updated dependencies [70ce7c5]
|
||||
- cojson@0.15.14
|
||||
|
||||
## 0.15.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.13
|
||||
|
||||
## 0.15.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.12
|
||||
|
||||
## 0.15.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.11
|
||||
|
||||
## 0.15.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.15.10",
|
||||
"version": "0.15.16",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -165,6 +165,6 @@ export function createWebSocketPeer({
|
||||
incoming,
|
||||
outgoing,
|
||||
role,
|
||||
deletePeerStateOnClose,
|
||||
persistent: !deletePeerStateOnClose,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# cojson
|
||||
|
||||
## 0.15.16
|
||||
|
||||
## 0.15.15
|
||||
|
||||
## 0.15.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 70ce7c5: Introduce the persistent peers. Used to mark the WebSocket connections to the server as persistent, and wait for reconnection before failing load.
|
||||
|
||||
## 0.15.13
|
||||
|
||||
## 0.15.12
|
||||
|
||||
## 0.15.11
|
||||
|
||||
## 0.15.10
|
||||
|
||||
## 0.15.9
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.15.10",
|
||||
"version": "0.15.16",
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"libsql": "^0.5.13",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user