@syncular/server-hono 0.26.4 → 0.26.6

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/admin.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * commit metadata, scope activity, and reactions) and authorization is
11
11
  * entirely the host's and mandatory.
12
12
  */
13
- import { type SyncularAdmin } from '@syncular/server';
13
+ import { type SyncularAdmin, type SyncularErrorHandler } from '@syncular/server';
14
14
  import { Hono } from 'hono';
15
15
  export interface AdminAuthContext {
16
16
  /** The partition the request targets (from the query string / route). */
@@ -30,6 +30,11 @@ export interface SyncularAdminRoutesOptions {
30
30
  * When unset, `partition` is required on every data endpoint.
31
31
  */
32
32
  readonly defaultPartition?: string;
33
+ /**
34
+ * Host error reporting: receives every exception that is not a
35
+ * `SyncError`, which answers 500 `sync.internal_error` (§10.2).
36
+ */
37
+ readonly onError?: SyncularErrorHandler;
33
38
  }
34
39
  /**
35
40
  * Build the mountable admin sub-app. Mount it under any prefix, e.g.
package/dist/admin.js CHANGED
@@ -10,15 +10,9 @@
10
10
  * commit metadata, scope activity, and reactions) and authorization is
11
11
  * entirely the host's and mandatory.
12
12
  */
13
- import { errorBody, matchesRingQuery, SyncError, } from '@syncular/server';
13
+ import { adapterSyncError, errorBody, matchesRingQuery, SyncError, } from '@syncular/server';
14
14
  import { Hono } from 'hono';
15
15
  import { ADMIN_CONSOLE_HTML } from './admin-page.js';
16
- function jsonError(error) {
17
- const sync = error instanceof SyncError
18
- ? error
19
- : new SyncError('sync.invalid_request', String(error));
20
- return Response.json(errorBody(sync), { status: sync.httpStatus });
21
- }
22
16
  function intParam(value, fallback) {
23
17
  if (value === undefined)
24
18
  return fallback;
@@ -35,6 +29,12 @@ export function createSyncularAdminRoutes(admin, options) {
35
29
  throw new Error('createSyncularAdminRoutes: an `authorize` guard is required — admin never mounts default-open');
36
30
  }
37
31
  const app = new Hono();
32
+ const jsonError = (error) => {
33
+ const sync = adapterSyncError(error, options.onError, 'admin');
34
+ return Response.json(errorBody(sync), { status: sync.httpStatus });
35
+ };
36
+ // Throws outside a route's own try, such as from `authorize`.
37
+ app.onError((error) => jsonError(error));
38
38
  /** Resolve the request's partition (query, else default) — required. */
39
39
  function partitionOf(c) {
40
40
  const partition = c.req.query('partition') ?? options.defaultPartition;
package/dist/index.js CHANGED
@@ -4,17 +4,24 @@
4
4
  * Mounts the §1.1 routes including sync, registered operations, segments, and
5
5
  * blobs. Realtime upgrades are runtime-specific and stay with the host.
6
6
  */
7
- import { encodeSegmentBody, errorBody, handleBlobDownload, handleBlobUpload, handleBlobUploadGrant, handleSegmentDownload, handleRemoteOperation, handleSyncRequest, SSP2_CONTENT_TYPE, SyncError, } from '@syncular/server';
7
+ import { adapterSyncError, encodeSegmentBody, encodeSegmentStream, errorBody, handleBlobDownload, handleBlobUpload, handleBlobUploadGrant, openSegmentDownload, handleRemoteOperation, handleSyncRequest, SEGMENT_STREAM_THRESHOLD_BYTES, SSP2_CONTENT_TYPE, SyncError, } from '@syncular/server';
8
8
  import { Hono } from 'hono';
9
9
  export * from './admin.js';
10
- function errorResponse(error) {
11
- const sync = error instanceof SyncError
12
- ? error
13
- : new SyncError('sync.invalid_request', String(error));
14
- return Response.json(errorBody(sync), { status: sync.httpStatus });
15
- }
16
10
  export function createSyncularHono(options) {
17
11
  const app = new Hono();
12
+ // A `SyncError` answers with its catalog status. Any other exception goes
13
+ // to `config.onError` and answers 500 `sync.internal_error` (§10.2).
14
+ const errorResponse = (error, route = 'sync') => {
15
+ const sync = adapterSyncError(error, options.config.onError, route);
16
+ return Response.json(errorBody(sync), { status: sync.httpStatus });
17
+ };
18
+ // Throws outside a route's own try, such as from `authenticate`.
19
+ app.onError((error, c) => {
20
+ const segment = c.req.path.split('/')[1];
21
+ return errorResponse(error, segment === 'operations' || segment === 'segments' || segment === 'blobs'
22
+ ? segment
23
+ : 'sync');
24
+ });
18
25
  app.post('/sync', async (c) => {
19
26
  const contentType = c.req.header('content-type')?.split(';')[0]?.trim();
20
27
  if (contentType !== SSP2_CONTENT_TYPE) {
@@ -49,29 +56,51 @@ export function createSyncularHono(options) {
49
56
  const auth = await options.authenticate(c.req.raw);
50
57
  if (auth === null)
51
58
  return errorResponse(new SyncError('sync.auth_required'));
52
- const bytes = new Uint8Array(await c.req.arrayBuffer());
53
- const out = await handleRemoteOperation(bytes, { ...options.config, ...auth }, options.operations);
54
- return c.body(out.slice().buffer, 200, {
55
- 'Content-Type': 'application/vnd.syncular.operations.v1+json',
56
- });
59
+ try {
60
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
61
+ const out = await handleRemoteOperation(bytes, { ...options.config, ...auth }, options.operations);
62
+ return c.body(out.slice().buffer, 200, {
63
+ 'Content-Type': 'application/vnd.syncular.operations.v1+json',
64
+ });
65
+ }
66
+ catch (error) {
67
+ return errorResponse(error, 'operations');
68
+ }
57
69
  });
58
70
  app.get('/segments/:segmentId', async (c) => {
59
71
  const auth = await options.authenticate(c.req.raw);
60
72
  if (auth === null)
61
73
  return errorResponse(new SyncError('sync.auth_required'));
62
74
  try {
63
- const result = await handleSegmentDownload({ ...options.config, ...auth }, {
75
+ const result = await openSegmentDownload({ ...options.config, ...auth }, {
64
76
  segmentId: c.req.param('segmentId'),
65
77
  scopesHeader: c.req.header('x-syncular-scopes') ?? '{}',
66
78
  });
67
79
  if (c.req.header('if-none-match') === result.headers.ETag) {
80
+ await result.body.cancel();
68
81
  return c.body(null, 304, result.headers);
69
82
  }
83
+ const acceptEncoding = c.req.header('accept-encoding');
84
+ if (result.byteLength > SEGMENT_STREAM_THRESHOLD_BYTES) {
85
+ // A large segment (a sqlite image) is relayed without buffering;
86
+ // only a streaming codec applies (§5.8).
87
+ const encoded = encodeSegmentStream(result.body, acceptEncoding);
88
+ return new Response(encoded.body, {
89
+ status: 200,
90
+ headers: {
91
+ ...result.headers,
92
+ ...(encoded.contentEncoding !== undefined
93
+ ? { 'Content-Encoding': encoded.contentEncoding }
94
+ : { 'Content-Length': String(result.byteLength) }),
95
+ },
96
+ });
97
+ }
70
98
  // §5.8 shipped default: compress the body per Accept-Encoding
71
99
  // (zstd preferred, gzip fallback, identity otherwise). Content
72
100
  // addresses are over the uncompressed bytes (§5.1) — fetch
73
101
  // decodes transparently on the client.
74
- const encoded = encodeSegmentBody(result.bytes, c.req.header('accept-encoding'));
102
+ const bytes = new Uint8Array(await new Response(result.body).arrayBuffer());
103
+ const encoded = encodeSegmentBody(bytes, acceptEncoding);
75
104
  return c.body(encoded.bytes.slice().buffer, 200, {
76
105
  ...result.headers,
77
106
  ...(encoded.contentEncoding !== undefined
@@ -80,7 +109,7 @@ export function createSyncularHono(options) {
80
109
  });
81
110
  }
82
111
  catch (error) {
83
- return errorResponse(error);
112
+ return errorResponse(error, 'segments');
84
113
  }
85
114
  });
86
115
  // §5.9.3: blob upload with server-side content-address verification.
@@ -102,7 +131,7 @@ export function createSyncularHono(options) {
102
131
  return c.body(null, 200);
103
132
  }
104
133
  catch (error) {
105
- return errorResponse(error);
134
+ return errorResponse(error, 'blobs');
106
135
  }
107
136
  });
108
137
  // §5.9.3: presigned-upload grant — mint a direct-to-storage PUT URL.
@@ -122,7 +151,7 @@ export function createSyncularHono(options) {
122
151
  return c.json(grant, 200);
123
152
  }
124
153
  catch (error) {
125
- return errorResponse(error);
154
+ return errorResponse(error, 'blobs');
126
155
  }
127
156
  });
128
157
  // §5.9.5: blob download, re-authorized against referencing rows. When the
@@ -146,7 +175,7 @@ export function createSyncularHono(options) {
146
175
  });
147
176
  }
148
177
  catch (error) {
149
- return errorResponse(error);
178
+ return errorResponse(error, 'blobs');
150
179
  }
151
180
  });
152
181
  return app;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server-hono",
3
- "version": "0.26.4",
3
+ "version": "0.26.6",
4
4
  "description": "Hono adapter for the Syncular sync server",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -45,10 +45,10 @@
45
45
  "!dist/**/*.test.d.ts"
46
46
  ],
47
47
  "dependencies": {
48
- "@syncular/server": "0.26.4",
48
+ "@syncular/server": "0.26.6",
49
49
  "hono": "^4.12.34"
50
50
  },
51
51
  "devDependencies": {
52
- "@syncular/core": "0.26.4"
52
+ "@syncular/core": "0.26.6"
53
53
  }
54
54
  }
