Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pocketbase-addon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/create': minor
---

Add a React PocketBase add-on. `--add-ons pocketbase` wires the PocketBase JS SDK into a TanStack Start app, with a migration for an owner-scoped `todos` collection and a `/demo/pocketbase` route with email and password sign-in and a private realtime todo list.
143 changes: 143 additions & 0 deletions packages/cli/tests-e2e/pocketbase-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { createServer } from 'node:http'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, test } from '@playwright/test'
import { attachRuntimeGuards, createReactAppFixture } from './helpers'

function createPocketBaseFixture() {
const requests: Array<string> = []
const server = createServer((request, response) => {
requests.push(request.url ?? '')
response.writeHead(request.method === 'OPTIONS' ? 204 : 200, {
'access-control-allow-origin': '*',
'access-control-allow-headers': '*',
'content-type': 'application/json',
})
response.end(
request.method === 'OPTIONS'
? undefined
: JSON.stringify({ code: 200, message: 'API is healthy.', data: {} }),
)
})

return {
requests,
start: (port: number) =>
new Promise<number>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
reject(new Error('Missing PocketBase fixture port'))
return
}
resolve(address.port)
})
}),
stop: () =>
new Promise<void>((resolve, reject) => {
if (!server.listening) {
resolve()
return
}
server.close((error) => (error ? reject(error) : resolve()))
}),
}
}

test('@matrix builds and renders the PocketBase add-on with a local server fixture', async ({
page,
}) => {
const server = createPocketBaseFixture()
const port = await server.start(0)
try {
const fixture = await createReactAppFixture({
appName: 'react-pocketbase-smoke',
addOns: ['pocketbase'],
runQualityGatesChecks: true,
afterCreate: async (appDir) => {
await writeFile(
join(appDir, '.env.local'),
`VITE_POCKETBASE_URL=http://127.0.0.1:${port}\n`,
)
},
})
const guards = attachRuntimeGuards(page, fixture.url)
try {
await page.goto(`${fixture.url}/demo/pocketbase`)
await expect(
page.getByRole('heading', { name: 'Sign in to see your todos' }),
).toBeVisible()
await expect(page.getByLabel('Password')).toBeVisible()
expect(server.requests.some((url) => url.startsWith('/api/health'))).toBe(
true,
)
} finally {
try {
guards.assertClean()
} finally {
guards.dispose()
await fixture.stop()
await fixture.cleanup()
}
}
} finally {
await server.stop()
}
})

test('@matrix recovers the PocketBase demo after the server starts', async ({
page,
}) => {
const server = createPocketBaseFixture()
// Reserve a free port, then stop so the app starts with no server running.
const port = await server.start(0)
await server.stop()
try {
const fixture = await createReactAppFixture({
appName: 'react-pocketbase-recovery',
addOns: ['pocketbase'],
afterCreate: async (appDir) => {
await writeFile(
join(appDir, '.env.local'),
`VITE_POCKETBASE_URL=http://127.0.0.1:${port}\n`,
)
},
})
try {
await page.goto(`${fixture.url}/demo/pocketbase`)
await expect(
page.getByRole('heading', { name: 'PocketBase is not ready' }),
).toBeVisible()

const retry = page.getByRole('button', { name: 'Try again' })
await retry.click()
await expect(retry).toBeEnabled()
await expect(
page.getByRole('heading', { name: 'PocketBase is not ready' }),
).toBeVisible()

await server.start(port)
// The stopped server's connection errors are expected, so only guard
// what happens from recovery onward.
const guards = attachRuntimeGuards(page, fixture.url)
try {
await retry.click()
await expect(
page.getByRole('heading', { name: 'Sign in to see your todos' }),
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'PocketBase is not ready' }),
).toBeHidden()
guards.assertClean()
} finally {
guards.dispose()
}
} finally {
await fixture.stop()
await fixture.cleanup()
}
} finally {
await server.stop()
}
})
40 changes: 40 additions & 0 deletions packages/create/src/frameworks/react/add-ons/pocketbase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## Setting up PocketBase

