@sveltebase/sync 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -510
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/live-query.svelte.d.ts.map +1 -1
- package/dist/client/status.svelte.d.ts.map +1 -1
- package/dist/cloudflare/index.d.ts +17 -0
- package/dist/cloudflare/index.d.ts.map +1 -0
- package/dist/cloudflare/index.js +116 -0
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/server/broker.d.ts +2 -2
- package/dist/server/broker.d.ts.map +1 -1
- package/dist/server/broker.js +1 -0
- package/dist/server/engine.d.ts +5 -2
- package/dist/server/engine.d.ts.map +1 -1
- package/dist/server/engine.js +4 -7
- package/dist/server/handler.d.ts +14 -35
- package/dist/server/handler.d.ts.map +1 -1
- package/dist/server/handler.js +67 -130
- package/dist/server/index.d.ts +12 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/sveltekit/index.d.ts +10 -0
- package/dist/sveltekit/index.d.ts.map +1 -0
- package/dist/sveltekit/index.js +29 -0
- package/package.json +9 -9
- package/dist/global.d.ts +0 -25
- package/dist/server/dev-engine.d.ts +0 -16
- package/dist/server/dev-engine.d.ts.map +0 -1
- package/dist/server/dev-engine.js +0 -160
- package/dist/vite.d.ts +0 -10
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -62
package/README.md
CHANGED
|
@@ -1,585 +1,203 @@
|
|
|
1
1
|
# @sveltebase/sync
|
|
2
2
|
|
|
3
|
-
Reactive, local-first database synchronization
|
|
3
|
+
Reactive, local-first database synchronization for Svelte 5 using a separate Cloudflare Worker for realtime sync.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Architecture
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Optimistic Updates**: Client mutations update the local database instantly (~1ms), sync with the server in the background, and roll back automatically on failures.
|
|
9
|
-
- **Real-Time Sync**: Single multiplexed WebSocket connection fanning out updates to all active subscribers.
|
|
10
|
-
- **Last-Write-Wins (LWW)**: Timestamps prevent out-of-order write conflicts.
|
|
11
|
-
- **Delta Syncing (Incremental Load)**: Automatically pulls only modified records since the last sync time to conserve network bandwidth.
|
|
12
|
-
- **Hibernate Friendly**: Client-initiated heartbeats allow Cloudflare Durable Objects to sleep when idle, cutting active execution costs down to near zero.
|
|
13
|
-
- **Vite Integration**: Custom dev plugin simulating Durable Objects and bindings proxy locally without full worker compilation loops.
|
|
14
|
-
- **Database Agnostic**: Completely decoupled from the underlying storage. You are not locked into Cloudflare D1; the sync handler hooks (`fetch`, `create`, `update`, `delete`) are simple, asynchronous JavaScript callbacks where you can connect to PostgreSQL, MySQL, Supabase, Neon, MongoDB, or any database of your choice.
|
|
7
|
+
`@sveltebase/sync` now uses two Workers:
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
The **Josh Thomas fork** introduces a platform entrypoint (`src/platform.cloudflare.ts`) which SvelteKit bundles into the worker wrapper, allowing you to export Durable Objects while SvelteKit continues to manage Svelte routing and page rendering.
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## 1. Cloudflare Configuration (`wrangler.jsonc`)
|
|
32
|
-
|
|
33
|
-
Define D1 database and Durable Object namespace configurations in your `wrangler.jsonc` (or `wrangler.toml`):
|
|
34
|
-
|
|
35
|
-
```json
|
|
36
|
-
{
|
|
37
|
-
"compatibility_date": "2026-06-07",
|
|
38
|
-
"compatibility_flags": ["nodejs_compat"],
|
|
39
|
-
"main": ".svelte-kit/cloudflare/_worker.js",
|
|
40
|
-
"d1_databases": [
|
|
41
|
-
{
|
|
42
|
-
"binding": "DB",
|
|
43
|
-
"database_name": "sveltebase-sync",
|
|
44
|
-
"database_id": "YOUR_DATABASE_ID",
|
|
45
|
-
"migrations_dir": "drizzle/migrations"
|
|
46
|
-
}
|
|
47
|
-
],
|
|
48
|
-
"durable_objects": {
|
|
49
|
-
"bindings": [
|
|
50
|
-
{
|
|
51
|
-
"name": "SYNC_ENGINE",
|
|
52
|
-
"class_name": "SyncEngine"
|
|
53
|
-
}
|
|
54
|
-
]
|
|
55
|
-
},
|
|
56
|
-
"migrations": [
|
|
57
|
-
{
|
|
58
|
-
"tag": "v1",
|
|
59
|
-
"new_sqlite_classes": ["SyncEngine"]
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
}
|
|
9
|
+
```txt
|
|
10
|
+
browser
|
|
11
|
+
-> SvelteKit app Worker
|
|
12
|
+
/api/sync -> env.SYNC_WORKER.fetch(request)
|
|
13
|
+
-> sync Worker
|
|
14
|
+
owns SyncEngine Durable Object
|
|
15
|
+
owns websocket upgrades
|
|
16
|
+
owns broadcasts
|
|
17
|
+
owns sync database/runtime bindings
|
|
18
|
+
owns sync auth verification
|
|
63
19
|
```
|
|
64
20
|
|
|
65
|
-
|
|
21
|
+
The SvelteKit app Worker does not export sync Durable Objects. This lets apps use the official `@sveltejs/adapter-cloudflare`.
|
|
66
22
|
|
|
67
|
-
##
|
|
23
|
+
## Imports
|
|
68
24
|
|
|
69
|
-
|
|
25
|
+
```ts
|
|
26
|
+
import { SyncClient, createLiveQuery } from "@sveltebase/sync/client";
|
|
27
|
+
import { defineSync, createPublisher } from "@sveltebase/sync/server";
|
|
28
|
+
import { syncProxy } from "@sveltebase/sync/sveltekit";
|
|
29
|
+
import { defineSyncWorker, SyncEngine } from "@sveltebase/sync/cloudflare";
|
|
30
|
+
```
|
|
70
31
|
|
|
71
|
-
|
|
32
|
+
## Client
|
|
72
33
|
|
|
73
|
-
```
|
|
34
|
+
```ts
|
|
74
35
|
// src/lib/sync-client.ts
|
|
75
36
|
import { SyncClient } from "@sveltebase/sync/client";
|
|
76
|
-
import type { Todo } from "$lib/server/db/schema";
|
|
77
37
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
38
|
+
type AppSchema = {
|
|
39
|
+
todos: {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
completed: boolean;
|
|
43
|
+
updatedAt: string;
|
|
44
|
+
};
|
|
81
45
|
};
|
|
82
46
|
|
|
83
|
-
export const sync = new SyncClient<
|
|
84
|
-
name: "
|
|
85
|
-
url: "/api/sync",
|
|
47
|
+
export const sync = new SyncClient<AppSchema>({
|
|
48
|
+
name: "app-sync",
|
|
49
|
+
url: "/api/sync",
|
|
86
50
|
tables: {
|
|
87
51
|
todos: {
|
|
88
|
-
indexes: "id, completed,
|
|
89
|
-
channel: "todos",
|
|
52
|
+
indexes: "id, completed, updatedAt",
|
|
53
|
+
channel: "todos",
|
|
90
54
|
},
|
|
91
55
|
},
|
|
92
56
|
});
|
|
93
57
|
```
|
|
94
58
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
```svelte
|
|
98
|
-
<script lang="ts">
|
|
99
|
-
import { sync } from "$lib/sync-client";
|
|
100
|
-
import { createLiveQuery } from "@sveltebase/sync/client";
|
|
101
|
-
import { Check, Trash } from "lucide-svelte";
|
|
102
|
-
|
|
103
|
-
const todosQuery = createLiveQuery(() =>
|
|
104
|
-
sync.todos.orderBy("createdAt").reverse().toArray()
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
let title = "";
|
|
108
|
-
|
|
109
|
-
async function addTodo() {
|
|
110
|
-
if (!title.trim()) return;
|
|
111
|
-
await sync.todos.add({
|
|
112
|
-
id: crypto.randomUUID(),
|
|
113
|
-
title,
|
|
114
|
-
completed: false,
|
|
115
|
-
createdAt: new Date().toISOString(),
|
|
116
|
-
updatedAt: new Date().toISOString(),
|
|
117
|
-
});
|
|
118
|
-
title = "";
|
|
119
|
-
}
|
|
120
|
-
</script>
|
|
121
|
-
|
|
122
|
-
<input bind:value={title} onkeydown={(e) => e.key === 'Enter' && addTodo()} />
|
|
123
|
-
|
|
124
|
-
{#if todosQuery.isLoading}
|
|
125
|
-
<p>Loading...</p>
|
|
126
|
-
{:else if todosQuery.error}
|
|
127
|
-
<p>Failed to load todos.</p>
|
|
128
|
-
{:else}
|
|
129
|
-
{#each (todosQuery.data || []) as todo (todo.id)}
|
|
130
|
-
<div>
|
|
131
|
-
<button onclick={() => sync.todos.update(todo.id, { completed: !todo.completed })}>
|
|
132
|
-
<Check class={todo.completed ? "text-emerald-500" : ""} />
|
|
133
|
-
</button>
|
|
134
|
-
<span>{todo.title}</span>
|
|
135
|
-
<button onclick={() => sync.todos.delete(todo.id)}><Trash /></button>
|
|
136
|
-
</div>
|
|
137
|
-
{/each}
|
|
138
|
-
{/if}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
`createLiveQuery` accepts a Dexie query function and an optional dependency getter. When any dependency changes, the live query is recreated with the latest reactive values:
|
|
142
|
-
|
|
143
|
-
```typescript
|
|
144
|
-
const query = createLiveQuery(
|
|
145
|
-
() => sync.todos.where("completed").equals(false).toArray(),
|
|
146
|
-
() => [filterValue]
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
query.data;
|
|
150
|
-
query.isLoading;
|
|
151
|
-
query.error;
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
You can import it from either `@sveltebase/sync/client` or the root `@sveltebase/sync` entrypoint.
|
|
155
|
-
|
|
156
|
-
### Synced Database Operations
|
|
157
|
-
|
|
158
|
-
Under the hood, `@sveltebase/sync` intercepts native Dexie table writes to capture and propagate mutations to the backend. The following methods automatically sync with the server:
|
|
159
|
-
|
|
160
|
-
* **`.add(row)`**: Triggers a `"create"` sync mutation.
|
|
161
|
-
* **`.put(row)` or `.put(id, changes)`**: Computes a diff of changed properties (for updates) or initiates a `"create"` mutation (for new rows) and sends it to the server.
|
|
162
|
-
* **`.update(id, changes)`**: Performs a local partial update and propagates the changes to the server as an `"update"` mutation.
|
|
163
|
-
* **`.delete(id)`**: Locally deletes the row and propagates a `"delete"` mutation to the server.
|
|
164
|
-
|
|
165
|
-
> [!NOTE]
|
|
166
|
-
> **Bulk methods** (such as `.bulkAdd()`, `.bulkPut()`, and `.bulkDelete()`) bypass backend syncing entirely. They write directly to IndexedDB, which is useful for performing offline seeding or local-only updates.
|
|
59
|
+
## Sync Handlers
|
|
167
60
|
|
|
168
|
-
|
|
61
|
+
Handlers run in the sync Worker. Use `ctx.platform.env` for Cloudflare bindings, `ctx.auth` for verified auth data, and `ctx.identity` for ownership/scoped fanout.
|
|
169
62
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
> **Database Agnostic (No Lock-In):**
|
|
174
|
-
> While the examples below connect to **Cloudflare D1 SQLite** (using Drizzle ORM), `@sveltebase/sync` is completely database-agnostic. The `fetch`, `create`, `update`, and `delete` handlers are standard asynchronous functions. You can fetch, save, or delete data using **any database** of your choice (PostgreSQL, MySQL, Supabase, Neon, MongoDB, etc.) by writing the appropriate database connection logic inside these hooks.
|
|
175
|
-
|
|
176
|
-
Define the handlers that translate IndexedDB operations (fetch, create, update, delete) to database queries:
|
|
177
|
-
|
|
178
|
-
```typescript
|
|
179
|
-
// src/lib/server/sync-todos.ts
|
|
180
|
-
import { defineSync } from "@sveltebase/sync";
|
|
181
|
-
import { getDB } from "$lib/server/db/index.js";
|
|
182
|
-
import { todos } from "$lib/server/db/schema";
|
|
183
|
-
import { desc, eq, gt } from "drizzle-orm";
|
|
184
|
-
import type { Todo } from "$lib/server/db/schema";
|
|
63
|
+
```ts
|
|
64
|
+
// src/lib/server/sync-handlers.ts
|
|
65
|
+
import { defineSync } from "@sveltebase/sync/server";
|
|
185
66
|
|
|
186
|
-
export const todoSync = defineSync
|
|
67
|
+
export const todoSync = defineSync({
|
|
187
68
|
channel: "todos",
|
|
188
69
|
|
|
189
70
|
fetch: async (ctx, since) => {
|
|
190
|
-
const db =
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
.select()
|
|
194
|
-
.from(todos)
|
|
195
|
-
.where(gt(todos.updatedAt, since))
|
|
196
|
-
.orderBy(desc(todos.createdAt));
|
|
197
|
-
}
|
|
198
|
-
return await db.select().from(todos).orderBy(desc(todos.createdAt));
|
|
199
|
-
},
|
|
200
|
-
|
|
201
|
-
create: async (ctx, data) => {
|
|
202
|
-
const db = getDB(ctx.platform);
|
|
203
|
-
const [created] = await db
|
|
204
|
-
.insert(todos)
|
|
205
|
-
.values(data)
|
|
206
|
-
.onConflictDoUpdate({
|
|
207
|
-
target: todos.id,
|
|
208
|
-
set: {
|
|
209
|
-
title: data.title,
|
|
210
|
-
completed: data.completed,
|
|
211
|
-
updatedAt: new Date().toISOString(),
|
|
212
|
-
},
|
|
213
|
-
})
|
|
214
|
-
.returning();
|
|
215
|
-
return created;
|
|
71
|
+
const db = ctx.platform.env.DB;
|
|
72
|
+
// Query any database here.
|
|
73
|
+
return [];
|
|
216
74
|
},
|
|
217
75
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.set({ ...changes, updatedAt: new Date().toISOString() })
|
|
223
|
-
.where(eq(todos.id, key))
|
|
224
|
-
.returning();
|
|
225
|
-
return updated;
|
|
76
|
+
authorize: async (ctx) => {
|
|
77
|
+
if (!ctx.auth) {
|
|
78
|
+
throw new Error("Unauthorized");
|
|
79
|
+
}
|
|
226
80
|
},
|
|
227
81
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
await db.delete(todos).where(eq(todos.id, key));
|
|
82
|
+
scope: (ctx) => {
|
|
83
|
+
return ctx.identity ? [ctx.identity] : [];
|
|
231
84
|
},
|
|
232
85
|
});
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
Export handlers from a single list:
|
|
236
|
-
```typescript
|
|
237
|
-
// src/lib/server/sync-handlers.ts
|
|
238
|
-
import { todoSync } from "./sync-todos.js";
|
|
239
86
|
|
|
240
87
|
export const handlers = [todoSync];
|
|
241
88
|
```
|
|
242
89
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
### Step 3: SvelteKit WebSocket Server Route
|
|
246
|
-
|
|
247
|
-
Set up the upgrade endpoint to forward SvelteKit HTTP upgrades to Durable Objects.
|
|
248
|
-
|
|
249
|
-
```typescript
|
|
250
|
-
// src/routes/api/sync/+server.ts
|
|
251
|
-
import { handleUpgrade } from "@sveltebase/sync";
|
|
252
|
-
import type { RequestEvent, RequestHandler } from "@sveltejs/kit";
|
|
253
|
-
|
|
254
|
-
export const GET: RequestHandler = (event: RequestEvent) => {
|
|
255
|
-
return handleUpgrade(event.request, event.platform);
|
|
256
|
-
};
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
---
|
|
90
|
+
## Sync Worker
|
|
260
91
|
|
|
261
|
-
|
|
92
|
+
Create a standalone Worker entrypoint that owns the Durable Object:
|
|
262
93
|
|
|
263
|
-
|
|
94
|
+
```ts
|
|
95
|
+
// src/worker/sync.ts
|
|
96
|
+
import { jwtCookieAuth } from "@sveltebase/auth/sync";
|
|
97
|
+
import { defineSyncWorker, SyncEngine } from "@sveltebase/sync/cloudflare";
|
|
98
|
+
import { handlers } from "$lib/server/sync-handlers";
|
|
264
99
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
100
|
+
export default defineSyncWorker({
|
|
101
|
+
handlers,
|
|
102
|
+
auth: jwtCookieAuth(),
|
|
103
|
+
});
|
|
268
104
|
|
|
269
|
-
export
|
|
270
|
-
kit: {
|
|
271
|
-
adapter: adapter({
|
|
272
|
-
platform: "src/platform.cloudflare.ts" // Platform config file
|
|
273
|
-
})
|
|
274
|
-
}
|
|
275
|
-
};
|
|
105
|
+
export { SyncEngine };
|
|
276
106
|
```
|
|
277
107
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
```typescript
|
|
281
|
-
// src/platform.cloudflare.ts
|
|
282
|
-
import { SyncEngineBase } from "@sveltebase/sync/server";
|
|
283
|
-
import { handlers } from "./lib/server/sync-handlers.js";
|
|
108
|
+
`defineSyncWorker()` handles:
|
|
284
109
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
super(ctx, env, handlers);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
---
|
|
110
|
+
- `GET /api/sync`: public websocket upgrade endpoint
|
|
111
|
+
- `POST /broadcast`: publish one external change
|
|
112
|
+
- `POST /broadcast-batch`: publish a batch of external changes
|
|
294
113
|
|
|
295
|
-
|
|
114
|
+
`GET /websocket` is internal to the sync Worker and Durable Object.
|
|
296
115
|
|
|
297
|
-
|
|
116
|
+
## SvelteKit Proxy Route
|
|
298
117
|
|
|
299
|
-
|
|
118
|
+
Keep browsers connecting to the app origin so existing cookies are sent:
|
|
300
119
|
|
|
301
|
-
```
|
|
302
|
-
//
|
|
303
|
-
import {
|
|
304
|
-
import {
|
|
305
|
-
import { syncDevPlugin } from "@sveltebase/sync/vite";
|
|
120
|
+
```ts
|
|
121
|
+
// src/routes/api/sync/+server.ts
|
|
122
|
+
import { SYNC_WORKER_URL } from "$env/static/private";
|
|
123
|
+
import { syncProxy } from "@sveltebase/sync/sveltekit";
|
|
306
124
|
|
|
307
|
-
export
|
|
308
|
-
|
|
309
|
-
syncDevPlugin({
|
|
310
|
-
// Path to your sync handlers. Uses ssrLoadModule so SvelteKit
|
|
311
|
-
// path aliases (like $lib) resolve perfectly at runtime.
|
|
312
|
-
handlersPath: "$lib/server/sync-handlers"
|
|
313
|
-
}),
|
|
314
|
-
sveltekit()
|
|
315
|
-
]
|
|
125
|
+
export const { GET, POST } = syncProxy({
|
|
126
|
+
fallbackUrl: SYNC_WORKER_URL,
|
|
316
127
|
});
|
|
317
128
|
```
|
|
318
129
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
## 3. Local Development Features
|
|
322
|
-
|
|
323
|
-
### Automatic Bindings Proxy
|
|
324
|
-
During development (`vite dev`), the dev engine uses Wrangler's programmatic Node API `getPlatformProxy()` under the hood. It caches the proxy on `globalThis` to survive Vite HMR reloads.
|
|
325
|
-
|
|
326
|
-
Both SvelteKit and the dev WebSocket server share the **exact same emulated D1 database instance** automatically.
|
|
327
|
-
|
|
328
|
-
### Message Buffering
|
|
329
|
-
Vite's module loading is asynchronous. When upgrading WebSocket connections, the plugin buffers incoming WebSocket frames during the module import phase. Once modules have fully loaded and handlers are registered, it replays the buffered messages to avoid connection race conditions.
|
|
130
|
+
In production, configure a Cloudflare service binding named `SYNC_WORKER`. In local development, use `fallbackUrl` such as `http://localhost:8788/api/sync`.
|
|
330
131
|
|
|
331
|
-
|
|
132
|
+
## Publishing Server Events
|
|
332
133
|
|
|
333
|
-
|
|
134
|
+
Publishing is explicit. It never reads SvelteKit request context implicitly.
|
|
334
135
|
|
|
335
|
-
|
|
336
|
-
|
|
136
|
+
```ts
|
|
137
|
+
import { createPublisher } from "@sveltebase/sync/server";
|
|
337
138
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
```typescript
|
|
341
|
-
// Helper to extract session profile from handshake request
|
|
342
|
-
async function getSession(ctx: SyncContext) {
|
|
343
|
-
const cookie = ctx.request.headers.get("Cookie");
|
|
344
|
-
const db = getDB(ctx.platform);
|
|
345
|
-
// Perform session verification/DB lookup...
|
|
346
|
-
return { userId: "usr_123", role: "admin" };
|
|
347
|
-
}
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
### Connection Auth (`ctx.auth`)
|
|
351
|
-
Sveltebase Sync can resolve and store an authenticated app payload during the WebSocket handshake. The resolved payload is passed to every sync handler as `ctx.auth`.
|
|
352
|
-
|
|
353
|
-
```typescript
|
|
354
|
-
// src/routes/api/sync/+server.ts
|
|
355
|
-
import { JWT_SECRET } from "$env/static/private";
|
|
356
|
-
import { getVerifiedUserFromRequest } from "@sveltebase/auth";
|
|
357
|
-
import { handleUpgrade } from "@sveltebase/sync";
|
|
358
|
-
import type { User } from "$lib/server/db/schema";
|
|
359
|
-
import type { RequestHandler } from "@sveltejs/kit";
|
|
360
|
-
|
|
361
|
-
export const GET: RequestHandler = (event) => {
|
|
362
|
-
return handleUpgrade(event.request, event.platform, {
|
|
363
|
-
auth: async (request) => {
|
|
364
|
-
const user = await getVerifiedUserFromRequest<User>(
|
|
365
|
-
request,
|
|
366
|
-
JWT_SECRET
|
|
367
|
-
);
|
|
368
|
-
|
|
369
|
-
return user ? { user } : null;
|
|
370
|
-
},
|
|
371
|
-
identity: (auth) => auth.user.id,
|
|
372
|
-
allowUnauthenticated: false
|
|
373
|
-
});
|
|
139
|
+
type AppSchema = {
|
|
140
|
+
todos: { id: string; title: string; updatedAt: string };
|
|
374
141
|
};
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
After this, every handler can access the user object:
|
|
378
142
|
|
|
379
|
-
|
|
380
|
-
ctx.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
---
|
|
386
|
-
|
|
387
|
-
### The `authorize` Hook
|
|
388
|
-
The `authorize` hook acts as a guard. It runs synchronously on the server when a client attempts to **subscribe** to a channel or submit a **mutation** (create, update, delete). If it throws an error, the operation is rejected and rolled back.
|
|
143
|
+
const publish = createPublisher<AppSchema>({
|
|
144
|
+
platform: ctx.platform,
|
|
145
|
+
binding: "SYNC_WORKER",
|
|
146
|
+
fallbackUrl: env.SYNC_WORKER_URL,
|
|
147
|
+
});
|
|
389
148
|
|
|
390
|
-
|
|
391
|
-
authorize: async (ctx) => {
|
|
392
|
-
const user = await getSession(ctx);
|
|
393
|
-
if (!user) {
|
|
394
|
-
throw new Error("Unauthorized access to channel");
|
|
395
|
-
}
|
|
396
|
-
}
|
|
149
|
+
await publish("todos", "update", todo.id, todo);
|
|
397
150
|
```
|
|
398
151
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
### Throwing & Filtering in Handlers (CRUD Operations)
|
|
402
|
-
|
|
403
|
-
Beyond the global `authorize` hook, you can enforce security directly inside your query (`fetch`) and mutation (`create`, `update`, `delete`) handlers:
|
|
404
|
-
|
|
405
|
-
#### 1. Filtering on Read (`fetch`)
|
|
406
|
-
Use the handshake HTTP request (`ctx.request`) to dynamically filter the records fetched from the database, preventing users from pulling unauthorized rows.
|
|
152
|
+
Inside the sync Worker, `createPublisher()` publishes directly to `platform.env.SYNC_ENGINE`. Inside the app Worker, it publishes through `platform.env.SYNC_WORKER.fetch()`. Without a binding, it uses `fallbackUrl`.
|
|
407
153
|
|
|
408
|
-
|
|
409
|
-
fetch: async (ctx, since) => {
|
|
410
|
-
const db = getDB(ctx.platform);
|
|
411
|
-
const user = ctx.auth?.user;
|
|
412
|
-
if (!user) return [];
|
|
154
|
+
## Cloudflare Configuration
|
|
413
155
|
|
|
414
|
-
|
|
415
|
-
const conditions = [];
|
|
156
|
+
App Worker:
|
|
416
157
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
#### 2. Write & Delete Handlers (Optional)
|
|
433
|
-
The `create`, `update`, and `delete` handlers are optional. If you omit any of these handlers, Sveltebase Sync treats the channel as read-only for that operation and will automatically reject any incoming client mutations.
|
|
434
|
-
|
|
435
|
-
If you *do* define them, you can throw regular JavaScript/TypeScript errors inside your mutation handlers. When an error is thrown:
|
|
436
|
-
1. The server catches the error and rejects the mutation.
|
|
437
|
-
2. The server sends a rejection response back to the client.
|
|
438
|
-
3. The client receives the rejection, triggers the `rollback` function, and reverts the optimistic UI change in IndexedDB.
|
|
439
|
-
|
|
440
|
-
```typescript
|
|
441
|
-
create: async (ctx, data) => {
|
|
442
|
-
const user = await getSession(ctx);
|
|
443
|
-
|
|
444
|
-
// Guard write action
|
|
445
|
-
if (user.role !== "editor" && user.role !== "admin") {
|
|
446
|
-
throw new Error("You do not have permission to create items.");
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const db = getDB(ctx.platform);
|
|
450
|
-
const [created] = await db.insert(todos).values(data).returning();
|
|
451
|
-
return created;
|
|
452
|
-
},
|
|
453
|
-
|
|
454
|
-
update: async (ctx, key, changes) => {
|
|
455
|
-
const user = ctx.auth?.user;
|
|
456
|
-
if (!user) {
|
|
457
|
-
throw new Error("Unauthorized");
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const db = getDB(ctx.platform);
|
|
461
|
-
|
|
462
|
-
// Fetch target record to verify ownership
|
|
463
|
-
const [record] = await db.select().from(todos).where(eq(todos.id, key));
|
|
464
|
-
if (record.ownerId !== user.id && user.role !== "admin") {
|
|
465
|
-
throw new Error("You cannot update a record owned by someone else.");
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const [updated] = await db.update(todos).set(changes).where(eq(todos.id, key)).returning();
|
|
469
|
-
return updated;
|
|
470
|
-
},
|
|
471
|
-
|
|
472
|
-
delete: async (ctx, key) => {
|
|
473
|
-
const user = await getSession(ctx);
|
|
474
|
-
|
|
475
|
-
// Guard delete action
|
|
476
|
-
if (user.role !== "admin") {
|
|
477
|
-
throw new Error("Only admins can delete items.");
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const db = getDB(ctx.platform);
|
|
481
|
-
await db.delete(todos).where(eq(todos.id, key));
|
|
158
|
+
```jsonc
|
|
159
|
+
{
|
|
160
|
+
"name": "my-app",
|
|
161
|
+
"main": ".svelte-kit/cloudflare/_worker.js",
|
|
162
|
+
"compatibility_date": "2026-06-07",
|
|
163
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
164
|
+
"services": [
|
|
165
|
+
{
|
|
166
|
+
"binding": "SYNC_WORKER",
|
|
167
|
+
"service": "my-app-sync"
|
|
168
|
+
}
|
|
169
|
+
]
|
|
482
170
|
}
|
|
483
171
|
```
|
|
484
172
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
### The `scope` Hook (Row-Level Broadcast Filtering)
|
|
488
|
-
The `scope` hook determines which of the connected and subscribed clients should receive real-time notifications when a database record is modified. It runs asynchronously after a mutation succeeds on the database.
|
|
489
|
-
|
|
490
|
-
> [!CAUTION]
|
|
491
|
-
> **Security Warning:** If you omit the `scope` hook, Sveltebase Sync defaults to broadcasting mutations to `"all"` subscribed connections.
|
|
492
|
-
> If your channel contains user-private data (meaning you filter by user ID inside the `fetch` handler), you **must** also define a `scope` hook that returns the user ID of the owner: `scope: (ctx, action, data) => [data.userId]`. Otherwise, a user's private updates will be broadcast to all connected users in real time.
|
|
493
|
-
|
|
494
|
-
* Return **`"all"`** to broadcast the change to every client subscribed to the channel.
|
|
495
|
-
* Return an **array of user IDs** (`string[]`) to restrict the broadcast. The broker will match these IDs against the connection's registered identity and skip broadcasting to everyone else.
|
|
173
|
+
Sync Worker:
|
|
496
174
|
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
175
|
+
```jsonc
|
|
176
|
+
{
|
|
177
|
+
"name": "my-app-sync",
|
|
178
|
+
"main": "./src/worker/sync.ts",
|
|
179
|
+
"compatibility_date": "2026-06-07",
|
|
180
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
181
|
+
"durable_objects": {
|
|
182
|
+
"bindings": [
|
|
183
|
+
{
|
|
184
|
+
"name": "SYNC_ENGINE",
|
|
185
|
+
"class_name": "SyncEngine"
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
"migrations": [
|
|
190
|
+
{
|
|
191
|
+
"tag": "v1",
|
|
192
|
+
"new_sqlite_classes": ["SyncEngine"]
|
|
508
193
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const admins = await db
|
|
512
|
-
.select({ id: users.id })
|
|
513
|
-
.from(users)
|
|
514
|
-
.where(eq(users.role, "admin"));
|
|
515
|
-
|
|
516
|
-
return admins.map((admin) => admin.id);
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
#### How Connection Identities are Registered
|
|
522
|
-
The broker matches the IDs returned by `scope` to each active connection's registered identity. That identity is resolved during the WebSocket handshake with the `identity` option on `handleUpgrade`.
|
|
523
|
-
|
|
524
|
-
##### Authenticating using SvelteKit Sessions & Cookies (Recommended)
|
|
525
|
-
Resolve the user session on the server inside your SvelteKit route (`+server.ts`) and return the full user object as connection auth:
|
|
526
|
-
|
|
527
|
-
```typescript
|
|
528
|
-
// src/routes/api/sync/+server.ts
|
|
529
|
-
import { JWT_SECRET } from "$env/static/private";
|
|
530
|
-
import { getVerifiedUserFromRequest } from "@sveltebase/auth";
|
|
531
|
-
import { handleUpgrade } from "@sveltebase/sync";
|
|
532
|
-
import type { User } from "$lib/server/db/schema";
|
|
533
|
-
import type { RequestEvent, RequestHandler } from "@sveltejs/kit";
|
|
534
|
-
|
|
535
|
-
export const GET: RequestHandler = async (event: RequestEvent) => {
|
|
536
|
-
return handleUpgrade(event.request, event.platform, {
|
|
537
|
-
auth: async (request) => {
|
|
538
|
-
const user = await getVerifiedUserFromRequest<User>(
|
|
539
|
-
request,
|
|
540
|
-
JWT_SECRET
|
|
541
|
-
);
|
|
542
|
-
|
|
543
|
-
return user ? { user } : null;
|
|
544
|
-
},
|
|
545
|
-
identity: (auth) => auth.user.id,
|
|
546
|
-
allowUnauthenticated: false
|
|
547
|
-
});
|
|
548
|
-
};
|
|
194
|
+
]
|
|
195
|
+
}
|
|
549
196
|
```
|
|
550
|
-
This approach keeps WebSocket URLs clean of private IDs, makes `ctx.auth.user` available to your sync handlers, and gives the broker a stable identity for `scope` filtering.
|
|
551
|
-
|
|
552
|
-
---
|
|
553
|
-
|
|
554
|
-
## 5. Type-Safe Backend Event Publishing (`createPublisher`)
|
|
555
|
-
|
|
556
|
-
When publishing backend events (e.g. from standard API routes, message queues, or cron triggers) to push updates to connected clients, you can create a type-safe publisher matched to your application's database schema. This checks channels (including dynamic channel patterns like `"todos:user_123"`), actions, and payloads at compile-time:
|
|
557
|
-
|
|
558
|
-
```typescript
|
|
559
|
-
import { createPublisher } from "@sveltebase/sync";
|
|
560
|
-
import type { Todo } from "$lib/server/db/schema";
|
|
561
|
-
|
|
562
|
-
// Define schema matching channel names to model types
|
|
563
|
-
type AppSyncSchema = {
|
|
564
|
-
todos: Todo;
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
// Create typed publish function (Option A: Explicit Schema)
|
|
568
|
-
const publish = createPublisher<AppSyncSchema>();
|
|
569
|
-
|
|
570
|
-
// Create typed publish function (Option B: Automatically inferred from Sync Handlers)
|
|
571
|
-
import { handlers } from "./lib/server/sync-handlers.js";
|
|
572
|
-
const publish = createPublisher(handlers);
|
|
573
|
-
|
|
574
|
-
// 1. Publish a create event (expects full Todo payload)
|
|
575
|
-
await publish("todos", "create", todo.id, todo);
|
|
576
|
-
|
|
577
|
-
// 2. Publish an update event (expects Partial<Todo> payload)
|
|
578
|
-
await publish("todos", "update", todo.id, { completed: true });
|
|
579
197
|
|
|
580
|
-
|
|
581
|
-
await publish("todos", "delete", todo.id, undefined);
|
|
198
|
+
Both Workers need the same session secret when using `@sveltebase/auth/sync`:
|
|
582
199
|
|
|
583
|
-
|
|
584
|
-
|
|
200
|
+
```bash
|
|
201
|
+
wrangler secret put JWT_SECRET --config wrangler.jsonc
|
|
202
|
+
wrangler secret put JWT_SECRET --config wrangler.sync.jsonc
|
|
585
203
|
```
|