/** * Hono adapter: a thin wrapper proving the embed boundary. * Hono is a dependency of this adapter only, never of the server core. * Mounts the §1.1 routes including sync, registered operations, segments, and * blobs. Realtime upgrades are runtime-specific and stay with the host. */ import { adapterSyncError, composeEvents, decodeRequestBody, encodeBody, encodeStream, errorBody, handleBlobDownload, handleBlobUpload, handleBlobUploadGrant, openSegmentDownload, handleRemoteOperation, type RemoteOperationRegistry, type RemoteOperationResolver, handleSyncRequest, RealtimeHub, RingBufferEvents, SEGMENT_STREAM_THRESHOLD_BYTES, SSP2_CONTENT_TYPE, SyncError, SyncularAdmin, type SyncularErrorRoute, type SyncServerConfig, varyOnAcceptEncoding, } from '@syncular/server'; import { Hono, type Context } from 'hono'; import { createSyncularAdminRoutes, type SyncularAdminGuards } from './admin'; export * from './admin'; export interface SyncularHonoOptions { readonly config: SyncServerConfig; readonly operations?: RemoteOperationRegistry | RemoteOperationResolver; /** Host authentication (§1.1); `null` ⇒ 401 `sync.auth_required`. */ readonly authenticate: ( request: Request, ) => Promise<{ actorId: string; partition: string } | null>; /** Mount the admin console (see `SyncularHonoAdminOptions`). */ readonly admin?: SyncularHonoAdminOptions; } /** * The admin console. The app mounts it at `path` and keeps an event ring of * `ringCapacity` events, composed into `config.events` and into the * realtime hub of `config.realtime`, so the console sees HTTP and socket * rounds. */ export interface SyncularHonoAdminOptions extends SyncularAdminGuards { /** The mount path; default `/admin`. */ readonly path?: string; /** Events the console keeps in memory; default 1000. */ readonly ringCapacity?: number; } export function createSyncularHono(options: SyncularHonoOptions): Hono { const app = new Hono(); let config = options.config; const admin = options.admin; if (admin !== undefined) { const ring = new RingBufferEvents( admin.ringCapacity !== undefined ? { capacity: admin.ringCapacity } : undefined, ); config = { ...config, events: config.events === undefined ? ring : composeEvents(config.events, ring), }; if (config.realtime instanceof RealtimeHub) { config.realtime.addEventSink(ring); } app.route( admin.path ?? '/admin', createSyncularAdminRoutes(SyncularAdmin.fromConfig(config, { ring }), { authorize: admin.authorize, ...(admin.authorizeActions !== undefined ? { authorizeActions: admin.authorizeActions } : {}), ...(config.onError !== undefined ? { onError: config.onError } : {}), ...(config.mapError !== undefined ? { mapError: config.mapError } : {}), }), ); } // A `SyncError` answers with its catalog status. Any other exception goes // to `config.onError` and answers 500 `sync.internal_error` (§10.2). const errorResponse = ( c: Context, error: unknown, route: SyncularErrorRoute = 'sync', ): Response => { const sync = adapterSyncError( error, (error, context) => config.onError?.(error, context), route, (error, context) => config.mapError?.(error, context), ); const response = Response.json(errorBody(sync), { status: sync.httpStatus, }); return c.newResponse(response.body, response); }; // §1.3: a client MAY gzip its request body; any other declared // `Content-Encoding` is refused with 415 instead of being misread. const unsupportedEncoding = ( c: Context, route: 'sync' | 'operations', ): Response => c.json( errorBody( new SyncError( route === 'sync' ? 'sync.invalid_request' : 'operation.invalid_request', 'unsupported content encoding', ), ), 415, ); // §1.3: every buffered body — a sync round, an operation result, a blob, a // segment — is encoded per the request's `Accept-Encoding` (zstd preferred, // gzip fallback, identity otherwise) and carries `Vary: Accept-Encoding`. // Headers are built through the Hono context so host middleware headers // survive; `encodeBody: 'manual'` marks the body as already encoded, so // workerd does not encode it a second time (see ALREADY_ENCODED). const encodedResponse = async ( c: Context, bytes: Uint8Array, headers: Record, ): Promise => { const encoded = await encodeBody(bytes, c.req.header('accept-encoding')); const out = c.newResponse(null, 200, { ...headers, ...(encoded.contentEncoding !== undefined ? { 'Content-Encoding': encoded.contentEncoding } : {}), }).headers; varyOnAcceptEncoding(out); return new Response(encoded.bytes.slice().buffer as ArrayBuffer, { status: 200, headers: out, ...ALREADY_ENCODED, }); }; // Throws outside a route's own try, such as from `authenticate`. app.onError((error, c) => { const segment = c.req.path.split('/')[1]; return errorResponse( c, error, segment === 'operations' || segment === 'segments' || segment === 'blobs' ? segment : 'sync', ); }); app.post('/sync', async (c) => { const contentType = c.req.header('content-type')?.split(';')[0]?.trim(); if (contentType !== SSP2_CONTENT_TYPE) { // §1.1: any other content type is rejected with HTTP 415. return c.json( errorBody( new SyncError('sync.invalid_request', 'unsupported content type'), ), 415, ); } const auth = await options.authenticate(c.req.raw); if (auth === null) return errorResponse(c, new SyncError('sync.auth_required')); try { const bytes = await decodeRequestBody(c.req.raw); if (bytes === undefined) return unsupportedEncoding(c, 'sync'); const out = await handleSyncRequest(bytes, { ...config, ...auth, }); return await encodedResponse(c, out, { 'Content-Type': SSP2_CONTENT_TYPE, }); } catch (error) { return errorResponse(c, error); } }); app.post('/operations', async (c) => { const contentType = c.req.header('content-type')?.split(';')[0]?.trim(); if (contentType !== 'application/vnd.syncular.operations.v1+json') { return c.json(errorBody(new SyncError('operation.invalid_request')), 415); } if (options.operations === undefined) { return errorResponse(c, new SyncError('operation.unknown')); } const auth = await options.authenticate(c.req.raw); if (auth === null) return errorResponse(c, new SyncError('sync.auth_required')); try { const bytes = await decodeRequestBody(c.req.raw); if (bytes === undefined) return unsupportedEncoding(c, 'operations'); const out = await handleRemoteOperation( bytes, { ...config, ...auth }, options.operations, ); return await encodedResponse(c, out, { 'Content-Type': 'application/vnd.syncular.operations.v1+json', }); } catch (error) { return errorResponse(c, error, 'operations'); } }); app.get('/segments/:segmentId', async (c) => { const auth = await options.authenticate(c.req.raw); if (auth === null) return errorResponse(c, new SyncError('sync.auth_required')); try { const result = await openSegmentDownload( { ...config, ...auth }, { segmentId: c.req.param('segmentId'), scopesHeader: c.req.header('x-syncular-scopes') ?? '{}', }, ); if (c.req.header('if-none-match') === result.headers.ETag) { await result.body.cancel(); return c.body(null, 304, result.headers); } const acceptEncoding = c.req.header('accept-encoding'); if (result.byteLength > SEGMENT_STREAM_THRESHOLD_BYTES) { // A large segment (a sqlite image) is relayed without buffering; // only a streaming codec applies (§1.3). const encoded = encodeStream(result.body, acceptEncoding); return new Response(encoded.body, { status: 200, headers: c.newResponse(null, 200, { ...result.headers, ...(encoded.contentEncoding !== undefined ? { 'Content-Encoding': encoded.contentEncoding } : { 'Content-Length': String(result.byteLength) }), }).headers, ...ALREADY_ENCODED, }); } // §1.3: compress the body per Accept-Encoding (zstd preferred, gzip // fallback, identity otherwise). Content addresses are over the // uncompressed bytes (§5.1) — fetch decodes transparently on the // client. const bytes = new Uint8Array( await new Response(result.body).arrayBuffer(), ); return await encodedResponse(c, bytes, result.headers); } catch (error) { return errorResponse(c, error, 'segments'); } }); // §5.9.3: blob upload with server-side content-address verification. app.put('/blobs/:blobId', async (c) => { const auth = await options.authenticate(c.req.raw); if (auth === null) return errorResponse(c, new SyncError('sync.auth_required')); try { const bytes = new Uint8Array(await c.req.arrayBuffer()); const contentType = c.req.header('content-type')?.split(';')[0]?.trim(); await handleBlobUpload( { ...config, ...auth }, { blobId: c.req.param('blobId'), bytes, ...(contentType !== undefined && contentType !== 'application/octet-stream' ? { mediaType: contentType } : {}), }, ); return c.body(null, 200); } catch (error) { return errorResponse(c, error, 'blobs'); } }); // §5.9.3: presigned-upload grant — mint a direct-to-storage PUT URL. app.post('/blobs/:blobId/upload-grant', async (c) => { const auth = await options.authenticate(c.req.raw); if (auth === null) return errorResponse(c, new SyncError('sync.auth_required')); try { const body = (await c.req.json().catch(() => ({}))) as { byteLength?: number; mediaType?: string; }; const grant = await handleBlobUploadGrant( { ...config, ...auth }, { blobId: c.req.param('blobId'), byteLength: Number(body.byteLength ?? 0), ...(typeof body.mediaType === 'string' ? { mediaType: body.mediaType } : {}), }, ); return c.json(grant, 200); } catch (error) { return errorResponse(c, error, 'blobs'); } }); // §5.9.5: blob download, re-authorized against referencing rows. When the // host configured presigned URLs, the result carries `url` (no bytes) and // the client fetches it directly (§5.9.5 always-issue). app.get('/blobs/:blobId', async (c) => { const auth = await options.authenticate(c.req.raw); if (auth === null) return errorResponse(c, new SyncError('sync.auth_required')); try { const result = await handleBlobDownload( { ...config, ...auth }, c.req.param('blobId'), ); if (result.url !== undefined) { return c.json( { url: result.url, urlExpiresAtMs: result.urlExpiresAtMs }, 200, ); } if (c.req.header('if-none-match') === result.headers.ETag) { return c.body(null, 304, result.headers); } return await encodedResponse( c, result.bytes ?? new Uint8Array(0), result.headers, ); } catch (error) { return errorResponse(c, error, 'blobs'); } }); return app; } /** * Every buffered route — sync, operations, blobs and segments — encodes its * own body (§1.3) and declares `Content-Encoding`. workerd encodes every * response that declares one again unless `encodeBody: 'manual'` marks the * body as encoded, so a Workers client would decode gzip inside gzip and fail * §5.1 verification (SYNCULAR-WORKERS-SEGMENT-ENCODING-001). Other runtimes * ignore the member. */ // Build response headers through the Hono context, then keep this runtime-specific // Response init. Hono newResponse/body discard encodeBody from their init. const ALREADY_ENCODED: { readonly encodeBody: 'manual' } = { encodeBody: 'manual', };