@syncular/server-hono 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.
Files changed (76) hide show
  1. package/dist/api-key-auth.d.ts +49 -0
  2. package/dist/api-key-auth.d.ts.map +1 -0
  3. package/dist/api-key-auth.js +110 -0
  4. package/dist/api-key-auth.js.map +1 -0
  5. package/dist/blobs.d.ts +69 -0
  6. package/dist/blobs.d.ts.map +1 -0
  7. package/dist/blobs.js +383 -0
  8. package/dist/blobs.js.map +1 -0
  9. package/dist/console/index.d.ts +8 -0
  10. package/dist/console/index.d.ts.map +1 -0
  11. package/dist/console/index.js +7 -0
  12. package/dist/console/index.js.map +1 -0
  13. package/dist/console/routes.d.ts +106 -0
  14. package/dist/console/routes.d.ts.map +1 -0
  15. package/dist/console/routes.js +1612 -0
  16. package/dist/console/routes.js.map +1 -0
  17. package/dist/console/schemas.d.ts +308 -0
  18. package/dist/console/schemas.d.ts.map +1 -0
  19. package/dist/console/schemas.js +201 -0
  20. package/dist/console/schemas.js.map +1 -0
  21. package/dist/create-server.d.ts +78 -0
  22. package/dist/create-server.d.ts.map +1 -0
  23. package/dist/create-server.js +99 -0
  24. package/dist/create-server.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +25 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openapi.d.ts +45 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +59 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/proxy/connection-manager.d.ts +78 -0
  34. package/dist/proxy/connection-manager.d.ts.map +1 -0
  35. package/dist/proxy/connection-manager.js +251 -0
  36. package/dist/proxy/connection-manager.js.map +1 -0
  37. package/dist/proxy/index.d.ts +8 -0
  38. package/dist/proxy/index.d.ts.map +1 -0
  39. package/dist/proxy/index.js +8 -0
  40. package/dist/proxy/index.js.map +1 -0
  41. package/dist/proxy/routes.d.ts +74 -0
  42. package/dist/proxy/routes.d.ts.map +1 -0
  43. package/dist/proxy/routes.js +147 -0
  44. package/dist/proxy/routes.js.map +1 -0
  45. package/dist/rate-limit.d.ts +101 -0
  46. package/dist/rate-limit.d.ts.map +1 -0
  47. package/dist/rate-limit.js +186 -0
  48. package/dist/rate-limit.js.map +1 -0
  49. package/dist/routes.d.ts +126 -0
  50. package/dist/routes.d.ts.map +1 -0
  51. package/dist/routes.js +788 -0
  52. package/dist/routes.js.map +1 -0
  53. package/dist/ws.d.ts +230 -0
  54. package/dist/ws.d.ts.map +1 -0
  55. package/dist/ws.js +601 -0
  56. package/dist/ws.js.map +1 -0
  57. package/package.json +73 -0
  58. package/src/__tests__/create-server.test.ts +187 -0
  59. package/src/__tests__/pull-chunk-storage.test.ts +189 -0
  60. package/src/__tests__/rate-limit.test.ts +78 -0
  61. package/src/__tests__/realtime-bridge.test.ts +131 -0
  62. package/src/__tests__/ws-connection-manager.test.ts +176 -0
  63. package/src/api-key-auth.ts +179 -0
  64. package/src/blobs.ts +534 -0
  65. package/src/console/index.ts +17 -0
  66. package/src/console/routes.ts +2155 -0
  67. package/src/console/schemas.ts +299 -0
  68. package/src/create-server.ts +180 -0
  69. package/src/index.ts +42 -0
  70. package/src/openapi.ts +74 -0
  71. package/src/proxy/connection-manager.ts +340 -0
  72. package/src/proxy/index.ts +8 -0
  73. package/src/proxy/routes.ts +223 -0
  74. package/src/rate-limit.ts +321 -0
  75. package/src/routes.ts +1186 -0
  76. package/src/ws.ts +789 -0
