|
1 | 1 | import type fastify from 'fastify'; |
| 2 | +import * as zlib from 'node:zlib'; |
2 | 3 | import * as uuid from 'uuid'; |
3 | 4 |
|
4 | | -import { errors, HTTPMethod, logger, RouteNotFound, router, ServiceError } from '@powersync/lib-services-framework'; |
| 5 | +import { errors, HTTPMethod, logger, RouteNotFound, router } from '@powersync/lib-services-framework'; |
5 | 6 | import { FastifyReply } from 'fastify'; |
6 | 7 | import { Context, ContextProvider, RequestEndpoint, RequestEndpointHandlerPayload } from './router.js'; |
7 | 8 |
|
@@ -105,18 +106,86 @@ export function registerFastifyNotFoundHandler(app: fastify.FastifyInstance) { |
105 | 106 | }); |
106 | 107 | } |
107 | 108 |
|
108 | | -function serviceErrorToResponse(error: ServiceError): router.RouterResponse { |
| 109 | +/** Registers a custom error handler that emits service-error JSON honouring any negotiated `Content-Encoding`. */ |
| 110 | +export function registerFastifyErrorHandler(app: fastify.FastifyInstance) { |
| 111 | + app.setErrorHandler<Error>(async (error, _request, reply) => { |
| 112 | + if (reply.raw.headersSent) { |
| 113 | + // Headers already flushed - reply.code/header would throw ERR_HTTP_HEADERS_SENT. |
| 114 | + reply.raw.destroy(error); |
| 115 | + return; |
| 116 | + } |
| 117 | + |
| 118 | + const { status, data } = fastifyErrorToResponse(error); |
| 119 | + if (status >= 500) { |
| 120 | + logger.error('Request failed', error); |
| 121 | + } else { |
| 122 | + logger.warn(`Request failed: ${error.message}`, { |
| 123 | + status, |
| 124 | + code: data.error.code |
| 125 | + }); |
| 126 | + } |
| 127 | + |
| 128 | + const json = JSON.stringify(data); |
| 129 | + |
| 130 | + // Fastify's default handler leaves `content-encoding` intact when it rewrites the body. |
| 131 | + const encoding = reply.getHeader('content-encoding'); |
| 132 | + let body: string | Buffer = json; |
| 133 | + if (encoding === 'gzip') { |
| 134 | + body = zlib.gzipSync(json); |
| 135 | + } else if (encoding === 'zstd') { |
| 136 | + body = zlib.zstdCompressSync(json); |
| 137 | + } else { |
| 138 | + reply.removeHeader('content-encoding'); |
| 139 | + } |
| 140 | + |
| 141 | + reply |
| 142 | + .code(status) |
| 143 | + .header('content-type', 'application/json; charset=utf-8') |
| 144 | + .header('content-length', Buffer.byteLength(body)) |
| 145 | + .send(body); |
| 146 | + }); |
| 147 | +} |
| 148 | + |
| 149 | +type ErrorResponseData = { error: errors.ErrorData }; |
| 150 | + |
| 151 | +function serviceErrorToResponse(serviceError: errors.ServiceError): router.RouterResponse<ErrorResponseData> { |
109 | 152 | return new router.RouterResponse({ |
110 | | - status: error.errorData.status || 500, |
| 153 | + status: serviceError.errorData.status || 500, |
111 | 154 | headers: { |
112 | 155 | 'Content-Type': 'application/json' |
113 | 156 | }, |
114 | 157 | data: { |
115 | | - error: error.errorData |
| 158 | + error: serviceError.errorData |
116 | 159 | } |
117 | 160 | }); |
118 | 161 | } |
119 | 162 |
|
| 163 | +function fastifyErrorToResponse(error: any): router.RouterResponse<ErrorResponseData> { |
| 164 | + // Preserve 4xx status from Fastify built-ins (validation, invalid JSON body, etc.) instead of collapsing to 500. |
| 165 | + if ( |
| 166 | + typeof error?.statusCode === 'number' && |
| 167 | + error.statusCode >= 400 && |
| 168 | + error.statusCode < 500 && |
| 169 | + typeof error.code === 'string' |
| 170 | + ) { |
| 171 | + return new router.RouterResponse({ |
| 172 | + status: error.statusCode, |
| 173 | + headers: { |
| 174 | + 'Content-Type': 'application/json' |
| 175 | + }, |
| 176 | + data: { |
| 177 | + error: { |
| 178 | + name: error.name, |
| 179 | + code: error.code, |
| 180 | + status: error.statusCode, |
| 181 | + description: error.message |
| 182 | + } |
| 183 | + } |
| 184 | + }); |
| 185 | + } |
| 186 | + return serviceErrorToResponse(errors.asServiceError(error)); |
| 187 | +} |
| 188 | + |
120 | 189 | async function respond(reply: FastifyReply, response: router.RouterResponse) { |
121 | 190 | Object.keys(response.headers).forEach((key) => { |
122 | 191 | reply.header(key, response.headers[key]); |
|
0 commit comments