diff --git a/.changeset/gentle-poems-live.md b/.changeset/gentle-poems-live.md new file mode 100644 index 000000000..bc47a4955 --- /dev/null +++ b/.changeset/gentle-poems-live.md @@ -0,0 +1,5 @@ +--- +'@powersync/nuxt': minor +--- + +Initial release of the PowerSync Nuxt module. Provides Nuxt Devtools integration, built-in diagnostics and data inspection, and composables. Supports Nuxt 4. diff --git a/.gitignore b/.gitignore index 815612e20..20489d127 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules +.env lib !tools/diagnostics-app/src/lib/ dist +.nuxt +.output *.tsbuildinfo .vscode .DS_STORE @@ -17,3 +20,6 @@ demos/**/pnpm-lock.yaml # Useful for local development demos/**/.pnpmfile.cjs +demos/**/.branches +demos/**/.temp + diff --git a/demos/nuxt-supabase-todolist/.env.template b/demos/nuxt-supabase-todolist/.env.template new file mode 100644 index 000000000..551971dc1 --- /dev/null +++ b/demos/nuxt-supabase-todolist/.env.template @@ -0,0 +1,15 @@ +# Self-hosted Environment Configuration +# Copy this template: `cp .env.template .env` +# Edit .env and enter your Supabase and PowerSync project details. + +NUXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +NUXT_PUBLIC_SUPABASE_ANON_KEY= +# PowerSync Configuration +NUXT_PUBLIC_POWERSYNC_URL=http://localhost:6000 + +# Self-hosted PowerSync Configuration +PS_POSTGRESQL_URI=postgresql://postgres:postgres@supabase_db_nuxt-supabase-todolist:5432/postgres +# PS_SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long # Uncomment this if you want to use legacy Supabase JWT secret for auth +PS_JWKS_URI=http://kong:8000/auth/v1/.well-known/jwks.json +PS_API_TOKEN=super-secret +PS_PORT=6000 \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/.nuxtrc b/demos/nuxt-supabase-todolist/.nuxtrc new file mode 100644 index 000000000..3c8c6a114 --- /dev/null +++ b/demos/nuxt-supabase-todolist/.nuxtrc @@ -0,0 +1 @@ +imports.autoImport=true \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/README.md b/demos/nuxt-supabase-todolist/README.md new file mode 100644 index 000000000..ccd2f45a2 --- /dev/null +++ b/demos/nuxt-supabase-todolist/README.md @@ -0,0 +1,136 @@ +# PowerSync + Supabase Nuxt Demo: Todo List + +This is a demo application showcasing PowerSync integration with Nuxt 4 and Supabase. It demonstrates real-time data synchronization for a simple todo list application using PowerSync's official Nuxt module. + +## Setup Instructions + +Note that this setup guide has minor deviations from the [Supabase + PowerSync integration guide](https://docs.powersync.com/integration-guides/supabase-+-powersync). Below we refer to sections in this guide where relevant. + +### 1. Install dependencies + +In the repo root directory, use [pnpm](https://pnpm.io/installation) to install dependencies: + +```bash +pnpm install +pnpm build:packages +``` + +### Quick Start: Local Development + +This demo can be started with local PowerSync and Supabase services. + +1. Install the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) + +2. Copy the environment template: + ```bash + cp .env.template .env + ``` + +3. Start Supabase: + ```bash + supabase start + ``` + +4. Copy the values from the `supabase start` output into `.env`. Local Supabase uses JWKS for auth, so this demo configures PowerSync with `jwks_uri` and `audience: authenticated`: + - `NUXT_PUBLIC_SUPABASE_ANON_KEY`: Use the **Publishable** key value + - `PS_JWKS_URI`: Use `http://kong:8000/auth/v1/.well-known/jwks.json` when PowerSync runs in Docker on the Supabase network (default in step 5). This points PowerSync at local Supabase's JWKS so it can verify tokens. If Kong is not reachable from the container (e.g. custom network), try `http://host.docker.internal:54321/auth/v1/.well-known/jwks.json` on Docker Desktop for Mac/Windows. + +5. Start PowerSync: + ```bash + docker run \ + -p 6060:6060 \ + -e POWERSYNC_CONFIG_B64=$(base64 -i ./powersync.yaml) \ + -e POWERSYNC_SYNC_RULES_B64=$(base64 -i ./sync-rules.yaml) \ + --env-file ./.env \ + --network supabase_network_nuxt-supabase-todolist \ + --name powersync-nuxt journeyapps/powersync-service:latest + ``` + +6. Run the demo: + ```bash + pnpm dev + ``` + +Open [http://localhost:3000](http://localhost:3000) to use the app. + +### 2. Create project on Supabase and set up Postgres + +This demo app uses Supabase as its Postgres database and backend: + +1. [Create a new project on the Supabase dashboard](https://supabase.com/dashboard/projects). +2. Go to the Supabase SQL Editor for your new project and execute the SQL statements in [`db/seed.sql`](db/seed.sql) to create the database schema, PowerSync replication role, and publication needed for PowerSync. + +**Note:** Before executing the SQL, make sure to update the `powersync_role` password in `db/seed.sql` (currently set to `'postgres_12345'`) to a secure password of your choice. + +**Important:** When connecting PowerSync to your Supabase database, you'll use the `powersync_role` credentials instead of the default Supabase connection string. This role has the necessary replication privileges and bypasses Row Level Security (RLS). + +### 3. Auth setup + +This app uses Supabase's email/password authentication. + +1. Go to "Authentication" -> "Providers" in your Supabase dashboard +2. Ensure "Email" provider is enabled +3. You can disable email confirmation for development by going to "Authentication" -> "Email Auth" and disabling "Confirm email" + +You'll need to create a user account when you first access the application. + +### 4. Set up PowerSync + +You can use either PowerSync Cloud or self-host PowerSync: + +- **PowerSync Cloud**: [Create a new project on the PowerSync dashboard](https://dashboard.powersync.com) and connect it to your Supabase database using the `powersync_role` credentials created in step 2. +- **Self-hosting**: Follow the [self-hosting guide](https://docs.powersync.com/self-hosting/getting-started) to deploy your own PowerSync instance. + +The sync rules for this demo are provided in [`sync-rules.yaml`](sync-rules.yaml) in this directory. + +### 5. Set up local environment variables + +Create a `.env` file in this directory with the following variables: + +```bash +NUXT_PUBLIC_SUPABASE_URL=your_supabase_url +NUXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +NUXT_PUBLIC_POWERSYNC_URL=your_powersync_instance_url +``` + +Replace the values with your actual credentials: +- Get `NUXT_PUBLIC_SUPABASE_URL` and `NUXT_PUBLIC_SUPABASE_ANON_KEY` from your Supabase project settings under "Project Settings" -> "API" +- Get `NUXT_PUBLIC_POWERSYNC_URL` from your PowerSync instance (Cloud dashboard or your self-hosted instance URL) + +### 6. Run the demo app + +In this directory, run the following to start the development server: + +```bash +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to try out the demo. + +## Project Structure + +``` +├── powersync/ +│ ├── AppSchema.ts # PowerSync schema definition +│ └── SuperbaseConnector.ts # Supabase connector implementation +├── plugins/ +│ └── powersync.client.ts # PowerSync plugin setup +├── pages/ +│ ├── index.vue # Main todo list page +│ ├── login.vue # Login page +│ └── confirm.vue # Auth confirmation page +├── components/ +│ └── AppHeader.vue # Header component +├── db/ +│ └── seed.sql # Database setup SQL +├── powersync.yaml # PowerSync server configuration +├── sync-rules.yaml # PowerSync sync rules +└── nuxt.config.ts # Nuxt configuration +``` + +## Learn More + +- [PowerSync Documentation](https://docs.powersync.com/) +- [Supabase Documentation](https://supabase.com/docs) +- [Nuxt Documentation](https://nuxt.com/) + diff --git a/demos/nuxt-supabase-todolist/app.config.ts b/demos/nuxt-supabase-todolist/app.config.ts new file mode 100644 index 000000000..786e6c408 --- /dev/null +++ b/demos/nuxt-supabase-todolist/app.config.ts @@ -0,0 +1,20 @@ +export default defineAppConfig({ + ui: { + colors: { + primary: 'indigo', + neutral: 'stone', + }, + input: { + variants: { + variant: { + subtle: 'ring-default bg-elevated/50', + }, + }, + }, + header: { + slots: { + root: 'border-none', + }, + }, + }, +}) diff --git a/demos/nuxt-supabase-todolist/app.vue b/demos/nuxt-supabase-todolist/app.vue new file mode 100644 index 000000000..4423fb90a --- /dev/null +++ b/demos/nuxt-supabase-todolist/app.vue @@ -0,0 +1,58 @@ + + + diff --git a/demos/nuxt-supabase-todolist/assets/css/main.css b/demos/nuxt-supabase-todolist/assets/css/main.css new file mode 100644 index 000000000..10337b802 --- /dev/null +++ b/demos/nuxt-supabase-todolist/assets/css/main.css @@ -0,0 +1,8 @@ +@import "tailwindcss"; +@import "@nuxt/ui"; + +:root { + --ui-header-height: 40px; + + --ui-container: 100%; +} \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/assets/img/powersync-icon.svg b/demos/nuxt-supabase-todolist/assets/img/powersync-icon.svg new file mode 100644 index 000000000..441e71289 --- /dev/null +++ b/demos/nuxt-supabase-todolist/assets/img/powersync-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/demos/nuxt-supabase-todolist/components/AppHeader.vue b/demos/nuxt-supabase-todolist/components/AppHeader.vue new file mode 100644 index 000000000..b7a562a01 --- /dev/null +++ b/demos/nuxt-supabase-todolist/components/AppHeader.vue @@ -0,0 +1,30 @@ + + + diff --git a/demos/nuxt-supabase-todolist/db/seed.sql b/demos/nuxt-supabase-todolist/db/seed.sql new file mode 100644 index 000000000..740e901f1 --- /dev/null +++ b/demos/nuxt-supabase-todolist/db/seed.sql @@ -0,0 +1,28 @@ +-- Past this into your Superbase SQL Editor + +-- TODO change this if changing the DB connection name +-- connect postgres; +-- Create tables + +CREATE TABLE IF NOT EXISTS public.tasks( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + completed_at timestamp with time zone NULL, + description text NOT NULL, + completed boolean NOT NULL DEFAULT FALSE, + user_id uuid NOT NULL, + CONSTRAINT tasks_pkey PRIMARY KEY (id) +); + +-- Create a role/user with replication privileges for PowerSync +CREATE ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD 'postgres_12345'; +-- Set up permissions for the newly created role +-- Read-only (SELECT) access is required +GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_role; + +-- Optionally, grant SELECT on all future tables (to cater for schema additions) +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role; + + +-- Create publication for PowerSync tables +CREATE PUBLICATION powersync FOR ALL TABLES; diff --git a/demos/nuxt-supabase-todolist/eslint.config.mjs b/demos/nuxt-supabase-todolist/eslint.config.mjs new file mode 100644 index 000000000..848ec061a --- /dev/null +++ b/demos/nuxt-supabase-todolist/eslint.config.mjs @@ -0,0 +1,9 @@ +// @ts-check +import withNuxt from './.nuxt/eslint.config.mjs' + +export default withNuxt({ + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'nuxt/nuxt-config-keys-order': 'off', + }, +}) diff --git a/demos/nuxt-supabase-todolist/layouts/default.vue b/demos/nuxt-supabase-todolist/layouts/default.vue new file mode 100644 index 000000000..b5dc6a84b --- /dev/null +++ b/demos/nuxt-supabase-todolist/layouts/default.vue @@ -0,0 +1,9 @@ + diff --git a/demos/nuxt-supabase-todolist/nuxt.config.ts b/demos/nuxt-supabase-todolist/nuxt.config.ts new file mode 100644 index 000000000..e4a162ba3 --- /dev/null +++ b/demos/nuxt-supabase-todolist/nuxt.config.ts @@ -0,0 +1,75 @@ +import wasm from 'vite-plugin-wasm' + +export default defineNuxtConfig({ + + modules: [ + '@powersync/nuxt', + '@nuxt/eslint', + '@nuxt/ui', + '@nuxtjs/supabase', + ], + ssr: false, + + devtools: { + enabled: true, + }, + + css: ['~/assets/css/main.css'], + + runtimeConfig: { + public: { + powersyncUrl: process.env.NUXT_PUBLIC_POWERSYNC_URL, + }, + }, + + // enable hot reloading when we make changes to our module + watch: ['../src/*', './**/*'], + + compatibilityDate: '2024-07-05', + + vite: { + optimizeDeps: { + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: [ + '@powersync/web > js-logger', + '@supabase/postgrest-js', + ], + }, + + worker: { + format: 'es', + plugins: () => [wasm()], + }, + }, + + unocss: { + autoImport: false, + }, + + eslint: { + config: { + stylistic: true, + }, + }, + + powersync: { + useDiagnostics: true, + kysely: true, + }, + + supabase: { + url: process.env.NUXT_PUBLIC_SUPABASE_URL, + key: process.env.NUXT_PUBLIC_SUPABASE_ANON_KEY, + redirectOptions: { + login: '/login', + callback: '/confirm', + // include: ['/protected'], + exclude: ['/unprotected', '/public/*'], + }, + clientOptions: { + auth: { + persistSession: true, + }, + }, + }, +}) diff --git a/demos/nuxt-supabase-todolist/package.json b/demos/nuxt-supabase-todolist/package.json new file mode 100644 index 000000000..8219ad74d --- /dev/null +++ b/demos/nuxt-supabase-todolist/package.json @@ -0,0 +1,27 @@ +{ + "private": true, + "name": "powersync-nuxt-supabase-todolist", + "scripts": { + "dev": "nuxt dev", + "build": "nuxt build", + "start": "nuxt preview" + }, + "dependencies": { + "@nuxt/ui": "^4.0.0", + "@nuxtjs/supabase": "2.0.1", + "@powersync/kysely-driver": "^1.3.2", + "@powersync/nuxt": "0.0.0-dev-20260128023420", + "@powersync/vue": "^0.4.1", + "@powersync/web": "^1.32.0", + "@supabase/supabase-js": "2.75.0", + "tailwindcss": "^4.1.18" + }, + "devDependencies": { + "@iconify-json/carbon": "^1.2.18", + "@nuxt/devtools": "^1.0.6", + "@nuxt/eslint": "^1.9.0", + "nuxt": "4.1.3", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0" + } +} diff --git a/demos/nuxt-supabase-todolist/pages/confirm.vue b/demos/nuxt-supabase-todolist/pages/confirm.vue new file mode 100644 index 000000000..50ad874a1 --- /dev/null +++ b/demos/nuxt-supabase-todolist/pages/confirm.vue @@ -0,0 +1,13 @@ + + + diff --git a/demos/nuxt-supabase-todolist/pages/index.vue b/demos/nuxt-supabase-todolist/pages/index.vue new file mode 100644 index 000000000..f15373309 --- /dev/null +++ b/demos/nuxt-supabase-todolist/pages/index.vue @@ -0,0 +1,153 @@ + + + diff --git a/demos/nuxt-supabase-todolist/pages/login.vue b/demos/nuxt-supabase-todolist/pages/login.vue new file mode 100644 index 000000000..a293a99bc --- /dev/null +++ b/demos/nuxt-supabase-todolist/pages/login.vue @@ -0,0 +1,118 @@ + + + diff --git a/demos/nuxt-supabase-todolist/plugins/powersync.client.ts b/demos/nuxt-supabase-todolist/plugins/powersync.client.ts new file mode 100644 index 000000000..4cd194cd3 --- /dev/null +++ b/demos/nuxt-supabase-todolist/plugins/powersync.client.ts @@ -0,0 +1,29 @@ +import { + AppSchemaWithDiagnostics, +} from '~/powersync/AppSchema' +import { SupabaseConnector } from '~/powersync/SuperbaseConnector' +import { SyncClientImplementation } from '@powersync/web' + + +export default defineNuxtPlugin({ + async setup(nuxtApp) { + const db = new NuxtPowerSyncDatabase({ + database: { + dbFilename: 'a-db-name.sqlite', + }, + schema: AppSchemaWithDiagnostics, + }) + + const connector = new SupabaseConnector() + + await db.init() + + await db.connect(connector, { + clientImplementation: SyncClientImplementation.RUST, + }) + + const plugin = createPowerSyncPlugin({ database: db }) + + nuxtApp.vueApp.use(plugin) + }, +}) diff --git a/demos/nuxt-supabase-todolist/pnpm-workspace.yaml b/demos/nuxt-supabase-todolist/pnpm-workspace.yaml new file mode 100644 index 000000000..33dd6e35c --- /dev/null +++ b/demos/nuxt-supabase-todolist/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - . \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/powersync.yaml b/demos/nuxt-supabase-todolist/powersync.yaml new file mode 100644 index 000000000..11a18ff05 --- /dev/null +++ b/demos/nuxt-supabase-todolist/powersync.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json +replication: + connections: + - type: postgresql + uri: !env PS_POSTGRESQL_URI + sslmode: disable + # sslmode: verify-full + debug_api: true + +storage: + type: postgresql + uri: !env PS_POSTGRESQL_URI + sslmode: disable + # sslmode: verify-full + +sync_rules: + path: ./sync-rules.yaml + +port: !env PS_PORT + +client_auth: + jwks_uri: !env PS_JWKS_URI + audience: + - authenticated + +telemetry: + prometheus_port: 9090 + disable_telemetry_sharing: true + +api: + tokens: + - !env PS_API_TOKEN \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/powersync/AppSchema.ts b/demos/nuxt-supabase-todolist/powersync/AppSchema.ts new file mode 100644 index 000000000..6e78bfffa --- /dev/null +++ b/demos/nuxt-supabase-todolist/powersync/AppSchema.ts @@ -0,0 +1,25 @@ +import { column, Schema, Table } from '@powersync/web' + +export const TASKS_TABLE = 'tasks' + +const tasks = new Table( + { + created_at: column.text, + completed_at: column.text, + description: column.text, + completed: column.integer, + user_id: column.text, + }, + { indexes: { user: ['user_id'] } }, +) + +export const AppSchema = new Schema({ + tasks, +}) + +export const AppSchemaWithDiagnostics = new Schema([ + ...AppSchema.tables, +]) + +export type Database = (typeof AppSchema)['types'] +export type TaskRecord = Database['tasks'] diff --git a/demos/nuxt-supabase-todolist/powersync/SuperbaseConnector.ts b/demos/nuxt-supabase-todolist/powersync/SuperbaseConnector.ts new file mode 100644 index 000000000..00635de72 --- /dev/null +++ b/demos/nuxt-supabase-todolist/powersync/SuperbaseConnector.ts @@ -0,0 +1,156 @@ +import type { PowerSyncCredentials, + AbstractPowerSyncDatabase, + CrudEntry, + PowerSyncBackendConnector } from '@powersync/web' +import { + BaseObserver, + UpdateType } from '@powersync/web' + +import type { Session, SupabaseClient } from '@supabase/supabase-js' +/// Postgres Response codes that we cannot recover from by retrying. +const FATAL_RESPONSE_CODES = [ + // Class 22 — Data Exception + // Examples include data type mismatch. + new RegExp('^22...$'), + // Class 23 — Integrity Constraint Violation. + // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. + new RegExp('^23...$'), + // INSUFFICIENT PRIVILEGE - typically a row-level security violation + new RegExp('^42501$'), +] + +export type SupabaseConnectorListener = { + initialized: () => void + sessionStarted: (session: Session) => void +} + +export class SupabaseConnector extends BaseObserver implements PowerSyncBackendConnector { + readonly client: SupabaseClient + + ready: boolean + + currentSession: Session | null + + constructor() { + super() + + this.client = useSupabaseClient() + this.currentSession = null + this.ready = false + } + + async init() { + if (this.ready) { + return + } + + const sessionResponse = await this.client.auth.getSession() + this.updateSession(sessionResponse.data.session) + + this.ready = true + this.iterateListeners(cb => cb.initialized?.()) + } + + async login(username: string, password: string) { + const { + data: { session }, + error, + } = await this.client.auth.signInWithPassword({ + email: username, + password: password, + }) + + if (error) { + throw error + } + + this.updateSession(session) + } + + async fetchCredentials() { + const { + data: { session }, + error, + } = await this.client.auth.getSession() + + if (!session || error) { + throw new Error(`Could not fetch Supabase credentials: ${error}`) + } + + console.debug('session expires at', session.expires_at) + + const config = useRuntimeConfig() + return { + endpoint: config.public.powersyncUrl, + token: session.access_token ?? '', + } satisfies PowerSyncCredentials + } + + async uploadData(database: AbstractPowerSyncDatabase): Promise { + const transaction = await database.getNextCrudTransaction() + + if (!transaction) { + return + } + + let lastOp: CrudEntry | null = null + try { + // Note: If transactional consistency is important, use database functions + // or edge functions to process the entire transaction in a single call. + for (const op of transaction.crud) { + lastOp = op + const table = this.client.from(op.table) + let result: any + switch (op.op) { + case UpdateType.PUT: { + const record = { ...op.opData, id: op.id } + result = await table.upsert(record) + break + } + case UpdateType.PATCH: + result = await table.update(op.opData).eq('id', op.id) + break + case UpdateType.DELETE: + result = await table.delete().eq('id', op.id) + break + } + + if (result.error) { + console.error(result.error) + result.error.message = `Could not update Supabase. Received error: ${result.error.message}` + throw result.error + } + } + + await transaction.complete() + } + catch (ex: any) { + console.debug(ex) + if (typeof ex.code == 'string' && FATAL_RESPONSE_CODES.some(regex => regex.test(ex.code))) { + /** + * 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:', lastOp, ex) + 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 ex + } + } + } + + updateSession(session: Session | null) { + this.currentSession = session + if (!session) { + return + } + this.iterateListeners(cb => cb.sessionStarted?.(session)) + } +} diff --git a/demos/nuxt-supabase-todolist/supabase/config.toml b/demos/nuxt-supabase-todolist/supabase/config.toml new file mode 100644 index 000000000..f3aa68435 --- /dev/null +++ b/demos/nuxt-supabase-todolist/supabase/config.toml @@ -0,0 +1,151 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "nuxt-supabase-todolist" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. public and storage are always included. +schemas = ["public", "storage", "graphql_public"] +# Extra schemas to add to the search_path of every request. public is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv6) +# ip_version = "IPv6" + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +enable_anonymous_sign_ins = false + + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = true +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }} ." + +# Use pre-defined map of phone number to OTP for testing. +[auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" + +[analytics] +enabled = false +port = 54327 +vector_port = 54328 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/demos/nuxt-supabase-todolist/supabase/migrations/20250121000000_configure_powersync.sql b/demos/nuxt-supabase-todolist/supabase/migrations/20250121000000_configure_powersync.sql new file mode 100644 index 000000000..834eeeac3 --- /dev/null +++ b/demos/nuxt-supabase-todolist/supabase/migrations/20250121000000_configure_powersync.sql @@ -0,0 +1,13 @@ +-- Create tasks table +CREATE TABLE IF NOT EXISTS public.tasks( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + completed_at timestamp with time zone NULL, + description text NOT NULL, + completed boolean NOT NULL DEFAULT FALSE, + user_id uuid NOT NULL, + CONSTRAINT tasks_pkey PRIMARY KEY (id) +); + +-- Create publication for PowerSync +CREATE PUBLICATION powersync FOR ALL TABLES; diff --git a/demos/nuxt-supabase-todolist/sync-rules.yaml b/demos/nuxt-supabase-todolist/sync-rules.yaml new file mode 100644 index 000000000..197cecfcb --- /dev/null +++ b/demos/nuxt-supabase-todolist/sync-rules.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json +config: + edition: 2 + +bucket_definitions: + tasks: + parameters: + - SELECT request.user_id() AS user_id + data: + - SELECT * FROM tasks WHERE tasks.user_id = bucket.user_id \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/tsconfig.json b/demos/nuxt-supabase-todolist/tsconfig.json new file mode 100644 index 000000000..eb97e3f0e --- /dev/null +++ b/demos/nuxt-supabase-todolist/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore index 8413169c4..ac73ad447 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -27,5 +27,6 @@ docs/vue-sdk/ docs/web-sdk/ docs/tanstack-react-query-sdk docs/node-sdk +docs/nuxt-sdk .env diff --git a/docs/README.md b/docs/README.md index a7de61665..399f4fb47 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,10 @@ pnpm build:packages In this directory: -Copy `.env.example` to `.env`. + +``` +cp .env.example .env +``` ```bash pnpm start diff --git a/docs/utils/packageMap.ts b/docs/utils/packageMap.ts index add2238db..09dab1031 100644 --- a/docs/utils/packageMap.ts +++ b/docs/utils/packageMap.ts @@ -8,7 +8,8 @@ enum Packages { AttachmentsSdk = 'attachments-sdk', WebSdk = 'web-sdk', TanstackReactQuerySdk = 'tanstack-react-query-sdk', - NodeSdk = 'node-sdk' + NodeSdk = 'node-sdk', + NuxtSdk = 'nuxt-sdk' } interface Package { @@ -79,5 +80,12 @@ export const packageMap: PackageMap = { entryPoints: ['../packages/node/src/index.ts'], tsconfig: '../packages/node/tsconfig.json', id: Packages.NodeSdk + }, + [Packages.NuxtSdk]: { + name: 'Nuxt Module', + dirName: Packages.NuxtSdk, + entryPoints: ['../packages/nuxt/src/index.ts'], + tsconfig: '../packages/nuxt/tsconfig.json', + id: Packages.NuxtSdk } }; diff --git a/package.json b/package.json index d046c72d2..921009b9c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "monorepo for powersync javascript sdks", "main": "index.js", "scripts": { + "reset:repo": "tsc -b scripts && node scripts/dist/reset-repo.js", "build:packages": "pnpm run --filter {./packages/**} -r build", "build:packages:prod": "pnpm run --filter {./packages/**} -r build:prod", "clean:packages": "pnpm run --filter {./packages/**} -r clean", @@ -49,6 +50,8 @@ "prettier": "^3.2.5", "prettier-plugin-embed": "^0.4.15", "prettier-plugin-sql": "^0.18.1", + "rollup": "4.14.3", + "rollup-plugin-dts": "^6.2.1", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/nuxt/README b/packages/nuxt/README new file mode 100644 index 000000000..d16bfd568 --- /dev/null +++ b/packages/nuxt/README @@ -0,0 +1,390 @@ +
+ PowerSync Logo +

