@syncular/server-hono 0.26.5 → 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 +6 -1
- package/dist/admin.js +7 -7
- package/dist/index.js +47 -18
- package/package.json +3 -3
- package/src/admin.ts +13 -8
- package/src/index.ts +61 -26
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,
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
48
|
+
"@syncular/server": "0.26.6",
|
|
49
49
|
"hono": "^4.12.34"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@syncular/core": "0.26.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
118
|
-
result.
|
|
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
|
|