diff --git a/demos/example-nextjs/.env.local.template b/demos/example-nextjs/.env.local.template index dd031f462..d03ffdbfa 100644 --- a/demos/example-nextjs/.env.local.template +++ b/demos/example-nextjs/.env.local.template @@ -1,4 +1,16 @@ -# Copy to .env.local, and complete these variables. -# Leave blank to test local-only. -NEXT_PUBLIC_POWERSYNC_URL= -NEXT_PUBLIC_POWERSYNC_TOKEN= +# Copy this file: cp .env.local.template .env.local +# +# App-level environment variables for Next.js. +# Docker Compose variables live in powersync/docker/.env. + +# ── PowerSync service ──────────────────────────────────────────────────────── +POWERSYNC_URL=http://localhost:8080 + +# Used by Next.js API routes (localhost, since it runs outside Docker) +DATABASE_URL=postgresql://postgres:changeme@localhost:5432/postgres + +# ── JWT key pair (required) ────────────────────────────────────────────────── +# Generate with: pnpm generate-keys +# Then paste the output below. +POWERSYNC_PRIVATE_KEY= +POWERSYNC_PUBLIC_KEY= diff --git a/demos/example-nextjs/.gitignore b/demos/example-nextjs/.gitignore index c7e298f86..deda6e72e 100644 --- a/demos/example-nextjs/.gitignore +++ b/demos/example-nextjs/.gitignore @@ -25,6 +25,9 @@ yarn-debug.log* # local env files .env*.local +# unignore docker env (overrides root .gitignore) +!powersync/docker/.env + # vercel .vercel @@ -32,6 +35,9 @@ yarn-debug.log* *.tsbuildinfo next-env.d.ts +# override root gitignore's "lib" rule (which targets build output) +!src/library/ + # ide .idea .fleet diff --git a/demos/example-nextjs/README.md b/demos/example-nextjs/README.md index dc77b651e..843cb8cdc 100644 --- a/demos/example-nextjs/README.md +++ b/demos/example-nextjs/README.md @@ -1,10 +1,119 @@ -# PowerSync Next.js example +# PowerSync Next.js Example -This example is built using [Next.js](https://nextjs.org/) and the [PowerSync JS web SDK](https://docs.powersync.com/client-sdk-references/js-web). +PowerSync demo using [Next.js](https://nextjs.org/) and the [PowerSync JS web SDK](https://docs.powersync.com/client-sdk-references/js-web). -To see it in action: +Syncs data from a local Postgres through a self-hosted PowerSync service. No login required; the Next.js server hands out anonymous JWTs signed with a local key pair. -1. `cd` into this directory and run `pnpm install`. -2. Copy `.env.local.template` to `.env.local`, and complete the environment variables. You can generate a [temporary development token](https://docs.powersync.com/usage/installation/authentication-setup/development-tokens), or leave blank to test with local-only data. -3. Run `pnpm start`. -4. Open the localhost URL displayed in the terminal output in your browser. +## Architecture + +``` +Browser (WASQLite) + ↕ sync (WebSocket) +PowerSync service <-> Postgres (source DB) + ↕ bucket storage +Postgres (storage DB) + +Browser -> POST /api/data -> Next.js API route -> Postgres (writes) +Browser -> GET /api/auth/token -> Next.js API route -> signed JWT +PowerSync -> GET /api/auth/keys -> Next.js API route -> JWKS (public key) +``` + +## Prerequisites + +- [Docker](https://www.docker.com/) (running) +- [PowerSync CLI](https://docs.powersync.com/self-hosting/installation) (`npm i -g @powersync/cli`) +- Node.js >= 18 and [pnpm](https://pnpm.io/) + +## Setup + +```bash +# 1. Install deps +pnpm install + +# 2. Create env file +cp .env.local.template .env.local + +# 3. Generate a JWT key pair and paste the output into .env.local +pnpm generate-keys + +# 4. Start local Postgres + PowerSync +pnpm local:up + +# 5. Start Next.js +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +### About the key pair + +PowerSync validates JWTs the Next.js app issues by fetching a JWKS at [/api/auth/keys](src/app/api/auth/keys/route.ts). The private key signs tokens; the public key is what PowerSync fetches. Both must be set in `.env.local` — the app will refuse to start token issuance without them. + +`pnpm generate-keys` prints base64-encoded JWKs to stdout. Copy the two lines into `.env.local`: + +``` +POWERSYNC_PRIVATE_KEY= +POWERSYNC_PUBLIC_KEY= +``` + +Restart `pnpm dev` after changing these values. + +## Project structure + +``` +src/ +├── app/ +│ ├── api/ +│ │ ├── auth/ +│ │ │ ├── keys/route.ts JWKS endpoint for PowerSync +│ │ │ └── token/route.ts Anonymous JWT endpoint +│ │ └── data/route.ts CRUD writes to Postgres +│ ├── globals.css +│ ├── layout.tsx +│ └── page.tsx +├── components/ +│ ├── CustomerList.tsx +│ ├── StatusPanel.tsx +│ └── SyncedContent.tsx +└── library/ + ├── auth-keys.ts Loads RSA key pair from env (server only) + ├── db.ts Postgres pool (server only) + └── powersync/ + ├── connector.ts Fetch token + upload mutations + ├── powersync-provider.tsx PowerSync context provider + └── schema.ts Client-side table schema +``` + +## Environment variables + +All config lives in `.env.local`. Docker Compose also reads from this file (via a symlink at `powersync/docker/.env`). + +| Variable | Required | What it does | +|---|---|---| +| `POWERSYNC_URL` | yes | PowerSync service URL, also used as the JWT audience | +| `DATABASE_URL` | yes | Postgres connection for Next.js API routes (uses `localhost`) | +| `POWERSYNC_PRIVATE_KEY` | yes | Base64-encoded JWK private key — generate with `pnpm generate-keys` | +| `POWERSYNC_PUBLIC_KEY` | yes | Base64-encoded JWK public key — generate with `pnpm generate-keys` | +| `PS_DATABASE_*` | yes | Postgres credentials used by Docker | +| `PS_STORAGE_*` | yes | Separate Postgres for PowerSync internal storage | +| `PS_DATA_SOURCE_URI` | yes | Postgres URI inside Docker (uses `pg-db` hostname) | +| `PS_STORAGE_SOURCE_URI` | yes | Storage Postgres URI inside Docker (uses `pg-storage` hostname) | + +## Scripts + +| Command | What it does | +|---|---| +| `pnpm dev` | Start Next.js dev server | +| `pnpm build` | Production build | +| `pnpm generate-keys` | Print a fresh JWT key pair for `.env.local` | +| `pnpm local:up` | Start PowerSync + Postgres via Docker | +| `pnpm local:down` | Stop Docker stack | + +## Resetting the database + +If you change the schema, wipe the Docker volumes so the init SQL runs again: + +```bash +powersync docker stop --directory powersync --remove --remove-volumes +powersync docker reset --directory powersync +``` diff --git a/demos/example-nextjs/next.config.js b/demos/example-nextjs/next.config.js deleted file mode 100644 index 31b985e7e..000000000 --- a/demos/example-nextjs/next.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - images: { - disableStaticImages: true - }, - turbopack: {} -}; diff --git a/demos/example-nextjs/next.config.ts b/demos/example-nextjs/next.config.ts new file mode 100644 index 000000000..6ef3b1cdd --- /dev/null +++ b/demos/example-nextjs/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next'; + +const config: NextConfig = { + serverExternalPackages: ['pg', 'jose'], + turbopack: { + root: import.meta.dirname + } +}; + +export default config; diff --git a/demos/example-nextjs/package.json b/demos/example-nextjs/package.json index 15c80bef5..3b403051d 100644 --- a/demos/example-nextjs/package.json +++ b/demos/example-nextjs/package.json @@ -1,46 +1,39 @@ { "name": "example-nextjs", - "version": "0.1.15", + "version": "0.2.0", "private": true, "scripts": { + "dev": "next dev", "build": "next build", - "clean": "rm -rf .next", - "watch": "next dev", "start": "next start", "lint": "next lint", - "test:build": "pnpm build", + "clean": "rm -rf .next", + "generate-keys": "node --experimental-strip-types scripts/generate-keys.mts", "copy-assets": "powersync-web copy-assets -o public", - "postinstall": "pnpm copy-assets" + "postinstall": "pnpm copy-assets", + "local:up": "powersync docker start --directory powersync", + "local:down": "powersync docker stop --directory powersync", + "test:build": "pnpm build" }, "dependencies": { - "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.5", - "@fontsource/roboto": "^5.0.13", "@journeyapps/wa-sqlite": "^1.6.0", - "@lexical/react": "^0.15.0", - "@mui/icons-material": "^5.15.18", - "@mui/material": "^5.15.18", "@powersync/react": "^1.10.0", "@powersync/web": "^1.37.2", - "lato-font": "^3.0.0", - "lexical": "^0.15.0", + "@tailwindcss/postcss": "^4.2.2", + "jose": "^5.9.6", "next": "^16.1.1", + "pg": "^8.13.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwindcss": "^4.2.2" }, "devDependencies": { "@types/node": "^20.12.12", + "@types/pg": "^8.11.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "autoprefixer": "^10.4.19", - "babel-loader": "^9.1.3", - "css-loader": "^6.11.0", "eslint": "^9.0.0", "eslint-config-next": "^16.0.0", - "postcss": "^8.4.35", - "sass": "^1.77.2", - "sass-loader": "^13.3.3", - "style-loader": "^3.3.4", - "tailwindcss": "^3.4.3" + "typescript": "^5.0.0" } } diff --git a/demos/example-nextjs/postcss.config.js b/demos/example-nextjs/postcss.config.js deleted file mode 100644 index 5cbc2c7d8..000000000 --- a/demos/example-nextjs/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -}; diff --git a/demos/example-nextjs/postcss.config.mjs b/demos/example-nextjs/postcss.config.mjs new file mode 100644 index 000000000..6ebbee33a --- /dev/null +++ b/demos/example-nextjs/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {} + } +}; + +export default config; diff --git a/demos/example-nextjs/powersync/cli.yaml b/demos/example-nextjs/powersync/cli.yaml new file mode 100644 index 000000000..89222b6b8 --- /dev/null +++ b/demos/example-nextjs/powersync/cli.yaml @@ -0,0 +1,11 @@ +# Adds YAML Schema support for VSCode users with the YAML extension installed. This enables features like validation and autocompletion based on the provided schema. +# yaml-language-server: $schema=https://unpkg.com/@powersync/cli-schemas@latest/json-schema/cli-config.json + +# This file is managed by the PowerSync CLI. +# Run \`powersync link --help\` for more information. +type: self-hosted +api_url: http://localhost:8080 +api_key: dev-token-do-not-use-in-production +plugins: + docker: + project_name: powersync_example-nextjs diff --git a/demos/example-nextjs/powersync/docker/.env b/demos/example-nextjs/powersync/docker/.env new file mode 100644 index 000000000..a7daabc99 --- /dev/null +++ b/demos/example-nextjs/powersync/docker/.env @@ -0,0 +1,15 @@ +# ── Application database (Postgres) ────────────────────────────────────────── +PS_DATABASE_USER=postgres +PS_DATABASE_PASSWORD=changeme +PS_DATABASE_NAME=postgres +PS_DATABASE_PORT=5432 + +# ── PowerSync storage database (internal) ──────────────────────────────────── +PS_STORAGE_USER=postgres +PS_STORAGE_PASSWORD=changeme +PS_STORAGE_DATABASE=powersync_storage +PS_STORAGE_PORT=5433 + +# ── Docker-internal URIs (pg-db / pg-storage are Docker service names) ──────── +PS_DATA_SOURCE_URI=postgresql://postgres:changeme@pg-db:5432/postgres +PS_STORAGE_SOURCE_URI=postgresql://postgres:changeme@pg-storage:5433/powersync_storage diff --git a/demos/example-nextjs/powersync/docker/docker-compose.yaml b/demos/example-nextjs/powersync/docker/docker-compose.yaml new file mode 100644 index 000000000..b80f52573 --- /dev/null +++ b/demos/example-nextjs/powersync/docker/docker-compose.yaml @@ -0,0 +1,48 @@ +# Composed PowerSync Docker stack (generated by powersync docker configure). +# Modules add entries to include and to services.powersync.depends_on. +# Relative paths: . = powersync/docker, .. = powersync. +# Include syntax requires Docker Compose v2.20.3+ + +include: + [ + modules/database-postgres/postgres.database.compose.yaml, + modules/storage-postgres/postgres.storage.compose.yaml + ] + +services: + powersync: + restart: unless-stopped + image: journeyapps/powersync-service:latest + command: [ 'start', '-r', 'unified' ] + env_file: + - .env + volumes: + - ../service.yaml:/config/service.yaml + - ../sync-config.yaml:/config/sync-config.yaml + environment: + POWERSYNC_CONFIG_PATH: /config/service.yaml + NODE_OPTIONS: --max-old-space-size=1000 + healthcheck: + test: + - 'CMD' + - 'node' + - '-e' + - "fetch('http://localhost:${PS_PORT:-8080}/probes/liveness').then(r => + r.ok ? process.exit(0) : process.exit(1)).catch(() => + process.exit(1))" + interval: 5s + timeout: 1s + retries: 15 + ports: + - '${PS_PORT:-8080}:${PS_PORT:-8080}' + # host.docker.internal resolves automatically on macOS/Windows; on Linux + # it needs this mapping so the container can reach the Next.js app running + # on the host (see client_auth.jwks_uri in ../service.yaml). + extra_hosts: + - 'host.docker.internal:host-gateway' + depends_on: + { + pg-db: { condition: service_healthy }, + pg-storage: { condition: service_healthy } + } +name: powersync_example-nextjs diff --git a/demos/example-nextjs/powersync/docker/modules/database-postgres/init-scripts/.gitkeep b/demos/example-nextjs/powersync/docker/modules/database-postgres/init-scripts/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/demos/example-nextjs/powersync/docker/modules/database-postgres/init-scripts/01-init.sql b/demos/example-nextjs/powersync/docker/modules/database-postgres/init-scripts/01-init.sql new file mode 100644 index 000000000..9524973be --- /dev/null +++ b/demos/example-nextjs/powersync/docker/modules/database-postgres/init-scripts/01-init.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS customers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- Seed data +INSERT INTO customers (id, name, created_at) VALUES + (gen_random_uuid()::text, 'Alice Johnson', now()::text), + (gen_random_uuid()::text, 'Bob Smith', now()::text), + (gen_random_uuid()::text, 'Carol White', now()::text); + +-- PowerSync requires a logical replication publication +CREATE PUBLICATION powersync FOR TABLE customers; diff --git a/demos/example-nextjs/powersync/docker/modules/database-postgres/postgres.database.compose.yaml b/demos/example-nextjs/powersync/docker/modules/database-postgres/postgres.database.compose.yaml new file mode 100644 index 000000000..a9c63238e --- /dev/null +++ b/demos/example-nextjs/powersync/docker/modules/database-postgres/postgres.database.compose.yaml @@ -0,0 +1,27 @@ +# Database module: PostgreSQL for replication source (logical replication). +# Paths are relative to this file's directory when using include (Compose resolves from the included file). +# Include from main: path: modules/database-postgres/postgres.database.compose.yaml + +services: + pg-db: + image: postgres:18 + restart: always + environment: + - POSTGRES_USER=${PS_DATABASE_USER} + - POSTGRES_DB=${PS_DATABASE_NAME} + - POSTGRES_PASSWORD=${PS_DATABASE_PASSWORD} + - PGPORT=${PS_DATABASE_PORT} + volumes: + - pg_data:/var/lib/postgresql + - ./init-scripts:/docker-entrypoint-initdb.d + ports: + - '${PS_DATABASE_PORT}:${PS_DATABASE_PORT}' + command: ['postgres', '-c', 'wal_level=logical'] + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${PS_DATABASE_USER} -d ${PS_DATABASE_NAME}'] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pg_data: diff --git a/demos/example-nextjs/powersync/docker/modules/storage-postgres/init-scripts/01-storage-db.sql b/demos/example-nextjs/powersync/docker/modules/storage-postgres/init-scripts/01-storage-db.sql new file mode 100644 index 000000000..28a61814b --- /dev/null +++ b/demos/example-nextjs/powersync/docker/modules/storage-postgres/init-scripts/01-storage-db.sql @@ -0,0 +1,2 @@ +-- Storage database is created by the Postgres image from POSTGRES_DB (e.g. powersync_storage). +-- Add any extra schema or grants here if needed. diff --git a/demos/example-nextjs/powersync/docker/modules/storage-postgres/postgres.storage.compose.yaml b/demos/example-nextjs/powersync/docker/modules/storage-postgres/postgres.storage.compose.yaml new file mode 100644 index 000000000..93ab796f0 --- /dev/null +++ b/demos/example-nextjs/powersync/docker/modules/storage-postgres/postgres.storage.compose.yaml @@ -0,0 +1,26 @@ +# Storage module: PostgreSQL for PowerSync bucket metadata. +# Paths are relative to this file's directory when using include (Compose resolves from the included file). +# Include from main: path: modules/storage-postgres/postgres.storage.compose.yaml + +services: + pg-storage: + image: postgres:18 + restart: always + environment: + - POSTGRES_USER=${PS_STORAGE_USER} + - POSTGRES_DB=${PS_STORAGE_DATABASE} + - POSTGRES_PASSWORD=${PS_STORAGE_PASSWORD} + - PGPORT=${PS_STORAGE_PORT} + volumes: + - pg_storage_data:/var/lib/postgresql + - ./init-scripts:/docker-entrypoint-initdb.d + ports: + - '${PS_STORAGE_PORT}:${PS_STORAGE_PORT}' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${PS_STORAGE_USER} -d ${PS_STORAGE_DATABASE}'] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pg_storage_data: diff --git a/demos/example-nextjs/powersync/service.yaml b/demos/example-nextjs/powersync/service.yaml new file mode 100644 index 000000000..76ebce846 --- /dev/null +++ b/demos/example-nextjs/powersync/service.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/cli-schemas@latest/json-schema/service-config.json + +_type: self-hosted + +telemetry: + disable_telemetry_sharing: true + +replication: + connections: + - type: postgresql + uri: !env PS_DATA_SOURCE_URI + sslmode: disable + +storage: + type: postgresql + uri: !env PS_STORAGE_SOURCE_URI + sslmode: disable + +sync_config: + path: sync-config.yaml + +# Anonymous JWT auth: the Next.js app serves a JWKS endpoint that PowerSync +# uses to validate tokens it issued itself. No user sign-in required. +client_auth: + jwks_uri: http://host.docker.internal:3000/api/auth/keys + audience: + - http://localhost:8080 + +api: + tokens: + - dev-token-do-not-use-in-production diff --git a/demos/example-nextjs/powersync/sync-config.yaml b/demos/example-nextjs/powersync/sync-config.yaml new file mode 100644 index 000000000..44d08d25b --- /dev/null +++ b/demos/example-nextjs/powersync/sync-config.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json + +config: + edition: 3 + +streams: + # Sync all todos to all users (anonymous / no auth filtering needed) + global: + auto_subscribe: true + queries: + - SELECT * FROM customers diff --git a/demos/example-nextjs/public/powersync-logo.svg b/demos/example-nextjs/public/powersync-logo.svg new file mode 100644 index 000000000..05e31b6ed --- /dev/null +++ b/demos/example-nextjs/public/powersync-logo.svg @@ -0,0 +1 @@ + diff --git a/demos/example-nextjs/scripts/generate-keys.mts b/demos/example-nextjs/scripts/generate-keys.mts new file mode 100644 index 000000000..9b778cd60 --- /dev/null +++ b/demos/example-nextjs/scripts/generate-keys.mts @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import * as jose from 'jose'; +import crypto from 'node:crypto'; + +async function main() { + const alg = 'RS256'; + const kid = `powersync-${crypto.randomBytes(5).toString('hex')}`; + + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { extractable: true }); + + const privateJwk: jose.JWK = { ...(await jose.exportJWK(privateKey)), alg, kid }; + const publicJwk: jose.JWK = { ...(await jose.exportJWK(publicKey)), alg, kid }; + + const privateBase64 = Buffer.from(JSON.stringify(privateJwk)).toString('base64'); + const publicBase64 = Buffer.from(JSON.stringify(publicJwk)).toString('base64'); + + console.log(`Public Key: +${JSON.stringify(publicJwk, null, 2)} + +--- + +Add the following to your .env.local file: + +POWERSYNC_PUBLIC_KEY=${publicBase64} + +POWERSYNC_PRIVATE_KEY=${privateBase64} +`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/demos/example-nextjs/src/app/api/auth/keys/route.ts b/demos/example-nextjs/src/app/api/auth/keys/route.ts new file mode 100644 index 000000000..6fb749bf4 --- /dev/null +++ b/demos/example-nextjs/src/app/api/auth/keys/route.ts @@ -0,0 +1,15 @@ +import { getKeyPair } from '@/library/auth-keys'; +import { NextResponse } from 'next/server'; + +// JWKS endpoint. PowerSync hits this to verify client tokens. +// See powersync/service.yaml -> client_auth.jwks_uri +export async function GET() { + try { + const { publicJwk } = await getKeyPair(); + return NextResponse.json({ keys: [publicJwk] }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('[api/auth/keys]', message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/demos/example-nextjs/src/app/api/auth/token/route.ts b/demos/example-nextjs/src/app/api/auth/token/route.ts new file mode 100644 index 000000000..81f5fa32a --- /dev/null +++ b/demos/example-nextjs/src/app/api/auth/token/route.ts @@ -0,0 +1,26 @@ +import { getKeyPair } from '@/library/auth-keys'; +import { JWT_ISSUER, POWERSYNC_URL } from '@/library/auth-config'; +import { SignJWT } from 'jose'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + try { + const userId = req.nextUrl.searchParams.get('user_id') ?? 'anonymous'; + const { privateKey, alg, kid } = await getKeyPair(); + + const token = await new SignJWT({}) + .setProtectedHeader({ alg, kid }) + .setSubject(userId) + .setIssuedAt() + .setIssuer(JWT_ISSUER) + .setAudience(POWERSYNC_URL) + .setExpirationTime('5m') + .sign(privateKey); + + return NextResponse.json({ token, powersync_url: POWERSYNC_URL }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('[api/auth/token]', message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/demos/example-nextjs/src/app/api/data/route.ts b/demos/example-nextjs/src/app/api/data/route.ts new file mode 100644 index 000000000..bab8190d4 --- /dev/null +++ b/demos/example-nextjs/src/app/api/data/route.ts @@ -0,0 +1,86 @@ +import { getPool } from '@/library/db'; +import { verifyRequest } from '@/library/auth-verify'; +import { NextRequest, NextResponse } from 'next/server'; + +type Op = 'PUT' | 'PATCH' | 'DELETE'; + +interface CrudEntry { + op: Op; + table: string; + id: string; + data?: Record; +} + +function escapeId(id: string) { + return `"${id.replace(/"/g, '""').replace(/\./g, '"."')}"`; +} + +async function applyBatch(batch: CrudEntry[]) { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const entry of batch) { + const table = escapeId(entry.table); + if (entry.op === 'PUT') { + const row = { ...entry.data, id: entry.id }; + const cols = Object.keys(row).map(escapeId).join(', '); + const updateClauses = Object.keys(row) + .filter((k) => k !== 'id') + .map((k) => `${escapeId(k)} = EXCLUDED.${escapeId(k)}`) + .join(', '); + const upsertSuffix = updateClauses ? `DO UPDATE SET ${updateClauses}` : 'DO NOTHING'; + await client.query( + `WITH r AS (SELECT (json_populate_record(null::${table}, $1::json)).*) + INSERT INTO ${table} (${cols}) SELECT ${cols} FROM r + ON CONFLICT(id) ${upsertSuffix}`, + [JSON.stringify(row)] + ); + } else if (entry.op === 'PATCH') { + const row = { ...entry.data, id: entry.id }; + const setClauses = Object.keys(entry.data ?? {}) + .filter((k) => k !== 'id') + .map((k) => `${escapeId(k)} = r.${escapeId(k)}`) + .join(', '); + if (setClauses) { + await client.query( + `WITH r AS (SELECT (json_populate_record(null::${table}, $1::json)).*) + UPDATE ${table} SET ${setClauses} FROM r WHERE ${table}.id = r.id`, + [JSON.stringify(row)] + ); + } + } else if (entry.op === 'DELETE') { + await client.query(`DELETE FROM ${table} WHERE id = $1`, [entry.id]); + } + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +export async function POST(req: NextRequest) { + try { + await verifyRequest(req); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unauthorized'; + return NextResponse.json({ error: message }, { status: 401 }); + } + + try { + const body = await req.json(); + // PowerSync sends: { batch: CrudEntry[] } + const batch: CrudEntry[] = body.batch ?? []; + await applyBatch(batch); + return NextResponse.json({ status: 'ok' }); + } catch (err: unknown) { + console.error('Data upload error', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/demos/example-nextjs/src/app/globals.css b/demos/example-nextjs/src/app/globals.css new file mode 100644 index 000000000..544f1f0ab --- /dev/null +++ b/demos/example-nextjs/src/app/globals.css @@ -0,0 +1,18 @@ +@import 'tailwindcss'; + +@theme { + --color-bg: #0a0a0a; + --color-surface: #161616; + --color-border: #282828; + --color-text: #e8e8e8; + --color-text-muted: #888; + --color-primary: #00d5ff; + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-danger: #ef4444; +} + +body { + @apply bg-bg text-text antialiased; + font-family: Inter, system-ui, sans-serif; +} diff --git a/demos/example-nextjs/src/app/globals.scss b/demos/example-nextjs/src/app/globals.scss deleted file mode 100644 index 266d352d2..000000000 --- a/demos/example-nextjs/src/app/globals.scss +++ /dev/null @@ -1,20 +0,0 @@ -:root { - --foreground-rgb: 0, 0, 0; - --background-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-rgb: 0, 0, 0; - } -} - -body { - color: rgb(var(--foreground-rgb)); - min-height: 100vh; - margin: 0; - background: var(--background-rgb); - font-family: Lato !important; - font-weight: 400 !important; -} diff --git a/demos/example-nextjs/src/app/layout.tsx b/demos/example-nextjs/src/app/layout.tsx index 49eebf13a..e4afee905 100644 --- a/demos/example-nextjs/src/app/layout.tsx +++ b/demos/example-nextjs/src/app/layout.tsx @@ -1,22 +1,16 @@ -'use client'; +import type { Metadata } from 'next'; +import { PowerSyncProvider } from '@/library/powersync/powersync-provider'; +import './globals.css'; -import { SystemProvider } from '@/components/providers/SystemProvider'; -import { CssBaseline } from '@mui/material'; -import React from 'react'; - -// @ts-ignore -import 'lato-font'; -import './globals.scss'; +export const metadata: Metadata = { + title: 'PowerSync Next.js Example' +}; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - PowerSync Next.js Example - + - - {children} + {children} ); diff --git a/demos/example-nextjs/src/app/page.tsx b/demos/example-nextjs/src/app/page.tsx index ea55b9232..01406ab9b 100644 --- a/demos/example-nextjs/src/app/page.tsx +++ b/demos/example-nextjs/src/app/page.tsx @@ -1,70 +1,15 @@ -'use client'; - -import { CircularProgress, Grid, ListItem, styled } from '@mui/material'; -import { useQuery, useStatus } from '@powersync/react'; - -const EntryPage = () => { - const status = useStatus(); - const { data: customers } = useQuery('SELECT id, name FROM customers'); - - const areVariablesSet = process.env.NEXT_PUBLIC_POWERSYNC_URL && process.env.NEXT_PUBLIC_POWERSYNC_TOKEN; - - if (areVariablesSet && !status.hasSynced) { - return ( - -