PowerSync Nuxt

+

Local-first apps made simple

+

Effortless offline-first development with PowerSync integration for Nuxt applications.

+
+ +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![License][license-src]][license-href] +[![Nuxt][nuxt-src]][nuxt-href] + +PowerSync Nuxt module integrated with the [Nuxt Devtools](https://github.com/nuxt/devtools). + +- [✨  Release Notes](/CHANGELOG.md) + +## Features + +- 🔍 **Built-in Diagnostics** - Direct access to PowerSync instance monitoring and real-time connection insights +- 🗄️ **Data Inspection** - Seamless local data browsing with powerful debugging and troubleshooting tools +- ⚡ **Useful Composables** - Ready-to-use Vue composables for rapid offline-first application development +- 📦 **All-in-One** - Exposes all `@powersync/vue` composables, making this the only required dependency + +## Installation + +This module re-exports all `@powersync/vue` composables. With **npm** (v7+), installing `@powersync/nuxt` is enough; with **pnpm**, install peer dependencies explicitly. + +```bash +# Using npm +npm install @powersync/nuxt +npm install --save-dev vite-plugin-top-level-await vite-plugin-wasm + +# Using pnpm (peer deps are not auto-installed) +pnpm add @powersync/nuxt @powersync/vue @powersync/web +pnpm add -D vite-plugin-top-level-await vite-plugin-wasm +``` + +> [!NOTE] +> This module works with `Nuxt 4` and should work with `Nuxt 3` but has not been tested. Support for Nuxt 2 is not guaranteed or planned. + +## Quick Start + +For a complete working app, see the [Nuxt + Supabase Todo List demo](https://github.com/powersync-ja/powersync-js/tree/main/demos/nuxt-supabase-todolist). To set up from scratch: + +1. Add `@powersync/nuxt` to the `modules` section of `nuxt.config.ts`: + +```typescript +import wasm from 'vite-plugin-wasm' + +export default defineNuxtConfig({ + modules: ['@powersync/nuxt'], + vite: { + optimizeDeps: { + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: ['@powersync/web > js-logger'], // <-- Include `js-logger` when it isn't installed and imported. + }, + worker: { + format: 'es', + plugins: () => [wasm()], + }, + }, +}) +``` + +> [!WARNING] +> If you are using Tailwind in your project see [Known Issues section](#known-issues) + +2. Create a PowerSync plugin (e.g., `plugins/powersync.client.ts`): + +```typescript +import { NuxtPowerSyncDatabase } from '@powersync/nuxt' +import { createPowerSyncPlugin } from '@powersync/nuxt' +import { AppSchema } from '~/powersync/AppSchema' +import { PowerSyncConnector } from '~/powersync/PowerSyncConnector' + +export default defineNuxtPlugin({ + async setup(nuxtApp) { + const db = new NuxtPowerSyncDatabase({ + database: { + dbFilename: 'your-db-filename.sqlite', + }, + schema: AppSchema, + }) + + const connector = new PowerSyncConnector() + + await db.init() + await db.connect(connector) + + const plugin = createPowerSyncPlugin({ database: db }) + nuxtApp.vueApp.use(plugin) + }, +}) +``` + +At this point, you're all set to use the module composables. The module automatically exposes all `@powersync/vue` composables, so you can use them directly: + +- `usePowerSync()` - Access the PowerSync database instance +- `useQuery()` - Query the database reactively +- And more... (see [API Reference](#api-reference)) + +## Setting up PowerSync + +This guide will walk you through the steps to set up PowerSync in your Nuxt project. + +### Create your Schema + +Create a file called `AppSchema.ts` and add your schema to it. + +```typescript +import { column, Schema, Table } from '@powersync/web' + +const lists = new Table({ + created_at: column.text, + name: column.text, + owner_id: column.text, +}) + +const todos = new Table( + { + list_id: column.text, + created_at: column.text, + completed_at: column.text, + description: column.text, + created_by: column.text, + completed_by: column.text, + completed: column.integer, + }, + { indexes: { list: ['list_id'] } }, +) + +export const AppSchema = new Schema({ + todos, + lists, +}) + +// For types +export type Database = (typeof AppSchema)['types'] +export type TodoRecord = Database['todos'] +export type ListRecord = Database['lists'] +``` + +> **Tip**: Learn more about how to create your schema [here](https://docs.powersync.com/client-sdk-references/javascript-web#1-define-the-schema). + +### Create your Connector + +Create a file called `PowerSyncConnector.ts` and add your connector to it. + +```typescript +import { UpdateType, type PowerSyncBackendConnector } from '@powersync/web' + +export class PowerSyncConnector implements PowerSyncBackendConnector { + async fetchCredentials() { + // Implement fetchCredentials to obtain a JWT from your authentication service. + // See https://docs.powersync.com/installation/authentication-setup + // If you're using Supabase or Firebase, you can re-use the JWT from those clients, see + // - https://docs.powersync.com/installation/authentication-setup/supabase-auth + // - https://docs.powersync.com/installation/authentication-setup/firebase-auth + return { + endpoint: '[Your PowerSync instance URL or self-hosted endpoint]', + // Use a development token (see Authentication Setup https://docs.powersync.com/installation/authentication-setup/development-tokens) to get up and running quickly + token: 'An authentication token', + } + } + + async uploadData(db: any) { + // Implement uploadData to send local changes to your backend service. + // You can omit this method if you only want to sync data from the database to the client + + // See example implementation here: https://docs.powersync.com/client-sdk-references/javascript-web#3-integrate-with-your-backend + // see demos here: https://github.com/powersync-ja/powersync-js/tree/main/demos + return + } +} +``` + +> **Tip**: Learn more about how to create your connector [here](https://docs.powersync.com/client-sdk-references/javascript-web#3-integrate-with-your-backend). + +### Create your PowerSync Plugin + +Finally, putting everything together, create a [plugin](https://nuxt.com/docs/4.x/guide/directory-structure/app/plugins) called `powersync.client.ts` to setup PowerSync. + +```typescript +import { createPowerSyncPlugin } from '@powersync/nuxt' +import { NuxtPowerSyncDatabase } from '@powersync/nuxt' +import { AppSchema } from '~/powersync/AppSchema' +import { PowerSyncConnector } from '~/powersync/PowerSyncConnector' + +export default defineNuxtPlugin({ + async setup(nuxtApp) { + const db = new NuxtPowerSyncDatabase({ + database: { + dbFilename: 'a-db-name.sqlite', + }, + schema: AppSchema, + }) + + const connector = new PowerSyncConnector() + + await db.init() + await db.connect(connector) + + const plugin = createPowerSyncPlugin({ database: db }) + + nuxtApp.vueApp.use(plugin) + }, +}) +``` + +### Kysely ORM (Optional) + +You can use Kysely as your ORM to interact with the database. The module optionally provides a `usePowerSyncKysely()` composable. To keep the bundle small, you must install the driver yourself and enable it in config. + +Install the driver: + +``` +pnpm add @powersync/kysely-driver +``` +In your existing `nuxt.config.ts`, set: + +```typescript +export default defineNuxtConfig({ + modules: ['@powersync/nuxt'], + powersync: { + kysely: true // <- opt-in + }, + vite: { + optimizeDeps: { + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: ['@powersync/web > js-logger'], + }, + worker: { + format: 'es', + plugins: () => [wasm()], + }, + }, +}) +``` + +When enabled, the module exposes `usePowerSyncKysely`. Use your schema’s `Database` type to get proper typings: + +```typescript +import { usePowerSyncKysely } from '@powersync/nuxt' +import { type Database } from '../powersync/AppSchema' + +// In your component or composable +const db = usePowerSyncKysely() + +// Use the db object to interact with the database +const users = await db.selectFrom('users').selectAll().execute() +``` + +### Enabling Diagnostics + +To enable the PowerSync Inspector with diagnostics capabilities: + +1. **Enable diagnostics in your config**: + +```typescript +export default defineNuxtConfig({ + modules: ['@powersync/nuxt'], + powersync: { + useDiagnostics: true, // <- Add this + }, + vite: { + optimizeDeps: { + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: ['@powersync/web > js-logger'], + }, + worker: { + format: 'es', + plugins: () => [wasm()], + }, + }, +}) +``` + +When `useDiagnostics: true` is set, `NuxtPowerSyncDatabase` automatically: +- Extend your schema with diagnostics schema +- Sets up diagnostics recording +- Stores the connector internally (accessible via diagnostics) +- Configures logging for diagnostics + + +2. **Accessing PowerSync Inspector**: + +Once diagnostics are enabled, you can access the [PowerSync Inspector](#powersync-inspector): + +- **Via Nuxt Devtools**: Open Devtools and look for the PowerSync tab +- **Direct URL**: `http://localhost:3000/__powersync-inspector` + + + +## PowerSync Inspector + +PowerSync Inspector is a tool that helps inspect and diagnose the state of your PowerSync client directly from your app in real-time. + +
+ PowerSync Logo +
+ +### Setup + +To setup the PowerSync inspector, you need to follow the steps in the [Enabling Diagnostics](#enabling-diagnostics) section. + +Once setup, the inspector can be accessed on the `http://localhost:3000/__powersync-inspector` route or via the [Nuxt Devtools](#nuxt-devtools). + +### Features + +#### Sync Status + +The `Sync Status` tab provides a real-time view of the sync status of your PowerSync client, including: +- Connection status +- Sync progress +- Upload queue statistics +- Error monitoring + +#### Data Inspector + +Browse and inspect your local database tables and data with powerful filtering and search capabilities. + +#### Config Inspector + +View and inspect your PowerSync configuration, connection options, and schema information. + +#### Logs + +Real-time logging of PowerSync operations with syntax highlighting and search functionality. + +#### Nuxt Devtools + +The inspector is also available in the Nuxt Devtools as a tab, providing seamless integration with your development workflow. + +## Known Issues + +1. PowerSync Inspector relies on `unocss` as a transitive dependency. It might clash with your existing setup, for example if you use Tailwind CSS. + +To fix this, you can add the following to your `nuxt.config.ts`: + +```typescript +export default defineNuxtConfig({ + unocss: { + autoImport: false + }, +}) +``` + +## Development + +```bash +# Install dependencies +pnpm install + +# Generate type stubs +pnpm run dev:prepare + +# Run Vitest +pnpm run test +pnpm run test:watch +``` + +## Local Testing + +If the playground is not enough for you, you can test the module locally by cloning this repo and pointing the nuxt app you want to test to the local module. + +Don't forget to add a watcher for the module for hot reloading. + +Example (in your nuxt app): + +```typescript +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['../../my-location/@powersync/nuxt/src/*'], + watch: ['../../my-location/@powersync/nuxt/src/*'], +}) +``` + + +[npm-version-src]: https://img.shields.io/npm/v/@powersync/nuxt/latest.svg?style=flat&colorA=18181B&colorB=28CF8D +[npm-version-href]: https://npmjs.com/package/@powersync/nuxt + +[npm-downloads-src]: https://img.shields.io/npm/dm/@powersync/nuxt.svg?style=flat&colorA=18181B&colorB=28CF8D +[npm-downloads-href]: https://npmjs.com/package/@powersync/nuxt + +[license-src]: https://img.shields.io/npm/l/@powersync/nuxt.svg?style=flat&colorA=18181B&colorB=28CF8D +[license-href]: https://npmjs.com/package/@powersync/nuxt + +[nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt.js +[nuxt-href]: https://nuxt.com diff --git a/packages/nuxt/img/inspector.png b/packages/nuxt/img/inspector.png new file mode 100644 index 000000000..2f8301c35 Binary files /dev/null and b/packages/nuxt/img/inspector.png differ diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json new file mode 100644 index 000000000..e81106908 --- /dev/null +++ b/packages/nuxt/package.json @@ -0,0 +1,95 @@ +{ + "name": "@powersync/nuxt", + "version": "0.0.0-dev-20260128023420", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "description": "PowerSync Nuxt module", + "license": "Apache-2.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/powersync-ja/powersync-js.git" + }, + "author": "POWERSYNC", + "bugs": { + "url": "https://github.com/powersync-ja/powersync-js/issues" + }, + "exports": { + ".": { + "types": "./dist/types.d.mts", + "import": "./dist/module.mjs" + } + }, + "main": "./dist/module.mjs", + "typesVersions": { + "*": { + ".": [ + "./dist/types.d.mts" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "prebuild": "nuxt-module-build prepare", + "build": "nuxt-module-build build", + "prebuild:prod": "nuxt-module-build prepare", + "build:prod": "nuxt-module-build build", + "clean": "rm -rf dist .nuxt", + "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare", + "watch": "nuxt-module-build build --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "test:exports": "attw --pack --profile=esm-only ." + }, + "homepage": "https://docs.powersync.com", + "dependencies": { + "@iconify-json/carbon": "^1.2.13", + "@nuxt/devtools-kit": "^2.6.2", + "@nuxt/devtools-ui-kit": "^2.6.2", + "@nuxt/kit": "^4.0.3", + "@tanstack/vue-table": "^8.21.3", + "@vueuse/core": "^14.0.0", + "@vueuse/nuxt": "^14.1.0", + "consola": "^3.4.2", + "defu": "^6.1.4", + "fuse.js": "^7.1.0", + "mitt": "^3.0.1", + "reka-ui": "^2.5.0", + "shiki": "^3.13.0", + "unocss": "^66.5.2", + "unstorage": "^1.17.1" + }, + "peerDependencies": { + "@journeyapps/wa-sqlite": "^1.2.6", + "@powersync/kysely-driver": "workspace:*", + "@powersync/vue": "workspace:*", + "@powersync/web": "workspace:*" + }, + "peerDependenciesMeta": { + "@powersync/kysely-driver": { + "optional": true + } + }, + "devDependencies": { + "@journeyapps/wa-sqlite": "^1.4.0", + "@nuxt/module-builder": "^1.0.2", + "async-mutex": "catalog:", + "bson": "catalog:", + "comlink": "catalog:", + "@nuxt/schema": "^4.1.2", + "@nuxt/test-utils": "^3.19.2", + "@powersync/kysely-driver": "workspace:*", + "@powersync/vue": "workspace:*", + "@powersync/web": "workspace:*", + "nuxt": "^4.1.2", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0", + "vitest": "^3.2.4", + "vue": "^3.5.20", + "vue-tsc": "^3.0.8" + } +} \ No newline at end of file diff --git a/packages/nuxt/src/devtools.ts b/packages/nuxt/src/devtools.ts new file mode 100644 index 000000000..6cf5da4b0 --- /dev/null +++ b/packages/nuxt/src/devtools.ts @@ -0,0 +1,28 @@ +import type { Nuxt } from 'nuxt/schema' +import { createResolver } from '@nuxt/kit' + +export function setupDevToolsUI(nuxt: Nuxt) { + const port = nuxt.options.devServer?.port || 3000 + const DEVTOOLS_UI_ROUTE = `http://localhost:${port}/__powersync-inspector` + + + // Devtools requires a URL starting with http:// or https:// to recognize it as an image otherwise it will be inferred as an Iconify icon + const iconUrl = `http://localhost:${port}/assets/powersync-icon.svg` + + nuxt.hook('devtools:customTabs', (tabs: any[]) => { + tabs.push({ + // unique identifier + name: 'powersync-inspector', + // title to display in the tab + title: 'Powersync Inspector', + // any icon from Iconify, or a URL to an image + // Using HTTP URL so devtools recognizes it as an image URL + icon: iconUrl, + // iframe view + view: { + type: 'iframe', + src: DEVTOOLS_UI_ROUTE, + }, + }) + }) +} diff --git a/packages/nuxt/src/index.ts b/packages/nuxt/src/index.ts new file mode 100644 index 000000000..44514763f --- /dev/null +++ b/packages/nuxt/src/index.ts @@ -0,0 +1,24 @@ +/** + * @packageDocumentation + * + * PowerSync Nuxt Module - Public API + * + * This module provides PowerSync integration for Nuxt applications with built-in diagnostics + * and inspector capabilities. + */ + +// Module Options +export type { PowerSyncNuxtModuleOptions } from './module.js' + +// Database Class +export { NuxtPowerSyncDatabase } from './runtime/utils/NuxtPowerSyncDatabase.js' + +// Composables +export { usePowerSyncKysely } from './runtime/composables/usePowerSyncKysely.js' +export { useDiagnosticsLogger } from './runtime/composables/useDiagnosticsLogger.js' +export { usePowerSyncInspector } from './runtime/composables/usePowerSyncInspector.js' +export { usePowerSyncInspectorDiagnostics } from './runtime/composables/usePowerSyncInspectorDiagnostics.js' +export type { + UsePowerSyncInspectorDiagnosticsReturn, + UsePowerSyncInspectorDiagnosticsTotals, +} from './runtime/composables/usePowerSyncInspectorDiagnostics.js' diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts new file mode 100644 index 000000000..add2782bc --- /dev/null +++ b/packages/nuxt/src/module.ts @@ -0,0 +1,227 @@ +import { + defineNuxtModule, + createResolver, + addPlugin, + addImports, + extendPages, + addLayout, + addComponentsDir, + findPath, +} from '@nuxt/kit' +import { defu } from 'defu' +import { setupDevToolsUI } from './devtools' +import { addImportsFrom } from './runtime/utils/addImportsFrom' + +/** + * Configuration options for the PowerSync Nuxt module. + * + * @example + * ```typescript + * export default defineNuxtConfig({ + * modules: ['@powersync/nuxt'], + * powersync: { + * useDiagnostics: true, + * }, + * }) + * ``` + */ +export interface PowerSyncNuxtModuleOptions { + /** + * Enable diagnostics and the PowerSync Inspector. + * + * When set to `true`, enables diagnostics recording and makes the PowerSync Inspector available. + * The inspector provides real-time monitoring, data inspection, and debugging tools. + * + * @default false + */ + useDiagnostics?: boolean + /** + * Enable Kysely integration. + * + * When set to `true`, enables the `usePowerSyncKysely` composable for type-safe database queries. + * Requires `@powersync/kysely-driver` to be installed. + * + * @default false + */ + kysely?: boolean +} + +export default defineNuxtModule({ + meta: { + name: 'powersync-nuxt', + configKey: 'powersync', + }, + // Default configuration options of the Nuxt module + defaults: { + useDiagnostics: false, + kysely: false, + }, + moduleDependencies: { + '@nuxt/devtools-ui-kit': {}, + '@vueuse/nuxt': {}, + }, + async setup(options, nuxt) { + const resolver = createResolver(import.meta.url) + + nuxt.options.runtimeConfig.public.powerSyncModuleOptions = defu( + + nuxt.options.runtimeConfig.public.powerSyncModuleOptions as any, + { + useDiagnostics: options.useDiagnostics, + kysely: options.kysely, + }, + ) + + if (options.kysely) { + const kyselyDriverPath = await findPath('@powersync/kysely-driver') + + if (!kyselyDriverPath) { + throw new Error( + '[@powersync/nuxt] The `kysely` option requires @powersync/kysely-driver to be installed.\n' + + 'Run: npm install @powersync/kysely-driver' + ) + } + } + + addPlugin(resolver.resolve('./runtime/plugin.client')) + + // expose the composables + addImports({ + name: 'NuxtPowerSyncDatabase', + from: resolver.resolve( + './runtime/utils/NuxtPowerSyncDatabase', + ), + }) + + addImports({ + name: 'usePowerSyncInspector', + from: resolver.resolve('./runtime/composables/usePowerSyncInspector'), + }) + + addImports({ + name: 'usePowerSyncInspectorDiagnostics', + from: resolver.resolve( + './runtime/composables/usePowerSyncInspectorDiagnostics', + ), + }) + + // Conditionally add Kysely composable if enabled + if (options.kysely) { + addImports({ + name: 'usePowerSyncKysely', + from: resolver.resolve('./runtime/composables/usePowerSyncKysely'), + }) + } + + addImports({ + name: 'useDiagnosticsLogger', + from: resolver.resolve('./runtime/composables/useDiagnosticsLogger'), + }) + + // From the runtime directory + addComponentsDir({ + path: resolver.resolve('runtime/components'), + }) + + addLayout( + resolver.resolve('./runtime/layouts/powersync-inspector-layout.vue'), + 'powersync-inspector-layout', + ) + + extendPages((pages) => { + pages.push({ + path: '/__powersync-inspector', + // file: resolver.resolve("#build/pages/__powersync-inspector.vue"), + file: resolver.resolve('./runtime/pages/__powersync-inspector.vue'), + name: 'Powersync Inspector', + }) + }) + + addImportsFrom([ + 'createPowerSyncPlugin', + 'providePowerSync', + 'usePowerSync', + 'usePowerSyncQuery', + 'usePowerSyncStatus', + 'usePowerSyncWatchedQuery', + 'useQuery', + 'useStatus', + 'useWatchedQuerySubscription', + ], '@powersync/vue') + + // Ensure the packages are transpiled + nuxt.options.build.transpile = nuxt.options.build.transpile || [] + nuxt.options.build.transpile.push('reka-ui', '@tanstack/vue-table', '@powersync/web', '@journeyapps/wa-sqlite') + + // Conditionally add Kysely driver to transpile list if enabled + if (options.kysely) { + nuxt.options.build.transpile.push('@powersync/kysely-driver') + } + + nuxt.hooks.hook('prepare:types', ({ references }: { references: any[] }) => { + references.push({ types: '@powersync/web' }) + references.push({ types: '@journeyapps/wa-sqlite' }) + + // Conditionally add Kysely types if enabled + if (options.kysely) { + references.push({ types: '@powersync/kysely-driver' }) + } + }) + + // Make assets available to runtime files via Vite resolve alias + // Configure Vite to resolve ./assets/* imports from the layout to the module's assets directory + // This allows: import iconUrl from './assets/powersync-icon.svg?url' + const assetsDir = resolver.resolve('./runtime/assets') + + nuxt.options.vite = nuxt.options.vite || {} + nuxt.options.vite.resolve = nuxt.options.vite.resolve || {} + + const existingAlias = nuxt.options.vite.resolve.alias || [] + const aliasArray = Array.isArray(existingAlias) + ? [...existingAlias] + : Object.entries(existingAlias).map(([find, replacement]) => ({ + find, + replacement: replacement as string + })) + + // Add alias for assets directory - matches ./assets/* pattern from layout files + aliasArray.push({ + find: /^\.\/assets\/(.+)$/, + replacement: `${assetsDir}/$1`, + }) + + nuxt.options.vite.resolve.alias = aliasArray + + // making the asset available via HTTP for devtools + // this Add a Vite plugin to serve the asset at /assets/powersync-icon.svg + nuxt.hook('vite:extendConfig', async (config, { isClient }) => { + if (!isClient) return + + const { readFileSync } = await import('node:fs') + const assetPath = resolver.resolve('./runtime/assets/powersync-icon.svg') + const vitePlugin = { + name: 'powersync-assets', + configureServer(server: any) { + // Serve the asset at /assets/powersync-icon.svg + server.middlewares.use('/assets/powersync-icon.svg', (req: any, res: any, next: any) => { + try { + const content = readFileSync(assetPath) + res.setHeader('Content-Type', 'image/svg+xml') + res.end(content) + } catch { + next() + } + }) + }, + } + + // Add plugin to existing plugins array + const plugins = config.plugins || [] + plugins.push(vitePlugin) + // @ts-ignore - plugins is read-only but we need to modify it + config.plugins = plugins + }) + + setupDevToolsUI(nuxt) + }, +}) diff --git a/packages/nuxt/src/nuxt-devtools.d.ts b/packages/nuxt/src/nuxt-devtools.d.ts new file mode 100644 index 000000000..b94ebadd0 --- /dev/null +++ b/packages/nuxt/src/nuxt-devtools.d.ts @@ -0,0 +1,7 @@ +declare module 'nuxt/schema' { + interface NuxtHooks { + 'devtools:customTabs': (tabs: any[]) => void + } +} + +export {} diff --git a/packages/nuxt/src/runtime/assets/powersync-icon.svg b/packages/nuxt/src/runtime/assets/powersync-icon.svg new file mode 100644 index 000000000..441e71289 --- /dev/null +++ b/packages/nuxt/src/runtime/assets/powersync-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/nuxt/src/runtime/components/BucketsInspectorTab.vue b/packages/nuxt/src/runtime/components/BucketsInspectorTab.vue new file mode 100644 index 000000000..893393128 --- /dev/null +++ b/packages/nuxt/src/runtime/components/BucketsInspectorTab.vue @@ -0,0 +1,707 @@ + + + + + diff --git a/packages/nuxt/src/runtime/components/ConfigInspectorTab.vue b/packages/nuxt/src/runtime/components/ConfigInspectorTab.vue new file mode 100644 index 000000000..39d3b725b --- /dev/null +++ b/packages/nuxt/src/runtime/components/ConfigInspectorTab.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/packages/nuxt/src/runtime/components/DataInspectorTab.vue b/packages/nuxt/src/runtime/components/DataInspectorTab.vue new file mode 100644 index 000000000..4f356c5fd --- /dev/null +++ b/packages/nuxt/src/runtime/components/DataInspectorTab.vue @@ -0,0 +1,857 @@ +