This project uses [PocketBase](https://pocketbase.io), an open source backend in a single file: an embedded SQLite database, auth, realtime subscriptions, file storage and an admin dashboard.

1. [Download PocketBase](https://pocketbase.io/docs/) for your platform and place the `pocketbase` binary in the root of this project.
2. Start the server:

```bash
./pocketbase serve
```

On first start, PocketBase applies the migration in `pb_migrations/`, which creates the `todos` collection used by the demo.

3. Open the admin dashboard at http://127.0.0.1:8090/_/ and create your superuser account.
4. Make sure `VITE_POCKETBASE_URL` in `.env.local` points to your PocketBase server (defaults to `http://127.0.0.1:8090`).
5. Run the app, visit `/demo/pocketbase` and create an account.

### What the add-on includes

- `src/lib/pocketbase.ts`: PocketBase JS SDK client (`pb`) and a `createPocketBase()` factory for per-request clients in server functions.
- `src/routes/demo/pocketbase.tsx`: email and password sign-in, plus a private todo list with realtime updates.
- `pb_migrations/`: JS migrations applied automatically by `pocketbase serve`.
- `.gitignore` entries for the `pocketbase` binary and `pb_data/`, which holds your local database and uploaded files.

### Permissions

Each todo has an `owner` relation to the built-in `users` collection, and the collection's API rules enforce ownership on the server:

| Action | Rule |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| List, view, delete | `@request.auth.id != "" && owner = @request.auth.id` |
| Create | `@request.auth.id != "" && @request.body.owner = @request.auth.id` |
| Update | The list rule, plus `(@request.body.owner:isset = false \|\| @request.body.owner = @request.auth.id)` |

Realtime subscriptions follow the list rule, so users only receive events for their own todos. Deleting a user also deletes their todos.

> [!IMPORTANT]
> PocketBase's default `users` collection allows public sign-ups. Review its API rules and enable email verification before deploying.

Learn more in the [PocketBase docs](https://pocketbase.io/docs/) and the [JS SDK docs](https://github.com/pocketbase/js-sdk).
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# PocketBase server URL (run `./pocketbase serve` locally, default http://127.0.0.1:8090)
VITE_POCKETBASE_URL=http://127.0.0.1:8090
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# PocketBase server binary and local data
/pocketbase
/pocketbase.exe
pb_data/
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/// <reference path="../pb_data/types.d.ts" />

// Creates the `todos` collection used by the /demo/pocketbase route.
// Applied automatically when running `./pocketbase serve`.
//
// Every todo belongs to the user in `owner`. The API rules below let users
// read and change only their own todos (realtime events follow `listRule`),
// and stop them from creating todos for someone else or handing theirs over.
const ownerOnly = '@request.auth.id != "" && owner = @request.auth.id'

migrate(
(app) => {
const users = app.findCollectionByNameOrId('users')

const collection = new Collection({
type: 'base',
name: 'todos',
listRule: ownerOnly,
viewRule: ownerOnly,
createRule:
'@request.auth.id != "" && @request.body.owner = @request.auth.id',
updateRule: `${ownerOnly} && (@request.body.owner:isset = false || @request.body.owner = @request.auth.id)`,
deleteRule: ownerOnly,
fields: [
{ name: 'text', type: 'text', required: true, max: 500 },
{ name: 'completed', type: 'bool' },
{
name: 'owner',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: ['CREATE INDEX idx_todos_owner ON todos (owner)'],
})

app.save(collection)
},
(app) => {
const collection = app.findCollectionByNameOrId('todos')
app.delete(collection)
},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import PocketBase from 'pocketbase'

export const POCKETBASE_URL =
import.meta.env.VITE_POCKETBASE_URL || 'http://127.0.0.1:8090'

/**
* Browser-side PocketBase client.
*
* The auth store of this instance is persisted in localStorage, so use it
* from client components only. For server functions, create a fresh client
* per request with `createPocketBase()` so auth state is never shared
* between users.
*/
export const pb = createPocketBase()

export function createPocketBase() {
const client = new PocketBase(POCKETBASE_URL)
// Avoid cancelling duplicated requests (e.g. React StrictMode double effects).
client.autoCancellation(false)
return client
}
Loading