package/src/admin.ts CHANGED
@@ -11,12 +11,14 @@
11
11
  * entirely the host's and mandatory.
12
12
  */
13
13
  import {
14
+ adapterSyncError,
14
15
  errorBody,
15
16
  matchesRingQuery,
16
17
  type RingEventQuery,
17
18
  type ReactionStatus,
18
19
  SyncError,
19
20
  type SyncularAdmin,
21
+ type SyncularErrorHandler,
20
22
  type SyncularServerEvent,
21
23
  } from '@syncular/server';
22
24
  import { Hono } from 'hono';
@@ -41,14 +43,11 @@ export interface SyncularAdminRoutesOptions {
41
43
  * When unset, `partition` is required on every data endpoint.
42
44
  */
43
45
  readonly defaultPartition?: string;
44
- }
45
-
46
- function jsonError(error: unknown): Response {
47
- const sync =
48
- error instanceof SyncError
49
- ? error
50
- : new SyncError('sync.invalid_request', String(error));
51
- return Response.json(errorBody(sync), { status: sync.httpStatus });
46
+ /**
47
+ * Host error reporting: receives every exception that is not a
48
+ * `SyncError`, which answers 500 `sync.internal_error` (§10.2).
49
+ */
50
+ readonly onError?: SyncularErrorHandler;
52
51
  }
