@syncular/server-cloudflare 0.0.1-60

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/dist/worker.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @syncular/server-cloudflare - Worker handler (polling only)
3
+ *
4
+ * Creates a stateless Cloudflare Worker that serves sync routes via Hono.
5
+ * No WebSocket support — use the Durable Object adapter for realtime.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { createSyncWorker } from '@syncular/server-cloudflare/worker';
10
+ * import { createD1Db } from '@syncular/dialect-d1';
11
+ * import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
12
+ * import { ensureSyncSchema } from '@syncular/server';
13
+ * import { createSyncServer } from '@syncular/server-hono';
14
+ *
15
+ * type Env = { DB: D1Database };
16
+ *
17
+ * export default createSyncWorker<Env>((app, env) => {
18
+ * const db = createD1Db(env.DB);
19
+ * const dialect = createSqliteServerDialect();
20
+ * const { syncRoutes, consoleRoutes } = createSyncServer({
21
+ * db, dialect,
22
+ * handlers: [tasksHandler],
23
+ * authenticate: async (c) => ({ actorId: c.req.header('x-user-id')! }),
24
+ * });
25
+ * app.route('/sync', syncRoutes);
26
+ * if (consoleRoutes) app.route('/console', consoleRoutes);
27
+ * });
28
+ * ```
29
+ */
30
+ import { Hono } from 'hono';
31
+ /**
32
+ * Create a Cloudflare Worker export that lazily initializes a Hono app.
33
+ *
34
+ * The `setup` callback is called once per isolate on the first request.
35
+ * It receives a fresh Hono app and the Worker env bindings.
36
+ */
37
+ export function createSyncWorker(setup) {
38
+ let app = null;
39
+ let initPromise = null;
40
+ async function getApp(env) {
41
+ if (app)
42
+ return app;
43
+ if (!initPromise) {
44
+ const honoApp = new Hono();
45
+ initPromise = Promise.resolve(setup(honoApp, env)).then(() => {
46
+ app = honoApp;
47
+ });
48
+ }
49
+ await initPromise;
50
+ return app;
51
+ }
52
+ return {
53
+ async fetch(request, env, ctx) {
54
+ const honoApp = await getApp(env);
55
+ return honoApp.fetch(request, env, ctx);
56
+ },
57
+ };
58
+ }
59
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAO5B;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAE9B,KAAgC,EAA6B;IAE7D,IAAI,GAAG,GAAmB,IAAI,CAAC;IAC/B,IAAI,WAAW,GAAyB,IAAI,CAAC;IAE7C,KAAK,UAAU,MAAM,CAAC,GAAa,EAAoB;QACrD,IAAI,GAAG;YAAE,OAAO,GAAG,CAAC;QACpB,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,MAAM,OAAO,GAAG,IAAI,IAAI,EAAK,CAAC;YAC9B,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;gBAC5D,GAAG,GAAG,OAAO,CAAC;YAAA,CACf,CAAC,CAAC;QACL,CAAC;QACD,MAAM,WAAW,CAAC;QAClB,OAAO,GAAI,CAAC;IAAA,CACb;IAED,OAAO;QACL,KAAK,CAAC,KAAK,CACT,OAAgB,EAChB,GAAa,EACb,GAAqB,EACF;YACnB,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,GAAG,CAAC,CAAC;YAClC,OAAO,OAAO,CAAC,KAAK,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;QAAA,CACzC;KACF,CAAC;AAAA,CACH"}
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@syncular/server-cloudflare",
3
+ "version": "0.0.1-60",
4
+ "description": "Cloudflare Workers adapter for the Syncular server",
5
+ "license": "MIT",
6
+ "author": "Benjamin Kniffler",
7
+ "homepage": "https://syncular.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/syncular/syncular.git",
11
+ "directory": "packages/server-cloudflare"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/syncular/syncular/issues"
15
+ },
16
+ "keywords": [
17
+ "sync",
18
+ "offline-first",
19
+ "realtime",
20
+ "database",
21
+ "typescript",
22
+ "cloudflare",
23
+ "workers",
24
+ "edge"
25
+ ],
26
+ "private": false,
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "bun": "./src/index.ts",
34
+ "import": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ }
38
+ },
39
+ "./worker": {
40
+ "bun": "./src/worker.ts",
41
+ "import": {
42
+ "types": "./dist/worker.d.ts",
43
+ "default": "./dist/worker.js"
44
+ }
45
+ },
46
+ "./durable-object": {
47
+ "bun": "./src/durable-object.ts",
48
+ "import": {
49
+ "types": "./dist/durable-object.d.ts",
50
+ "default": "./dist/durable-object.js"
51
+ }
52
+ },
53
+ "./r2": {
54
+ "bun": "./src/r2.ts",
55
+ "import": {
56
+ "types": "./dist/r2.d.ts",
57
+ "default": "./dist/r2.js"
58
+ }
59
+ }
60
+ },
61
+ "scripts": {
62
+ "tsgo": "tsgo --noEmit",
63
+ "build": "rm -rf dist && tsgo",
64
+ "release": "bun pm pack --destination . && npm publish ./*.tgz --tag latest && rm -f ./*.tgz"
65
+ },
66
+ "peerDependencies": {
67
+ "hono": "^4.0.0"
68
+ },
69
+ "devDependencies": {
70
+ "@cloudflare/workers-types": "*",
71
+ "@syncular/config": "0.0.0"
72
+ },
73
+ "files": [
74
+ "dist",
75
+ "src"
76
+ ]
77
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @syncular/server-cloudflare - Durable Object handler (WebSocket + polling)
3
+ *
4
+ * Provides a base DurableObject class with Hono routing and WebSocket support.
5
+ * The DO's stateful nature allows it to hold persistent WebSocket connections,
6
+ * bridging Cloudflare's hibernation API to Hono's `upgradeWebSocket` interface.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { SyncDurableObject, createSyncWorkerWithDO } from '@syncular/server-cloudflare/durable-object';
11
+ * import { createD1Db } from '@syncular/dialect-d1';
12
+ * import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
13
+ * import { ensureSyncSchema } from '@syncular/server';
14
+ * import { createSyncServer } from '@syncular/server-hono';
15
+ *
16
+ * type Env = { DB: D1Database; SYNC_DO: DurableObjectNamespace };
17
+ *
18
+ * export class SyncDO extends SyncDurableObject<Env> {
19
+ * setup(app, env, upgradeWebSocket) {
20
+ * const db = createD1Db(env.DB);
21
+ * const dialect = createSqliteServerDialect();
22
+ * const { syncRoutes, consoleRoutes } = createSyncServer({
23
+ * db, dialect,
24
+ * handlers: [tasksHandler],
25
+ * authenticate: async (c) => ({ actorId: c.req.header('x-user-id')! }),
26
+ * upgradeWebSocket,
27
+ * });
28
+ * app.route('/sync', syncRoutes);
29
+ * if (consoleRoutes) app.route('/console', consoleRoutes);
30
+ * }
31
+ * }
32
+ *
33
+ * // Worker entry — routes all requests to the DO
34
+ * export default createSyncWorkerWithDO<Env>('SYNC_DO');
35
+ * ```
36
+ */
37
+
38
+ import { Hono } from 'hono';
39
+ import type { UpgradeWebSocket, WSEvents } from 'hono/ws';
40
+ import { defineWebSocketHelper, WSContext } from 'hono/ws';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // WebSocket ↔ Hono bridge
44
+ // ---------------------------------------------------------------------------
45
+
46
+ interface WebSocketTag {
47
+ events: WSEvents<WebSocket>;
48
+ }
49
+
50
+ /**
51
+ * WeakMap from server-side WebSocket → tag with event handlers.
52
+ * Populated on upgrade, read in webSocketMessage/webSocketClose.
53
+ */
54
+ const socketTags = new WeakMap<WebSocket, WebSocketTag>();
55
+
56
+ function createWSContext(ws: WebSocket): WSContext<WebSocket> {
57
+ return new WSContext<WebSocket>({
58
+ send(data) {
59
+ ws.send(data);
60
+ },
61
+ close(code, reason) {
62
+ ws.close(code, reason);
63
+ },
64
+ raw: ws,
65
+ get readyState() {
66
+ return ws.readyState as 0 | 1 | 2 | 3;
67
+ },
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Create an `upgradeWebSocket` function backed by the Durable Object
73
+ * hibernation API (`state.acceptWebSocket`).
74
+ *
75
+ * Each accepted socket is tagged with its Hono `WSEvents` handlers so the
76
+ * DO's `webSocketMessage` / `webSocketClose` callbacks can dispatch to them.
77
+ */
78
+ function createDOUpgradeWebSocket(
79
+ doState: DurableObjectState
80
+ ): UpgradeWebSocket<WebSocket> {
81
+ return defineWebSocketHelper((_c, events) => {
82
+ const pair = new WebSocketPair();
83
+ const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
84
+
85
+ // Accept via hibernation API so the DO can wake on messages
86
+ doState.acceptWebSocket(server);
87
+
88
+ // Tag the server socket so webSocketMessage/webSocketClose can find handlers
89
+ socketTags.set(server, { events: events as WSEvents<WebSocket> });
90
+
91
+ // Fire onOpen synchronously (socket is already accepted)
92
+ const wsCtx = createWSContext(server);
93
+ events.onOpen?.(new Event('open'), wsCtx);
94
+
95
+ return new Response(null, { status: 101, webSocket: client });
96
+ });
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // SyncDurableObject base class
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Base class for a Syncular Durable Object with Hono routing and WebSocket.
105
+ *
106
+ * Subclass and implement `setup()` to configure routes.
107
+ */
108
+ export abstract class SyncDurableObject<
109
+ E extends object = Record<string, unknown>,
110
+ > {
111
+ protected ctx: DurableObjectState;
112
+ protected env: E;
113
+
114
+ private app: Hono<{ Bindings: E }> | null = null;
115
+ private initPromise: Promise<void> | null = null;
116
+ private doUpgradeWebSocket: UpgradeWebSocket<WebSocket>;
117
+
118
+ constructor(ctx: DurableObjectState, env: E) {
119
+ this.ctx = ctx;
120
+ this.env = env;
121
+ this.doUpgradeWebSocket = createDOUpgradeWebSocket(ctx);
122
+ }
123
+
124
+ /**
125
+ * Configure the Hono app with sync routes.
126
+ *
127
+ * Called once when the DO first receives a request.
128
+ * Use `upgradeWebSocket` when creating the sync server to enable realtime.
129
+ */
130
+ abstract setup(
131
+ app: Hono<{ Bindings: E }>,
132
+ env: E,
133
+ upgradeWebSocket: UpgradeWebSocket<WebSocket>
134
+ ): void | Promise<void>;
135
+
136
+ private async getApp(): Promise<Hono<{ Bindings: E }>> {
137
+ if (this.app) return this.app;
138
+ if (!this.initPromise) {
139
+ const honoApp = new Hono<{ Bindings: E }>();
140
+ this.initPromise = Promise.resolve(
141
+ this.setup(honoApp, this.env, this.doUpgradeWebSocket)
142
+ ).then(() => {
143
+ this.app = honoApp;
144
+ });
145
+ }
146
+ await this.initPromise;
147
+ return this.app!;
148
+ }
149
+
150
+ /** Handle incoming HTTP requests (and WebSocket upgrades). */
151
+ async fetch(request: Request): Promise<Response> {
152
+ const app = await this.getApp();
153
+ return app.fetch(request, this.env);
154
+ }
155
+
156
+ /** Dispatch incoming WebSocket messages to Hono event handlers. */
157
+ async webSocketMessage(
158
+ ws: WebSocket,
159
+ message: string | ArrayBuffer
160
+ ): Promise<void> {
161
+ const tag = socketTags.get(ws);
162
+ if (!tag?.events.onMessage) return;
163
+
164
+ const wsCtx = createWSContext(ws);
165
+ const evt = new MessageEvent('message', { data: message });
166
+ tag.events.onMessage(evt, wsCtx);
167
+ }
168
+
169
+ /** Dispatch WebSocket close events to Hono event handlers. */
170
+ async webSocketClose(
171
+ ws: WebSocket,
172
+ code: number,
173
+ reason: string,
174
+ _wasClean: boolean
175
+ ): Promise<void> {
176
+ const tag = socketTags.get(ws);
177
+ if (!tag?.events.onClose) return;
178
+
179
+ const wsCtx = createWSContext(ws);
180
+ const evt = new CloseEvent('close', { code, reason });
181
+ tag.events.onClose(evt, wsCtx);
182
+ socketTags.delete(ws);
183
+ }
184
+
185
+ /** Dispatch WebSocket error events to Hono event handlers. */
186
+ async webSocketError(ws: WebSocket, _error: unknown): Promise<void> {
187
+ const tag = socketTags.get(ws);
188
+ if (!tag?.events.onError) return;
189
+
190
+ const wsCtx = createWSContext(ws);
191
+ const evt = new Event('error');
192
+ tag.events.onError(evt, wsCtx);
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Worker → DO router
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * Create a Worker export that routes all requests to a Durable Object.
202
+ *
203
+ * Uses a single DO instance (derived from a stable ID) to hold all
204
+ * connections. For multi-tenant setups, override `getStubId`.
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * export default createSyncWorkerWithDO<Env>('SYNC_DO');
209
+ * ```
210
+ */
211
+ export function createSyncWorkerWithDO<E extends object>(
212
+ bindingName: string & keyof E,
213
+ options?: {
214
+ /**
215
+ * Derive a DurableObject ID from the request.
216
+ * Defaults to a single global instance via `idFromName('sync')`.
217
+ */
218
+ getStubId?: (
219
+ ns: DurableObjectNamespace,
220
+ request: Request,
221
+ env: E
222
+ ) => DurableObjectId;
223
+ }
224
+ ): ExportedHandler<E> {
225
+ return {
226
+ async fetch(
227
+ request: Request,
228
+ env: E,
229
+ _ctx: ExecutionContext
230
+ ): Promise<Response> {
231
+ const ns = env[
232
+ bindingName as keyof E
233
+ ] as unknown as DurableObjectNamespace;
234
+ const id = options?.getStubId
235
+ ? options.getStubId(ns, request, env)
236
+ : ns.idFromName('sync');
237
+ const stub = ns.get(id);
238
+ return stub.fetch(request);
239
+ },
240
+ };
241
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @syncular/server-cloudflare - Cloudflare adapters for Syncular
3
+ *
4
+ * Two deployment modes:
5
+ * - Worker (polling only): `@syncular/server-cloudflare/worker`
6
+ * - Durable Object (WebSocket + polling): `@syncular/server-cloudflare/durable-object`
7
+ *
8
+ * Blob storage:
9
+ * - R2 native: `@syncular/server-cloudflare/r2`
10
+ *
11
+ * Dialect is user-provided:
12
+ * - D1 + SQLite: `@syncular/dialect-d1` + `@syncular/server-dialect-sqlite`
13
+ * - Neon + Postgres: `@syncular/dialect-neon` + `@syncular/server-dialect-postgres`
14
+ */
15
+
16
+ export * from './durable-object';
17
+ export * from './r2';
18
+ export * from './worker';