- Syncing down from the backend. This will load indefinitely if you have not set up the connection correctly. Check the console for issues. -

- -
- ); - } - - if (!areVariablesSet) { - return ( - -

You have not set up a connection to the backend, please connect your backend.

-
- ); - } +import Image from 'next/image'; +import { SyncedContent } from '@/components/SyncedContent'; +export default function HomePage() { return ( - - -

Customers

-
- - {customers.length === 0 ? ( - -

You currently have no customers. Please connect PowerSync to your database to see them sync down.

-
- ) : ( - -
- {customers.map((c) => ( - {c.name} - ))} - {customers.length == 0 ? : []} -
-
- )} -
+
+
+
+ PowerSync +
+ +
+
); -}; - - -namespace S { - export const CenteredGrid = styled(Grid)` - display: flex; - justify-content: center; - align-items: center; - `; - - export const MainGrid = styled(CenteredGrid)` - min-height: 100vh; - display: flex; - flex-direction: column; - `; } - -export default EntryPage; diff --git a/demos/example-nextjs/src/components/CustomerList.tsx b/demos/example-nextjs/src/components/CustomerList.tsx new file mode 100644 index 000000000..9818d0898 --- /dev/null +++ b/demos/example-nextjs/src/components/CustomerList.tsx @@ -0,0 +1,75 @@ +'use client'; + +import type { Customer } from '@/library/powersync/schema'; +import { usePowerSync, useQuery } from '@powersync/react'; +import { useState } from 'react'; + +export function CustomerList() { + const db = usePowerSync(); + const { data: customers } = useQuery('SELECT id, name FROM customers ORDER BY created_at ASC'); + const [input, setInput] = useState(''); + + const addCustomer = async () => { + const name = input.trim(); + if (!name) return; + await db.execute(`INSERT INTO customers (id, name, created_at) VALUES (uuid(), ?, datetime('now'))`, [name]); + setInput(''); + }; + + const deleteCustomer = async (id: string) => { + await db.execute('DELETE FROM customers WHERE id = ?', [id]); + }; + + return ( +
+

+ Customers + + {customers.length} + +

+ +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addCustomer()} + className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text placeholder:text-text-muted focus:border-primary focus:outline-none" + /> + +
+ + {customers.length === 0 ? ( +

No customers yet. Add one above.

+ ) : ( +
    + {customers.map((c) => ( +
  • + {c.name} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/demos/example-nextjs/src/components/StatusPanel.tsx b/demos/example-nextjs/src/components/StatusPanel.tsx new file mode 100644 index 000000000..7df4a6413 --- /dev/null +++ b/demos/example-nextjs/src/components/StatusPanel.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useStatus } from '@powersync/react'; + +const chipStyles = { + default: 'bg-border text-text-muted', + success: 'bg-success/20 text-success', + warning: 'bg-warning/20 text-warning', + error: 'bg-danger/20 text-danger' +} as const; + +function StatusItem({ label, value, ok, icon }: { label: string; value: string; ok: boolean; icon?: React.ReactNode }) { + return ( +
+ {label} +
+ {icon} + {value} +
+
+ ); +} + +function ArrowUp() { + return ( + + + + ); +} + +function ArrowDown() { + return ( + + + + ); +} + +export function StatusPanel() { + const status = useStatus(); + const { connected, hasSynced, dataFlowStatus } = status; + const { uploading, downloading, uploadError, downloadError } = dataFlowStatus; + + let label = 'Connecting…'; + let chipColor: keyof typeof chipStyles = 'warning'; + + if (connected && hasSynced) { + if (uploadError || downloadError) { + label = 'Error'; + chipColor = 'error'; + } else { + label = 'Synced'; + chipColor = 'success'; + } + } else if (connected) { + label = 'Syncing'; + chipColor = 'warning'; + } else if (hasSynced) { + label = 'Disconnected'; + chipColor = 'default'; + } + + return ( +
+
+ Sync Status + {label} +
+ +
+ +
+ + + : undefined} + /> + : undefined} + /> +
+ + {(uploadError || downloadError) && ( +
+ {uploadError &&

Upload: {uploadError.message}

} + {downloadError &&

Download: {downloadError.message}

} +
+ )} +
+ ); +} diff --git a/demos/example-nextjs/src/components/SyncedContent.tsx b/demos/example-nextjs/src/components/SyncedContent.tsx new file mode 100644 index 000000000..0a25e7bdc --- /dev/null +++ b/demos/example-nextjs/src/components/SyncedContent.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { CustomerList } from '@/components/CustomerList'; +import { StatusPanel } from '@/components/StatusPanel'; +import { useStatus } from '@powersync/react'; + +export function SyncedContent() { + const status = useStatus(); + + if (!status.hasSynced) { + return ( +
+
+

Connecting…

+
+ ); + } + + return ( + <> + + + + ); +} diff --git a/demos/example-nextjs/src/components/providers/SystemProvider.tsx b/demos/example-nextjs/src/components/providers/SystemProvider.tsx deleted file mode 100644 index 5c7ce2e9d..000000000 --- a/demos/example-nextjs/src/components/providers/SystemProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { AppSchema } from '@/library/powersync/AppSchema'; -import { BackendConnector } from '@/library/powersync/BackendConnector'; -import { CircularProgress } from '@mui/material'; -import { PowerSyncContext } from '@powersync/react'; -import { createBaseLogger, LogLevel, PowerSyncDatabase, WASQLiteOpenFactory } from '@powersync/web'; -import React, { Suspense } from 'react'; - -const logger = createBaseLogger(); -logger.useDefaults(); -logger.setLevel(LogLevel.DEBUG); - -const factory = new WASQLiteOpenFactory({ - dbFilename: 'powersync-nextjs.db', - /** Use the pre-bundled worker from public/@powersync/ - * This is required since Turbopack doesn't support dynamic imports of workers yet - * https://github.com/vercel/turborepo/issues/3643 - */ - worker: '/@powersync/worker/WASQLiteDB.umd.js' -}); - -const powerSync = new PowerSyncDatabase({ - database: factory, - schema: AppSchema, - flags: { - disableSSRWarning: true - }, - sync: { - // Use the pre-bundled sync worker from public/@powersync/ - worker: '/@powersync/worker/SharedSyncImplementation.umd.js' - } -}); -const connector = new BackendConnector(); - -powerSync.connect(connector); - -export const SystemProvider = ({ children }: { children: React.ReactNode }) => { - return ( - }> - {children} - - ); -}; - -export default SystemProvider; diff --git a/demos/example-nextjs/src/library/auth-config.ts b/demos/example-nextjs/src/library/auth-config.ts new file mode 100644 index 000000000..aa4d7c3ea --- /dev/null +++ b/demos/example-nextjs/src/library/auth-config.ts @@ -0,0 +1,2 @@ +export const POWERSYNC_URL = process.env.POWERSYNC_URL ?? 'http://localhost:8080'; +export const JWT_ISSUER = 'powersync-nextjs-demo'; diff --git a/demos/example-nextjs/src/library/auth-keys.ts b/demos/example-nextjs/src/library/auth-keys.ts new file mode 100644 index 000000000..3b27ae01d --- /dev/null +++ b/demos/example-nextjs/src/library/auth-keys.ts @@ -0,0 +1,47 @@ +import { importJWK, type JWK, type KeyLike } from 'jose'; + +// For production, consider caching the imported key to avoid re-parsing on every request. +export async function getKeyPair(): Promise<{ + privateKey: KeyLike; + publicJwk: JWK & { kid: string; alg: string }; + alg: string; + kid: string; +}> { + const envPrivate = process.env.POWERSYNC_PRIVATE_KEY; + const envPublic = process.env.POWERSYNC_PUBLIC_KEY; + + if (!envPrivate || !envPublic) { + throw new Error( + 'POWERSYNC_PRIVATE_KEY and POWERSYNC_PUBLIC_KEY are not set in .env.local. Run `pnpm generate-keys` and paste the output into .env.local, then restart the dev server.' + ); + } + + const privateJwk = parseJwk('POWERSYNC_PRIVATE_KEY', envPrivate); + const publicJwk = parseJwk('POWERSYNC_PUBLIC_KEY', envPublic); + + if (privateJwk.kid !== publicJwk.kid) { + throw new Error( + `POWERSYNC_PRIVATE_KEY and POWERSYNC_PUBLIC_KEY have mismatched kids (${privateJwk.kid} vs ${publicJwk.kid}). Run \`pnpm generate-keys\` to create a matching pair.` + ); + } + + const privateKey = (await importJWK(privateJwk)) as KeyLike; + return { privateKey, publicJwk, alg: privateJwk.alg, kid: privateJwk.kid }; +} + +function parseJwk(name: string, base64: string): JWK & { kid: string; alg: string } { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(base64, 'base64').toString()); + } catch { + throw new Error(`${name} could not be decoded. Run \`pnpm generate-keys\` and paste the output into .env.local.`); + } + + const jwk = parsed as Partial & { kid?: string; alg?: string }; + if (!jwk || typeof jwk !== 'object' || !jwk.kty || !jwk.alg || !jwk.kid) { + throw new Error( + `${name} is missing required JWK fields (kty, alg, kid). Run \`pnpm generate-keys\` to create a fresh pair.` + ); + } + return jwk as JWK & { kid: string; alg: string }; +} diff --git a/demos/example-nextjs/src/library/auth-verify.ts b/demos/example-nextjs/src/library/auth-verify.ts new file mode 100644 index 000000000..7553e3ae4 --- /dev/null +++ b/demos/example-nextjs/src/library/auth-verify.ts @@ -0,0 +1,22 @@ +import { getKeyPair } from '@/library/auth-keys'; +import { JWT_ISSUER, POWERSYNC_URL } from '@/library/auth-config'; +import { importJWK, jwtVerify, type KeyLike } from 'jose'; +import type { NextRequest } from 'next/server'; + +async function getVerificationKey(): Promise { + const { publicJwk } = await getKeyPair(); + return (await importJWK(publicJwk)) as KeyLike; +} + +export async function verifyRequest(req: NextRequest): Promise { + const header = req.headers.get('authorization') ?? ''; + const match = header.match(/^Bearer\s+(.+)$/i); + if (!match) { + throw new Error('Missing bearer token'); + } + const key = await getVerificationKey(); + await jwtVerify(match[1], key, { + issuer: JWT_ISSUER, + audience: POWERSYNC_URL + }); +} diff --git a/demos/example-nextjs/src/library/db.ts b/demos/example-nextjs/src/library/db.ts new file mode 100644 index 000000000..10c70fbac --- /dev/null +++ b/demos/example-nextjs/src/library/db.ts @@ -0,0 +1,11 @@ +import { Pool } from 'pg'; + +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool({ connectionString: process.env.DATABASE_URL }); + pool.on('error', (err) => console.error('Postgres pool error', err)); + } + return pool; +} diff --git a/demos/example-nextjs/src/library/powersync/BackendConnector.ts b/demos/example-nextjs/src/library/powersync/BackendConnector.ts deleted file mode 100644 index ed8c4d2ad..000000000 --- a/demos/example-nextjs/src/library/powersync/BackendConnector.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { AbstractPowerSyncDatabase, PowerSyncBackendConnector } from '@powersync/web'; - -export class BackendConnector implements PowerSyncBackendConnector { - private powersyncUrl: string | undefined; - private powersyncToken: string | undefined; - - constructor() { - this.powersyncUrl = process.env.NEXT_PUBLIC_POWERSYNC_URL; - // This token is for development only. - // For production applications, integrate with an auth provider or custom auth. - this.powersyncToken = process.env.NEXT_PUBLIC_POWERSYNC_TOKEN; - } - - async fetchCredentials() { - // TODO: Use an authentication service or custom implementation here. - - if (this.powersyncToken == null || this.powersyncUrl == null) { - return null; - } - - return { - endpoint: this.powersyncUrl, - token: this.powersyncToken - }; - } - - async uploadData(database: AbstractPowerSyncDatabase): Promise { - const transaction = await database.getNextCrudTransaction(); - - if (!transaction) { - return; - } - - try { - // TODO: Upload here - - await transaction.complete(); - } catch (error: any) { - if (shouldDiscardDataOnError(error)) { - // Instead of blocking the queue with these errors, discard the (rest of the) transaction. - // - // Note that these errors typically indicate a bug in the application. - // If protecting against data loss is important, save the failing records - // elsewhere instead of discarding, and/or notify the user. - console.error(`Data upload error - discarding`, error); - await transaction.complete(); - } else { - // Error may be retryable - e.g. network error or temporary server error. - // Throwing an error here causes this call to be retried after a delay. - throw error; - } - } - } -} - -function shouldDiscardDataOnError(error: any) { - // TODO: Ignore non-retryable errors here - return false; -} diff --git a/demos/example-nextjs/src/library/powersync/connector.ts b/demos/example-nextjs/src/library/powersync/connector.ts new file mode 100644 index 000000000..fc931c1c5 --- /dev/null +++ b/demos/example-nextjs/src/library/powersync/connector.ts @@ -0,0 +1,62 @@ +import { AbstractPowerSyncDatabase, CrudEntry, PowerSyncBackendConnector } from '@powersync/web'; + +export class BackendConnector implements PowerSyncBackendConnector { + private async fetchToken(): Promise<{ token: string; powersync_url: string }> { + const res = await fetch('/api/auth/token'); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(body.error ?? `Failed to fetch credentials (${res.status} ${res.statusText})`); + } + return body; + } + + async fetchCredentials() { + const body = await this.fetchToken(); + return { endpoint: body.powersync_url, token: body.token }; + } + + async uploadData(database: AbstractPowerSyncDatabase): Promise { + const transaction = await database.getNextCrudTransaction(); + if (!transaction) return; + + try { + const batch = transaction.crud.map((entry: CrudEntry) => ({ + op: entry.op as string, + table: entry.table, + id: entry.id, + data: entry.opData + })); + + const { token } = await this.fetchToken(); + const res = await fetch('/api/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ batch }) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(`Upload failed (${res.status}): ${body.error ?? res.statusText}`); + } + + await transaction.complete(); + } catch (error: unknown) { + if (isFatalError(error)) { + console.error('Discarding transaction due to fatal error', error); + await transaction.complete(); + } else { + throw error; + } + } + } +} + +function isFatalError(error: unknown): boolean { + if (error instanceof Error) { + return /violates|duplicate key|not-null constraint/i.test(error.message); + } + return false; +} diff --git a/demos/example-nextjs/src/library/powersync/powersync-provider.tsx b/demos/example-nextjs/src/library/powersync/powersync-provider.tsx new file mode 100644 index 000000000..3c9a82876 --- /dev/null +++ b/demos/example-nextjs/src/library/powersync/powersync-provider.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { AppSchema } from '@/library/powersync/schema'; +import { BackendConnector } from '@/library/powersync/connector'; +import { PowerSyncContext } from '@powersync/react'; +import { PowerSyncDatabase, WASQLiteOpenFactory } from '@powersync/web'; +import React, { Suspense } from 'react'; + +let dbInstance: PowerSyncDatabase | null = null; + +function getDB(): PowerSyncDatabase { + if (dbInstance) return dbInstance; + + dbInstance = new PowerSyncDatabase({ + database: new WASQLiteOpenFactory({ + dbFilename: 'powersync-nextjs.db', + worker: '/@powersync/worker/WASQLiteDB.umd.js' + }), + schema: AppSchema, + flags: { disableSSRWarning: true }, + sync: { worker: '/@powersync/worker/SharedSyncImplementation.umd.js' } + }); + + dbInstance.connect(new BackendConnector()); + return dbInstance; +} + +export function PowerSyncProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/demos/example-nextjs/src/library/powersync/AppSchema.ts b/demos/example-nextjs/src/library/powersync/schema.ts similarity index 81% rename from demos/example-nextjs/src/library/powersync/AppSchema.ts rename to demos/example-nextjs/src/library/powersync/schema.ts index 3569f78fc..1660736b7 100644 --- a/demos/example-nextjs/src/library/powersync/AppSchema.ts +++ b/demos/example-nextjs/src/library/powersync/schema.ts @@ -5,9 +5,7 @@ const customers = new Table({ created_at: column.text }); -export const AppSchema = new Schema({ - customers -}); +export const AppSchema = new Schema({ customers }); export type Database = (typeof AppSchema)['types']; export type Customer = Database['customers']; diff --git a/demos/example-nextjs/tailwind.config.js b/demos/example-nextjs/tailwind.config.js deleted file mode 100644 index c7e8a3d11..000000000 --- a/demos/example-nextjs/tailwind.config.js +++ /dev/null @@ -1,15 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - './app/**/*.{js,ts,jsx,tsx,mdx}', // Note the addition of the `app` directory. - './pages/**/*.{js,ts,jsx,tsx,mdx}', - './components/**/*.{js,ts,jsx,tsx,mdx}', - - // Or if using `src` directory: - './src/**/*.{js,ts,jsx,tsx,mdx}' - ], - theme: { - extend: {} - }, - plugins: [] -};