@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/durable-object.d.ts +88 -0
- package/dist/durable-object.d.ts.map +1 -0
- package/dist/durable-object.js +170 -0
- package/dist/durable-object.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/r2.d.ts +140 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +139 -0
- package/dist/r2.js.map +1 -0
- package/dist/worker.d.ts +42 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +59 -0
- package/dist/worker.js.map +1 -0
- package/package.json +77 -0
- package/src/durable-object.ts +241 -0
- package/src/index.ts +18 -0
- package/src/r2.ts +315 -0
- package/src/worker.ts +73 -0
package/src/r2.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R2 blob storage adapter using native R2Bucket binding.
|
|
3
|
+
*
|
|
4
|
+
* This adapter stores blobs in Cloudflare R2 using the native binding,
|
|
5
|
+
* without requiring the AWS SDK. Since R2 bindings don't support presigned URLs,
|
|
6
|
+
* this adapter generates signed tokens that allow uploads/downloads through
|
|
7
|
+
* the Worker's blob routes (similar to the database adapter).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Blob Storage Types (locally defined to avoid DOM/Workers lib conflicts)
|
|
12
|
+
// These match @syncular/core BlobStorageAdapter interface.
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for signing an upload URL.
|
|
17
|
+
*/
|
|
18
|
+
export interface BlobSignUploadOptions {
|
|
19
|
+
/** SHA-256 hash (for naming and checksum validation) */
|
|
20
|
+
hash: string;
|
|
21
|
+
/** Content size in bytes */
|
|
22
|
+
size: number;
|
|
23
|
+
/** MIME type */
|
|
24
|
+
mimeType: string;
|
|
25
|
+
/** URL expiration in seconds */
|
|
26
|
+
expiresIn: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Result of signing an upload URL.
|
|
31
|
+
*/
|
|
32
|
+
export interface BlobSignedUpload {
|
|
33
|
+
/** The URL to upload to */
|
|
34
|
+
url: string;
|
|
35
|
+
/** HTTP method */
|
|
36
|
+
method: 'PUT' | 'POST';
|
|
37
|
+
/** Required headers */
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Options for signing a download URL.
|
|
43
|
+
*/
|
|
44
|
+
export interface BlobSignDownloadOptions {
|
|
45
|
+
/** SHA-256 hash */
|
|
46
|
+
hash: string;
|
|
47
|
+
/** URL expiration in seconds */
|
|
48
|
+
expiresIn: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Adapter for blob storage backends.
|
|
53
|
+
* Implements the same interface as @syncular/core BlobStorageAdapter.
|
|
54
|
+
*/
|
|
55
|
+
export interface BlobStorageAdapter {
|
|
56
|
+
/** Adapter name for logging/debugging */
|
|
57
|
+
readonly name: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate a presigned URL for uploading a blob.
|
|
61
|
+
*/
|
|
62
|
+
signUpload(options: BlobSignUploadOptions): Promise<BlobSignedUpload>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate a presigned URL for downloading a blob.
|
|
66
|
+
*/
|
|
67
|
+
signDownload(options: BlobSignDownloadOptions): Promise<string>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if a blob exists in storage.
|
|
71
|
+
*/
|
|
72
|
+
exists(hash: string): Promise<boolean>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Delete a blob (for garbage collection).
|
|
76
|
+
*/
|
|
77
|
+
delete(hash: string): Promise<void>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get blob metadata from storage (optional).
|
|
81
|
+
*/
|
|
82
|
+
getMetadata?(
|
|
83
|
+
hash: string
|
|
84
|
+
): Promise<{ size: number; mimeType?: string } | null>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Store blob data directly (for adapters that support direct storage).
|
|
88
|
+
*/
|
|
89
|
+
put?(
|
|
90
|
+
hash: string,
|
|
91
|
+
data: Uint8Array,
|
|
92
|
+
metadata?: Record<string, unknown>
|
|
93
|
+
): Promise<void>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get blob data directly (for adapters that support direct retrieval).
|
|
97
|
+
*/
|
|
98
|
+
get?(hash: string): Promise<Uint8Array | null>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Token signer interface for creating/verifying upload/download tokens.
|
|
103
|
+
*/
|
|
104
|
+
export interface BlobTokenSigner {
|
|
105
|
+
/**
|
|
106
|
+
* Sign a token for blob upload/download authorization.
|
|
107
|
+
* @param payload The data to sign
|
|
108
|
+
* @param expiresIn Expiration time in seconds
|
|
109
|
+
* @returns A signed token string
|
|
110
|
+
*/
|
|
111
|
+
sign(
|
|
112
|
+
payload: { hash: string; action: 'upload' | 'download'; expiresAt: number },
|
|
113
|
+
expiresIn: number
|
|
114
|
+
): Promise<string>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Verify and decode a signed token.
|
|
118
|
+
* @returns The payload if valid, null if invalid/expired
|
|
119
|
+
*/
|
|
120
|
+
verify(token: string): Promise<{
|
|
121
|
+
hash: string;
|
|
122
|
+
action: 'upload' | 'download';
|
|
123
|
+
expiresAt: number;
|
|
124
|
+
} | null>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a simple HMAC-based token signer.
|
|
129
|
+
*/
|
|
130
|
+
export function createHmacTokenSigner(secret: string): BlobTokenSigner {
|
|
131
|
+
const encoder = new TextEncoder();
|
|
132
|
+
|
|
133
|
+
async function hmacSign(data: string): Promise<string> {
|
|
134
|
+
const key = await crypto.subtle.importKey(
|
|
135
|
+
'raw',
|
|
136
|
+
encoder.encode(secret),
|
|
137
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
138
|
+
false,
|
|
139
|
+
['sign']
|
|
140
|
+
);
|
|
141
|
+
const signature = await crypto.subtle.sign(
|
|
142
|
+
'HMAC',
|
|
143
|
+
key,
|
|
144
|
+
encoder.encode(data)
|
|
145
|
+
);
|
|
146
|
+
return bufferToHex(new Uint8Array(signature));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
async sign(payload, _expiresIn) {
|
|
151
|
+
const data = JSON.stringify(payload);
|
|
152
|
+
const dataB64 = btoa(data);
|
|
153
|
+
const sig = await hmacSign(dataB64);
|
|
154
|
+
return `${dataB64}.${sig}`;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async verify(token) {
|
|
158
|
+
const [dataB64, sig] = token.split('.');
|
|
159
|
+
if (!dataB64 || !sig) return null;
|
|
160
|
+
|
|
161
|
+
const expectedSig = await hmacSign(dataB64);
|
|
162
|
+
if (sig !== expectedSig) return null;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(atob(dataB64)) as {
|
|
166
|
+
hash: string;
|
|
167
|
+
action: 'upload' | 'download';
|
|
168
|
+
expiresAt: number;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (Date.now() > data.expiresAt) return null;
|
|
172
|
+
|
|
173
|
+
return data;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bufferToHex(buffer: Uint8Array): string {
|
|
182
|
+
return Array.from(buffer)
|
|
183
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
184
|
+
.join('');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface R2BlobStorageAdapterOptions {
|
|
188
|
+
/** R2 bucket binding */
|
|
189
|
+
bucket: R2Bucket;
|
|
190
|
+
/** Optional key prefix for all blobs */
|
|
191
|
+
keyPrefix?: string;
|
|
192
|
+
/** Base URL for the blob routes (e.g., "https://api.example.com/api/sync") */
|
|
193
|
+
baseUrl: string;
|
|
194
|
+
/** Token signer for authorization */
|
|
195
|
+
tokenSigner: BlobTokenSigner;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create an R2 blob storage adapter using native R2Bucket binding.
|
|
200
|
+
*
|
|
201
|
+
* Since R2 bindings don't support presigned URLs, this adapter generates
|
|
202
|
+
* signed tokens and uses Worker-proxied uploads/downloads.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```typescript
|
|
206
|
+
* import { createR2BlobStorageAdapter, createHmacTokenSigner } from '@syncular/server-cloudflare/r2';
|
|
207
|
+
*
|
|
208
|
+
* type Env = { BLOBS: R2Bucket };
|
|
209
|
+
*
|
|
210
|
+
* const adapter = createR2BlobStorageAdapter({
|
|
211
|
+
* bucket: env.BLOBS,
|
|
212
|
+
* baseUrl: 'https://api.example.com/sync',
|
|
213
|
+
* tokenSigner: createHmacTokenSigner(env.BLOB_SECRET),
|
|
214
|
+
* });
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export function createR2BlobStorageAdapter(
|
|
218
|
+
options: R2BlobStorageAdapterOptions
|
|
219
|
+
): BlobStorageAdapter {
|
|
220
|
+
const { bucket, keyPrefix = '', baseUrl, tokenSigner } = options;
|
|
221
|
+
|
|
222
|
+
// Normalize base URL (remove trailing slash)
|
|
223
|
+
const normalizedBaseUrl = baseUrl.replace(/\/$/, '');
|
|
224
|
+
|
|
225
|
+
function getKey(hash: string): string {
|
|
226
|
+
// Remove "sha256:" prefix and use hex as key
|
|
227
|
+
const hex = hash.startsWith('sha256:') ? hash.slice(7) : hash;
|
|
228
|
+
return `${keyPrefix}${hex}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
name: 'r2',
|
|
233
|
+
|
|
234
|
+
async signUpload(opts: BlobSignUploadOptions): Promise<BlobSignedUpload> {
|
|
235
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
236
|
+
const token = await tokenSigner.sign(
|
|
237
|
+
{ hash: opts.hash, action: 'upload', expiresAt },
|
|
238
|
+
opts.expiresIn
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// URL points to server's blob upload endpoint
|
|
242
|
+
const url = `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/upload?token=${encodeURIComponent(token)}`;
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
url,
|
|
246
|
+
method: 'PUT',
|
|
247
|
+
headers: {
|
|
248
|
+
'Content-Type': opts.mimeType,
|
|
249
|
+
'Content-Length': String(opts.size),
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async signDownload(opts: BlobSignDownloadOptions): Promise<string> {
|
|
255
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
256
|
+
const token = await tokenSigner.sign(
|
|
257
|
+
{ hash: opts.hash, action: 'download', expiresAt },
|
|
258
|
+
opts.expiresIn
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/download?token=${encodeURIComponent(token)}`;
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async exists(hash: string): Promise<boolean> {
|
|
265
|
+
const key = getKey(hash);
|
|
266
|
+
const head = await bucket.head(key);
|
|
267
|
+
return head !== null;
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async delete(hash: string): Promise<void> {
|
|
271
|
+
const key = getKey(hash);
|
|
272
|
+
await bucket.delete(key);
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
async getMetadata(
|
|
276
|
+
hash: string
|
|
277
|
+
): Promise<{ size: number; mimeType?: string } | null> {
|
|
278
|
+
const key = getKey(hash);
|
|
279
|
+
const head = await bucket.head(key);
|
|
280
|
+
if (!head) return null;
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
size: head.size,
|
|
284
|
+
mimeType: head.httpMetadata?.contentType,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
async put(
|
|
289
|
+
hash: string,
|
|
290
|
+
data: Uint8Array,
|
|
291
|
+
metadata?: Record<string, unknown>
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
const key = getKey(hash);
|
|
294
|
+
const mimeType =
|
|
295
|
+
typeof metadata?.mimeType === 'string'
|
|
296
|
+
? metadata.mimeType
|
|
297
|
+
: 'application/octet-stream';
|
|
298
|
+
|
|
299
|
+
await bucket.put(key, data, {
|
|
300
|
+
httpMetadata: {
|
|
301
|
+
contentType: mimeType,
|
|
302
|
+
},
|
|
303
|
+
sha256: hash.startsWith('sha256:') ? hash.slice(7) : undefined,
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async get(hash: string): Promise<Uint8Array | null> {
|
|
308
|
+
const key = getKey(hash);
|
|
309
|
+
const object = await bucket.get(key);
|
|
310
|
+
if (!object) return null;
|
|
311
|
+
|
|
312
|
+
return new Uint8Array(await object.arrayBuffer());
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
31
|
+
import { Hono } from 'hono';
|
|
32
|
+
|
|
33
|
+
type SyncWorkerSetup<B extends object> = (
|
|
34
|
+
app: Hono<{ Bindings: B }>,
|
|
35
|
+
env: B
|
|
36
|
+
) => void | Promise<void>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a Cloudflare Worker export that lazily initializes a Hono app.
|
|
40
|
+
*
|
|
41
|
+
* The `setup` callback is called once per isolate on the first request.
|
|
42
|
+
* It receives a fresh Hono app and the Worker env bindings.
|
|
43
|
+
*/
|
|
44
|
+
export function createSyncWorker<
|
|
45
|
+
Bindings extends object = Record<string, unknown>,
|
|
46
|
+
>(setup: SyncWorkerSetup<Bindings>): ExportedHandler<Bindings> {
|
|
47
|
+
type E = { Bindings: Bindings };
|
|
48
|
+
let app: Hono<E> | null = null;
|
|
49
|
+
let initPromise: Promise<void> | null = null;
|
|
50
|
+
|
|
51
|
+
async function getApp(env: Bindings): Promise<Hono<E>> {
|
|
52
|
+
if (app) return app;
|
|
53
|
+
if (!initPromise) {
|
|
54
|
+
const honoApp = new Hono<E>();
|
|
55
|
+
initPromise = Promise.resolve(setup(honoApp, env)).then(() => {
|
|
56
|
+
app = honoApp;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
await initPromise;
|
|
60
|
+
return app!;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
async fetch(
|
|
65
|
+
request: Request,
|
|
66
|
+
env: Bindings,
|
|
67
|
+
ctx: ExecutionContext
|
|
68
|
+
): Promise<Response> {
|
|
69
|
+
const honoApp = await getApp(env);
|
|
70
|
+
return honoApp.fetch(request, env, ctx);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|