package/src/blobs.ts ADDED
@@ -0,0 +1,534 @@
1
+ /**
2
+ * @syncular/server-hono - Blob routes for media/binary handling
3
+ *
4
+ * Provides:
5
+ * - POST /blobs/upload - Initiate a blob upload (get presigned URL)
6
+ * - POST /blobs/:hash/complete - Complete a blob upload
7
+ * - GET /blobs/:hash/url - Get a presigned download URL
8
+ * - PUT /blobs/:hash/upload - Direct upload (for database adapter)
9
+ * - GET /blobs/:hash/download - Direct download (for database adapter)
10
+ */
11
+
12
+ import {
13
+ BlobUploadCompleteResponseSchema,
14
+ BlobUploadInitRequestSchema,
15
+ BlobUploadInitResponseSchema,
16
+ ErrorResponseSchema,
17
+ parseBlobHash,
18
+ } from '@syncular/core';
19
+ import type {
20
+ BlobManager,
21
+ BlobNotFoundError,
22
+ BlobValidationError,
23
+ } from '@syncular/server';
24
+ import {
25
+ type BlobTokenSigner,
26
+ readBlobFromDatabase,
27
+ type SyncBlobsDb,
28
+ storeBlobInDatabase,
29
+ } from '@syncular/server';
30
+ import type { Context } from 'hono';
31
+ import { Hono } from 'hono';
32
+ import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
33
+ import type { Kysely } from 'kysely';
34
+ import { z } from 'zod';
35
+
36
+ interface BlobAuthResult {
37
+ actorId: string;
38
+ }
39
+
40
+ export interface CreateBlobRoutesOptions<DB extends SyncBlobsDb = SyncBlobsDb> {
41
+ /** Blob manager instance */
42
+ blobManager: BlobManager;
43
+ /** Authentication function */
44
+ authenticate: (c: Context) => Promise<BlobAuthResult | null>;
45
+ /**
46
+ * Token signer for database adapter direct uploads/downloads.
47
+ * Required if using the database blob storage adapter.
48
+ */
49
+ tokenSigner?: BlobTokenSigner;
50
+ /**
51
+ * Database instance for direct blob storage.
52
+ * Required if using the database blob storage adapter.
53
+ */
54
+ db?: Kysely<DB>;
55
+ /**
56
+ * Optional: Check if actor can access a blob.
57
+ * By default, any authenticated actor can access any completed blob.
58
+ * Provide this to implement scope-based access control.
59
+ */
60
+ canAccessBlob?: (args: { actorId: string; hash: string }) => Promise<boolean>;
61
+ /**
62
+ * Maximum upload size in bytes.
63
+ * Default: 100MB (104857600)
64
+ */
65
+ maxUploadSize?: number;
66
+ }
67
+
68
+ const hashParamsSchema = z.object({
69
+ hash: z.string().min(1),
70
+ });
71
+
72
+ const tokenQuerySchema = z.object({
73
+ token: z.string().min(1),
74
+ });
75
+
76
+ /**
77
+ * Create blob routes for Hono.
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * const blobRoutes = createBlobRoutes({
82
+ * blobManager,
83
+ * authenticate: async (c) => {
84
+ * const token = c.req.header('Authorization')?.replace('Bearer ', '');
85
+ * if (!token) return null;
86
+ * const user = await verifyToken(token);
87
+ * return user ? { actorId: user.id } : null;
88
+ * },
89
+ * });
90
+ *
91
+ * app.route('/api/sync', blobRoutes);
92
+ * ```
93
+ */
94
+ export function createBlobRoutes<DB extends SyncBlobsDb>(
95
+ options: CreateBlobRoutesOptions<DB>
96
+ ): Hono {
97
+ const {
98
+ blobManager,
99
+ authenticate,
100
+ tokenSigner,
101
+ db,
102
+ canAccessBlob,
103
+ maxUploadSize = 100 * 1024 * 1024, // 100MB
104
+ } = options;
105
+
106
+ const routes = new Hono();
107
+
108
+ // -------------------------------------------------------------------------
109
+ // POST /blobs/upload - Initiate upload
110
+ // -------------------------------------------------------------------------
111
+
112
+ routes.post(
113
+ '/blobs/upload',
114
+ describeRoute({
115
+ tags: ['blobs'],
116
+ summary: 'Initiate blob upload',
117
+ description:
118
+ 'Initiates a blob upload and returns a presigned URL for uploading',
119
+ responses: {
120
+ 200: {
121
+ description: 'Upload initiated (or blob already exists)',
122
+ content: {
123
+ 'application/json': {
124
+ schema: resolver(BlobUploadInitResponseSchema),
125
+ },
126
+ },
127
+ },
128
+ 400: {
129
+ description: 'Invalid request',
130
+ content: {
131
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
132
+ },
133
+ },
134
+ 401: {
135
+ description: 'Unauthenticated',
136
+ content: {
137
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
138
+ },
139
+ },
140
+ },
141
+ }),
142
+ zValidator('json', BlobUploadInitRequestSchema),
143
+ async (c) => {
144
+ const auth = await authenticate(c);
145
+ if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
146
+
147
+ const body = c.req.valid('json');
148
+
149
+ // Validate size
150
+ if (body.size > maxUploadSize) {
151
+ return c.json(
152
+ {
153
+ error: 'BLOB_TOO_LARGE',
154
+ message: `Maximum upload size is ${maxUploadSize} bytes`,
155
+ },
156
+ 400
157
+ );
158
+ }
159
+
160
+ try {
161
+ const result = await blobManager.initiateUpload({
162
+ hash: body.hash,
163
+ size: body.size,
164
+ mimeType: body.mimeType,
165
+ actorId: auth.actorId,
166
+ });
167
+
168
+ return c.json(result, 200);
169
+ } catch (err) {
170
+ if (isBlobValidationError(err)) {
171
+ return c.json(
172
+ { error: 'INVALID_REQUEST', message: err.message },
173
+ 400
174
+ );
175
+ }
176
+ throw err;
177
+ }
178
+ }
179
+ );
180
+
181
+ // -------------------------------------------------------------------------
182
+ // POST /blobs/:hash/complete - Complete upload
183
+ // -------------------------------------------------------------------------
184
+
185
+ routes.post(
186
+ '/blobs/:hash/complete',
187
+ describeRoute({
188
+ tags: ['blobs'],
189
+ summary: 'Complete blob upload',
190
+ description:
191
+ 'Marks a blob upload as complete after the client has uploaded to the presigned URL',
192
+ responses: {
193
+ 200: {
194
+ description: 'Upload completed',
195
+ content: {
196
+ 'application/json': {
197
+ schema: resolver(BlobUploadCompleteResponseSchema),
198
+ },
199
+ },
200
+ },
201
+ 400: {
202
+ description: 'Invalid request or upload failed',
203
+ content: {
204
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
205
+ },
206
+ },
207
+ 401: {
208
+ description: 'Unauthenticated',
209
+ content: {
210
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
211
+ },
212
+ },
213
+ },
214
+ }),
215
+ zValidator('param', hashParamsSchema),
216
+ async (c) => {
217
+ const auth = await authenticate(c);
218
+ if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
219
+
220
+ const { hash } = c.req.valid('param');
221
+
222
+ // Validate hash format
223
+ if (!parseBlobHash(hash)) {
224
+ return c.json(
225
+ { error: 'INVALID_REQUEST', message: 'Invalid blob hash format' },
226
+ 400
227
+ );
228
+ }
229
+
230
+ const result = await blobManager.completeUpload(hash);
231
+
232
+ if (!result.ok) {
233
+ return c.json({ error: 'UPLOAD_FAILED', message: result.error }, 400);
234
+ }
235
+
236
+ return c.json(result, 200);
237
+ }
238
+ );
239
+
240
+ // -------------------------------------------------------------------------
241
+ // GET /blobs/:hash/url - Get download URL
242
+ // -------------------------------------------------------------------------
243
+
244
+ routes.get(
245
+ '/blobs/:hash/url',
246
+ describeRoute({
247
+ tags: ['blobs'],
248
+ summary: 'Get blob download URL',
249
+ description: 'Returns a presigned URL for downloading a blob',
250
+ responses: {
251
+ 200: {
252
+ description: 'Download URL',
253
+ content: {
254
+ 'application/json': {
255
+ schema: resolver(
256
+ z.object({
257
+ url: z.string().url(),
258
+ expiresAt: z.string(),
259
+ metadata: z.object({
260
+ hash: z.string(),
261
+ size: z.number(),
262
+ mimeType: z.string(),
263
+ createdAt: z.string(),
264
+ uploadComplete: z.boolean(),
265
+ }),
266
+ })
267
+ ),
268
+ },
269
+ },
270
+ },
271
+ 401: {
272
+ description: 'Unauthenticated',
273
+ content: {
274
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
275
+ },
276
+ },
277
+ 403: {
278
+ description: 'Forbidden',
279
+ content: {
280
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
281
+ },
282
+ },
283
+ 404: {
284
+ description: 'Not found',
285
+ content: {
286
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
287
+ },
288
+ },
289
+ },
290
+ }),
291
+ zValidator('param', hashParamsSchema),
292
+ async (c) => {
293
+ const auth = await authenticate(c);
294
+ if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
295
+
296
+ const { hash } = c.req.valid('param');
297
+
298
+ // Validate hash format
299
+ if (!parseBlobHash(hash)) {
300
+ return c.json({ error: 'NOT_FOUND' }, 404);
301
+ }
302
+
303
+ // Check access if canAccessBlob is provided
304
+ if (canAccessBlob) {
305
+ const canAccess = await canAccessBlob({ actorId: auth.actorId, hash });
306
+ if (!canAccess) {
307
+ return c.json({ error: 'FORBIDDEN' }, 403);
308
+ }
309
+ }
310
+
311
+ try {
312
+ const result = await blobManager.getDownloadUrl({
313
+ hash,
314
+ actorId: auth.actorId,
315
+ });
316
+ return c.json(result, 200);
317
+ } catch (err) {
318
+ if (isBlobNotFoundError(err)) {
319
+ return c.json({ error: 'NOT_FOUND' }, 404);
320
+ }
321
+ throw err;
322
+ }
323
+ }
324
+ );
325
+
326
+ // -------------------------------------------------------------------------
327
+ // PUT /blobs/:hash/upload - Direct upload (database adapter)
328
+ // -------------------------------------------------------------------------
329
+
330
+ if (tokenSigner && db) {
331
+ routes.put(
332
+ '/blobs/:hash/upload',
333
+ describeRoute({
334
+ tags: ['blobs'],
335
+ summary: 'Direct blob upload',
336
+ description:
337
+ 'Direct upload endpoint for database storage adapter. Requires a signed token.',
338
+ responses: {
339
+ 200: {
340
+ description: 'Upload successful',
341
+ },
342
+ 400: {
343
+ description: 'Invalid request',
344
+ content: {
345
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
346
+ },
347
+ },
348
+ 401: {
349
+ description: 'Invalid or expired token',
350
+ content: {
351
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
352
+ },
353
+ },
354
+ },
355
+ }),
356
+ zValidator('param', hashParamsSchema),
357
+ zValidator('query', tokenQuerySchema),
358
+ async (c) => {
359
+ const { hash } = c.req.valid('param');
360
+ const { token } = c.req.valid('query');
361
+
362
+ // Verify token
363
+ const payload = await tokenSigner.verify(token);
364
+ if (!payload || payload.action !== 'upload' || payload.hash !== hash) {
365
+ return c.json({ error: 'INVALID_TOKEN' }, 401);
366
+ }
367
+
368
+ // Get upload metadata
369
+ const metadata = await blobManager.getMetadata(hash);
370
+
371
+ // Read body
372
+ const body = await c.req.arrayBuffer();
373
+ const bodyBytes = new Uint8Array(body);
374
+
375
+ // Verify size
376
+ const expectedSize = metadata?.size;
377
+ if (expectedSize !== undefined && bodyBytes.length !== expectedSize) {
378
+ return c.json(
379
+ {
380
+ error: 'SIZE_MISMATCH',
381
+ message: `Expected ${expectedSize} bytes, got ${bodyBytes.length}`,
382
+ },
383
+ 400
384
+ );
385
+ }
386
+
387
+ // Verify hash
388
+ const computedHash = await computeSha256Hash(bodyBytes);
389
+ const expectedHex = parseBlobHash(hash);
390
+ if (computedHash !== expectedHex) {
391
+ return c.json(
392
+ {
393
+ error: 'HASH_MISMATCH',
394
+ message: 'Content hash does not match',
395
+ },
396
+ 400
397
+ );
398
+ }
399
+
400
+ // Store via the blob adapter (R2, database, etc.)
401
+ const mimeType =
402
+ c.req.header('Content-Type') ??
403
+ metadata?.mimeType ??
404
+ 'application/octet-stream';
405
+
406
+ if (blobManager.adapter.put) {
407
+ await blobManager.adapter.put(hash, bodyBytes, { mimeType });
408
+ } else {
409
+ await storeBlobInDatabase(db, {
410
+ hash,
411
+ size: bodyBytes.length,
412
+ mimeType,
413
+ body: bodyBytes,
414
+ });
415
+ }
416
+
417
+ return c.text('OK', 200);
418
+ }
419
+ );
420
+
421
+ // -------------------------------------------------------------------------
422
+ // GET /blobs/:hash/download - Direct download (database adapter)
423
+ // -------------------------------------------------------------------------
424
+
425
+ routes.get(
426
+ '/blobs/:hash/download',
427
+ describeRoute({
428
+ tags: ['blobs'],
429
+ summary: 'Direct blob download',
430
+ description:
431
+ 'Direct download endpoint for database storage adapter. Requires a signed token.',
432
+ responses: {
433
+ 200: {
434
+ description: 'Blob content',
435
+ },
436
+ 401: {
437
+ description: 'Invalid or expired token',
438
+ content: {
439
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
440
+ },
441
+ },
442
+ 404: {
443
+ description: 'Not found',
444
+ content: {
445
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
446
+ },
447
+ },
448
+ },
449
+ }),
450
+ zValidator('param', hashParamsSchema),
451
+ zValidator('query', tokenQuerySchema),
452
+ async (c) => {
453
+ const { hash } = c.req.valid('param');
454
+ const { token } = c.req.valid('query');
455
+
456
+ // Verify token
457
+ const payload = await tokenSigner.verify(token);
458
+ if (
459
+ !payload ||
460
+ payload.action !== 'download' ||
461
+ payload.hash !== hash
462
+ ) {
463
+ return c.json({ error: 'INVALID_TOKEN' }, 401);
464
+ }
465
+
466
+ // Read via the blob adapter (R2, database, etc.)
467
+ if (blobManager.adapter.get) {
468
+ const data = await blobManager.adapter.get(hash);
469
+ if (!data) {
470
+ return c.json({ error: 'NOT_FOUND' }, 404);
471
+ }
472
+ const meta = blobManager.adapter.getMetadata
473
+ ? await blobManager.adapter.getMetadata(hash)
474
+ : null;
475
+ return new Response(data as BodyInit, {
476
+ status: 200,
477
+ headers: {
478
+ 'Content-Type': meta?.mimeType ?? 'application/octet-stream',
479
+ 'Content-Length': String(data.length),
480
+ 'Cache-Control': 'private, max-age=31536000, immutable',
481
+ },
482
+ });
483
+ }
484
+
485
+ // Fallback: read from database directly
486
+ const blob = await readBlobFromDatabase(db, hash);
487
+ if (!blob) {
488
+ return c.json({ error: 'NOT_FOUND' }, 404);
489
+ }
490
+
491
+ return new Response(blob.body as BodyInit, {
492
+ status: 200,
493
+ headers: {
494
+ 'Content-Type': blob.mimeType,
495
+ 'Content-Length': String(blob.size),
496
+ 'Cache-Control': 'private, max-age=31536000, immutable',
497
+ },
498
+ });
499
+ }
500
+ );
501
+ }
502
+
503
+ return routes;
504
+ }
505
+
506
+ // ============================================================================
507
+ // Helpers
508
+ // ============================================================================
509
+
510
+ function isBlobValidationError(err: unknown): err is BlobValidationError {
511
+ return (
512
+ typeof err === 'object' &&
513
+ err !== null &&
514
+ (err as { name?: string }).name === 'BlobValidationError'
515
+ );
516
+ }
517
+
518
+ function isBlobNotFoundError(err: unknown): err is BlobNotFoundError {
519
+ return (
520
+ typeof err === 'object' &&
521
+ err !== null &&
522
+ (err as { name?: string }).name === 'BlobNotFoundError'
523
+ );
524
+ }
525
+
526
+ async function computeSha256Hash(data: Uint8Array): Promise<string> {
527
+ // Create a new ArrayBuffer copy to satisfy TypeScript's strict typing
528
+ const buffer = new Uint8Array(data).buffer as ArrayBuffer;
529
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
530
+ const hashArray = new Uint8Array(hashBuffer);
531
+ return Array.from(hashArray)
532
+ .map((b) => b.toString(16).padStart(2, '0'))
533
+ .join('');
534
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @syncular/server-hono - Console API
3
+ *
4
+ * Provides monitoring and operations endpoints for the @syncular dashboard.
5
+ */
6
+
7
+ // Re-export types from routes (which exports from schemas)
8
+ export type {
9
+ ConsoleAuthResult,
10
+ ConsoleEventEmitter,
11
+ CreateConsoleRoutesOptions,
12
+ } from './routes';
13
+ export {
14
+ createConsoleEventEmitter,
15
+ createConsoleRoutes,
16
+ createTokenAuthenticator,
17
+ } from './routes';