53
52
 
54
53
  function intParam(value: string | undefined, fallback: number): number {
@@ -72,6 +71,12 @@ export function createSyncularAdminRoutes(
72
71
  );
73
72
  }
74
73
  const app = new Hono();
74
+ const jsonError = (error: unknown): Response => {
75
+ const sync = adapterSyncError(error, options.onError, 'admin');
76
+ return Response.json(errorBody(sync), { status: sync.httpStatus });
77
+ };
78
+ // Throws outside a route's own try, such as from `authorize`.
79
+ app.onError((error) => jsonError(error));
75
80
 
76
81
  /** Resolve the request's partition (query, else default) — required. */
77
82
  function partitionOf(c: {
package/src/index.ts CHANGED
@@ -5,17 +5,21 @@
5
5
  * blobs. Realtime upgrades are runtime-specific and stay with the host.
6
6
  */
7
7
  import {
8
+ adapterSyncError,
8
9
  encodeSegmentBody,
10
+ encodeSegmentStream,
9
11
  errorBody,
10
12
  handleBlobDownload,
11
13
  handleBlobUpload,
12
14
  handleBlobUploadGrant,
13
- handleSegmentDownload,
15
+ openSegmentDownload,
14
16
  handleRemoteOperation,
15
17
  type RemoteOperationRegistry,
16
18
  handleSyncRequest,
19
+ SEGMENT_STREAM_THRESHOLD_BYTES,
17
20
  SSP2_CONTENT_TYPE,
18
21
  SyncError,
22
+ type SyncularErrorRoute,
19
23
  type SyncServerConfig,
20
24
  } from '@syncular/server';
21
25
  import { Hono } from 'hono';
@@ -31,16 +35,27 @@ export interface SyncularHonoOptions {
31
35
  ) => Promise<{ actorId: string; partition: string } | null>;
32
36
  }
33
37
 
34
- function errorResponse(error: unknown): Response {
35
- const sync =
36
- error instanceof SyncError
37
- ? error
38
- : new SyncError('sync.invalid_request', String(error));
39
- return Response.json(errorBody(sync), { status: sync.httpStatus });
40
- }
41
-
42
38
  export function createSyncularHono(options: SyncularHonoOptions): Hono {
43
39
  const app = new Hono();
40
+ // A `SyncError` answers with its catalog status. Any other exception goes
41
+ // to `config.onError` and answers 500 `sync.internal_error` (§10.2).
42
+ const errorResponse = (
43
+ error: unknown,
44
+ route: SyncularErrorRoute = 'sync',
45
+ ): Response => {
46
+ const sync = adapterSyncError(error, options.config.onError, route);
47
+ return Response.json(errorBody(sync), { status: sync.httpStatus });
48
+ };
49
+ // Throws outside a route's own try, such as from `authenticate`.
50
+ app.onError((error, c) => {
51
+ const segment = c.req.path.split('/')[1];
52
+ return errorResponse(
53
+ error,
54
+ segment === 'operations' || segment === 'segments' || segment === 'blobs'
55
+ ? segment
56
+ : 'sync',
57
+ );
58
+ });
44
59
 
45
60
  app.post('/sync', async (c) => {
46
61
  const contentType = c.req.header('content-type')?.split(';')[0]?.trim();
@@ -84,15 +99,19 @@ export function createSyncularHono(options: SyncularHonoOptions): Hono {
84
99
  const auth = await options.authenticate(c.req.raw);
85
100
  if (auth === null)
86
101
  return errorResponse(new SyncError('sync.auth_required'));
87
- const bytes = new Uint8Array(await c.req.arrayBuffer());
88
- const out = await handleRemoteOperation(
89
- bytes,
90
- { ...options.config, ...auth },
91
- options.operations,
92
- );
93
- return c.body(out.slice().buffer as ArrayBuffer, 200, {
94
- 'Content-Type': 'application/vnd.syncular.operations.v1+json',
95
- });
102
+ try {
103
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
104
+ const out = await handleRemoteOperation(
105
+ bytes,
106
+ { ...options.config, ...auth },
107
+ options.operations,
108
+ );
109
+ return c.body(out.slice().buffer as ArrayBuffer, 200, {
110
+ 'Content-Type': 'application/vnd.syncular.operations.v1+json',
111
+ });
112
+ } catch (error) {
113
+ return errorResponse(error, 'operations');
114
+ }
96
115
  });
97
116
 
98
117
  app.get('/segments/:segmentId', async (c) => {
@@ -100,7 +119,7 @@ export function createSyncularHono(options: SyncularHonoOptions): Hono {
100
119
  if (auth === null)
101
120
  return errorResponse(new SyncError('sync.auth_required'));
102
121
  try {
103
- const result = await handleSegmentDownload(
122
+ const result = await openSegmentDownload(
104
123
  { ...options.config, ...auth },
105
124
  {
106
125
  segmentId: c.req.param('segmentId'),
@@ -108,16 +127,32 @@ export function createSyncularHono(options: SyncularHonoOptions): Hono {
108
127
  },
109
128
  );
110
129
  if (c.req.header('if-none-match') === result.headers.ETag) {
130
+ await result.body.cancel();
111
131
  return c.body(null, 304, result.headers);
112
132
  }
133
+ const acceptEncoding = c.req.header('accept-encoding');
134
+ if (result.byteLength > SEGMENT_STREAM_THRESHOLD_BYTES) {
135
+ // A large segment (a sqlite image) is relayed without buffering;
136
+ // only a streaming codec applies (§5.8).
137
+ const encoded = encodeSegmentStream(result.body, acceptEncoding);
138
+ return new Response(encoded.body, {
139
+ status: 200,
140
+ headers: {
141
+ ...result.headers,
142
+ ...(encoded.contentEncoding !== undefined
143
+ ? { 'Content-Encoding': encoded.contentEncoding }
144
+ : { 'Content-Length': String(result.byteLength) }),
145
+ },
146
+ });
147
+ }
113
148
  // §5.8 shipped default: compress the body per Accept-Encoding
114
149
  // (zstd preferred, gzip fallback, identity otherwise). Content
115
150
  // addresses are over the uncompressed bytes (§5.1) — fetch
116
151
  // decodes transparently on the client.
117
- const encoded = encodeSegmentBody(
118
- result.bytes,
119
- c.req.header('accept-encoding'),
152
+ const bytes = new Uint8Array(
153
+ await new Response(result.body).arrayBuffer(),
120
154
  );
155
+ const encoded = encodeSegmentBody(bytes, acceptEncoding);
121
156
  return c.body(encoded.bytes.slice().buffer as ArrayBuffer, 200, {
122
157
  ...result.headers,
123
158
  ...(encoded.contentEncoding !== undefined
@@ -125,7 +160,7 @@ export function createSyncularHono(options: SyncularHonoOptions): Hono {
125
160
  : {}),
126
161
  });
127
162
  } catch (error) {
128
- return errorResponse(error);
163
+ return errorResponse(error, 'segments');
129
164
  }
130
165
  });
131
166
 
@@ -150,7 +185,7 @@ export function createSyncularHono(options: SyncularHonoOptions): Hono {
150
185
  );
151
186
  return c.body(null, 200);
152
187
  } catch (error) {
153
- return errorResponse(error);
188
+ return errorResponse(error, 'blobs');
154
189
  }
155
190
  });
156
191
 
@@ -176,7 +211,7 @@ export function createSyncularHono(options: SyncularHonoOptions): Hono {
176
211
  );
177
212
  return c.json(grant, 200);
178
213
  } catch (error) {
179
- return errorResponse(error);
214
+ return errorResponse(error, 'blobs');
180
215
  }
181
216
  });
182
217
 
@@ -206,7 +241,7 @@ export function createSyncularHono(options: SyncularHonoOptions): Hono {
206
241
  ...result.headers,
207
242
  });
208
243
  } catch (error) {
209
- return errorResponse(error);
244
+ return errorResponse(error, 'blobs');
210
245
  }
211
246
  });
212
247