diff --git a/TODO.md b/TODO.md index 8e5ba24..6647ffc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,15 +1,27 @@ - [ ] Integrate with other data stores + - [ ] SQLite + - [ ] PostgreSQL - [X] Access control with resources +- [ ] Custom definitions + - [ ] Middlewares + - [ ] Request decorators + - [ ] Status messages + - [ ] Response bodies (in case of error messages) - [ ] Content negotiation - - [ ] Language - - [X] Encoding - - [ ] Media Type + - [X] Language + - [X] Charset + - [X] Media Type + - [ ] Improve content negotiation on success/error responses (able to explicitly select language/media type/charset) - [X] HTTPS - [X] Date/Datetime handling (endpoints should be able to accept timestamps and ISO date/datetime strings) - [ ] Querying items in collections + - [ ] Better URL parsing for determining target resource/resource IDs (e.g. `/api/users/3/posts/5`, `/users/3` is a query, `posts` is the target resource, `5` is the target resource ID. Different case with `/api/users/3/posts/5/attachments`) - [ ] Tests - [X] Happy path - [ ] Error handling - [ ] Implement error handling compliant to RFC 9457 - Problem Details for HTTP APIs - [ ] Create RESTful client for frontend, server for backend (specify data sources on the server side) - [ ] `EventEmitter` for `202 Accepted` requests (CQRS-style service) +- [ ] Implement RPC endpoints +- [ ] Implement `Vary` header (requires providing a `getHeader()` method in the request object to listen for obtained headers) +- [ ] Add example on serving data as documents using `application/html` type. diff --git a/src/backend/http/server.ts b/src/backend/http/server.ts index 6f43397..394ab13 100644 --- a/src/backend/http/server.ts +++ b/src/backend/http/server.ts @@ -87,74 +87,102 @@ export interface CreateServerParams { streamResponses?: boolean; } -export interface Middleware { +type ResourceRequestContext = Omit & Required>; + +export interface Middleware { (req: Req): undefined | Response | Promise; } -const getAllowedMiddlewares = (resource?: Resource, mainResourceId?: string) => { - const middlewares = [] as [string, Middleware, v.BaseSchema?][]; +type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - if (typeof resource === 'undefined') { - return middlewares; - } +interface AllowedMiddlewareSpecification { + method: Method; + middleware: Middleware; + constructBodySchema?: (resource: Resource, resourceId?: string) => v.BaseSchema; + allowed: (resource: Resource) => boolean; +} - if (typeof resource.dataSource === 'undefined') { - return middlewares; - } +const constructPostSchema = (resource: Resource) => { + return resource.schema; +}; - if (typeof mainResourceId !== 'string') { - if (resource.state.canFetchCollection) { - middlewares.push(['GET', handleGetCollection]); - } - if (resource.state.canCreate) { - middlewares.push(['POST', handleCreateItem, resource.schema]); - } - return middlewares; +const constructPutSchema = (resource: Resource, mainResourceId?: string) => { + if (typeof mainResourceId === 'undefined') { + return resource.schema; } - if (resource.state.canFetchItem) { - middlewares.push(['GET', handleGetItem]); - } - if (resource.state.canEmplace) { - const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; - const idAttr = resource.state.shared.get('idAttr') as string; - const idConfig = resource.state.shared.get('idConfig') as any; - const putSchema = ( - schema.type === 'object' - ? v.merge([ - schema as v.ObjectSchema, - v.object({ - [idAttr]: v.transform( - v.any(), - input => idConfig!.serialize(input), - v.literal(mainResourceId) - ) - }) - ]) - : schema - ); - middlewares.push(['PUT', handleEmplaceItem, putSchema]); - } - if (resource.state.canPatch) { - const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; - const patchSchema = ( - schema.type === 'object' - ? v.partial( - schema as v.ObjectSchema, - (schema as v.ObjectSchema).rest, - (schema as v.ObjectSchema).pipe - ) - : schema - ); - middlewares.push(['PATCH', handlePatchItem, patchSchema]); - } - if (resource.state.canDelete) { - middlewares.push(['DELETE', handleDeleteItem]); - } + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; + const idAttr = resource.state.shared.get('idAttr') as string; + const idConfig = resource.state.shared.get('idConfig') as any; + return ( + schema.type === 'object' + ? v.merge([ + schema as v.ObjectSchema, + v.object({ + [idAttr]: v.transform( + v.any(), + input => idConfig!.serialize(input), + v.literal(mainResourceId) + ) + }) + ]) + : schema + ); +}; - return middlewares; +const constructPatchSchema = (resource: Resource) => { + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; + return ( + schema.type === 'object' + ? v.partial( + schema as v.ObjectSchema, + (schema as v.ObjectSchema).rest, + (schema as v.ObjectSchema).pipe + ) + : schema + ); }; +// TODO add a way to define custom middlewares +const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ + { + method: 'GET', + middleware: handleGetCollection, + allowed: (resource) => resource.state.canFetchCollection, + }, + { + method: 'POST', + middleware: handleCreateItem, + allowed: (resource) => resource.state.canCreate, + constructBodySchema: constructPostSchema, + }, +]; + +const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [ + { + method: 'GET', + middleware: handleGetItem, + allowed: (resource) => resource.state.canFetchItem, + }, + { + method: 'PUT', + middleware: handleEmplaceItem, + constructBodySchema: constructPutSchema, + allowed: (resource) => resource.state.canEmplace, + }, + { + method: 'PATCH', + middleware: handlePatchItem, + constructBodySchema: constructPatchSchema, + allowed: (resource) => resource.state.canPatch, + }, + { + method: 'DELETE', + middleware: handleDeleteItem, + allowed: (resource) => resource.state.canDelete, + }, +]; + export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { const isHttps = 'key' in serverParams && 'cert' in serverParams; @@ -182,25 +210,108 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr ].includes(mediaType) ); - const processRequest = (middlewares: [string, Middleware, v.BaseSchema?][]) => async (req: RequestContext) => { + const handleMiddlewares = async (currentHandlerState: Awaited>, currentMiddleware: AllowedMiddlewareSpecification, req: ResourceRequestContext) => { + const { method: middlewareMethod, middleware, constructBodySchema} = currentMiddleware; + const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method; + + if (effectiveMethod !== middlewareMethod) { + return currentHandlerState; + } + + if (typeof currentHandlerState !== 'undefined') { + return currentHandlerState; + } + + if (typeof constructBodySchema === 'function') { + const bodySchema = constructBodySchema(req.resource, req.resourceId); + const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; + const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); + const mediaType = fragments[0]; + const charsetParam = ( + fragments + .map((s) => s.trim()) + .find((f) => f.startsWith('charset=')) + + ?? ( + isTextMediaType(mediaType) + ? 'charset=utf-8' + : 'charset=binary' + ) + ); + const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim()); + const charset = ( + ( + (charsetRaw.startsWith('"') && charsetRaw.endsWith('"')) + || (charsetRaw.startsWith("'") && charsetRaw.endsWith("'")) + ) + ? charsetRaw.slice(1, -1).trim() + : charsetRaw.trim() + ) + const theBodyBuffer = await getBody(req); + const encodingPair = req.backend.app.charsets.get(charset); + if (typeof encodingPair === 'undefined') { + throw new HttpMiddlewareError('unableToDecodeResource', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + }); + } + const deserializerPair = req.backend.app.mediaTypes.get(mediaType); + if (typeof deserializerPair === 'undefined') { + throw new HttpMiddlewareError('unableToDeserializeResource', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + }); + } + const theBodyStr = encodingPair.decode(theBodyBuffer); + const theBody = deserializerPair.deserialize(theBodyStr); + try { + req.body = await v.parseAsync(bodySchema, theBody, {abortEarly: false, abortPipeEarly: false}); + } catch (errRaw) { + const err = errRaw as v.ValiError; + // todo use error message key for each method + // TODO better error reporting, localizable messages + // TODO handle error handlers' errors + if (Array.isArray(err.issues)) { + throw new HttpMiddlewareError('invalidResource', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + body: err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + }); + } + } + } + + const result = await middleware(req); + + // HEAD is just GET without the response body + if (req.method === 'HEAD' && result instanceof PlainResponse) { + const { body: _, ...etcResult } = result; + + return new PlainResponse(etcResult); + } + + return result; + }; + + const processRequest = (middlewares: AllowedMiddlewareSpecification[]) => async (req: ResourceRequestContext) => { if (req.url === '/' || req.url === '') { return handleGetRoot(req); } - if (typeof req.resource === 'undefined') { + const { resource } = req; + if (typeof resource === 'undefined') { throw new HttpMiddlewareError('resourceNotFound', { statusCode: constants.HTTP_STATUS_NOT_FOUND, }); } - if (typeof req.resource.dataSource === 'undefined') { + if (typeof resource.dataSource === 'undefined') { throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, }); } try { - await req.resource.dataSource.initialize(); + await resource.dataSource.initialize(); } catch (cause) { throw new HttpMiddlewareError( 'unableToInitializeResourceDataSource', @@ -211,87 +322,10 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr ); } - const middlewareResponse = await middlewares.reduce( - async (currentHandlerStatePromise, currentValue) => { - const [middlewareMethod, middleware, schema] = currentValue; - try { - const currentHandlerState = await currentHandlerStatePromise; - const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method; - - if (effectiveMethod !== middlewareMethod) { - return currentHandlerState; - } - - if (typeof currentHandlerState !== 'undefined') { - return currentHandlerState; - } - - if (schema) { - const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; - const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); - const mediaType = fragments[0]; - const charsetParam = ( - fragments - .map((s) => s.trim()) - .find((f) => f.startsWith('charset=')) - - ?? ( - isTextMediaType(mediaType) - ? 'charset=utf-8' - : 'charset=binary' - ) - ); - const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim()); - const charset = ( - ( - (charsetRaw.startsWith('"') && charsetRaw.endsWith('"')) - || (charsetRaw.startsWith("'") && charsetRaw.endsWith("'")) - ) - ? charsetRaw.slice(1, -1).trim() - : charsetRaw.trim() - ) - const theBodyBuffer = await getBody(req); - const encodingPair = req.backend.app.charsets.get(charset); - if (typeof encodingPair === 'undefined') { - throw new HttpMiddlewareError('unableToDecodeResource', { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - }); - } - const deserializerPair = req.backend.app.mediaTypes.get(mediaType); - if (typeof deserializerPair === 'undefined') { - throw new HttpMiddlewareError('unableToDeserializeResource', { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - }); - } - const theBodyStr = encodingPair.decode(theBodyBuffer); - const theBody = deserializerPair.deserialize(theBodyStr); - req.body = await v.parseAsync(schema, theBody, { abortEarly: false, abortPipeEarly: false }); - } - - const result = await middleware(req); - - if (req.method === 'HEAD' && result instanceof PlainResponse) { - const { body: _, ...etcResult } = result; - - return Promise.resolve(new PlainResponse(etcResult)); - } - - return Promise.resolve(result); - } catch (errRaw) { - // todo use error message key for each method - // TODO better error reporting, localizable messages - // TODO handle error handlers' errors - if (errRaw instanceof v.ValiError && Array.isArray(errRaw.issues)) { - throw new HttpMiddlewareError('invalidResource', { - statusCode: constants.HTTP_STATUS_BAD_REQUEST, - body: errRaw.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )), - }); - } - - throw errRaw; - } + const middlewareResponse = await middlewares.reduce>( + async (currentHandlerStatePromise, currentMiddleware) => { + const currentHandlerState = await currentHandlerStatePromise; + return await handleMiddlewares(currentHandlerState, currentMiddleware, req); }, Promise.resolve>(undefined) ) as Awaited>; @@ -319,135 +353,119 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr }; const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse) => { - const req = await decorateRequest(reqRaw); - const middlewares = getAllowedMiddlewares(req.resource, req.resourceId); - const processRequestFn = processRequest(middlewares); - let middlewareState: Response; - try { - middlewareState = await processRequestFn(req) as any; // TODO fix this - } catch (processRequestErrRaw) { - const finalErr = processRequestErrRaw as HttpMiddlewareError; - const headers = finalErr.response.headers ?? {}; - let encoded: Buffer | undefined; - let serialized; - try { - serialized = typeof finalErr.response.body !== 'undefined' ? req.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; - } catch (cause) { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - return; - } - + const plainReq = await decorateRequest(reqRaw); // TODO add type safety here, put handleGetRoot as its own middleware as it does not concern over any resource + if (typeof plainReq.resource !== 'undefined') { + const resourceReq = plainReq as ResourceRequestContext; + const effectiveMiddlewares = ( + typeof resourceReq.resourceId === 'string' + ? defaultItemMiddlewares + : defaultCollectionMiddlewares + ); + const middlewares = effectiveMiddlewares.filter((m) => m.allowed(resourceReq.resource)); + const processRequestFn = processRequest(middlewares); + let middlewareState: Response; try { - encoded = typeof serialized !== 'undefined' ? req.backend.cn.charset.encode(serialized) : undefined; - } catch (cause) { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - } - - headers['Content-Type'] = [ - req.backend.cn.mediaType.name, - `charset=${req.backend.cn.charset.name}` - ].join('; '); - - const statusMessageKey = finalErr.response.statusMessage ? req.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource!.state.itemName) ?? ''; - res.writeHead(finalErr.response.statusCode, headers); - if (typeof encoded !== 'undefined') { - res.end(encoded); - return; - } - res.end(); - return; - } - - const headers: Record = { - ...( - middlewareState.headers ?? {} - ), - 'Content-Language': req.cn.language.name - }; - - if (middlewareState instanceof http.ServerResponse) { - // TODO streaming responses - middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); - return; - } - - if (middlewareState instanceof PlainResponse) { - let encoded: Buffer | undefined; - if (typeof middlewareState.body !== 'undefined') { + middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this + } catch (processRequestErrRaw) { + const finalErr = processRequestErrRaw as HttpMiddlewareError; + const headers = finalErr.response.headers ?? {}; + let encoded: Buffer | undefined; let serialized; try { - serialized = req.cn.mediaType.serialize(middlewareState.body); + serialized = typeof finalErr.response.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; } catch (cause) { - res.statusMessage = req.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace(/\$RESOURCE/g, req.resource!.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': req.backend.cn.language.name, - }); + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); res.end(); return; } try { - encoded = req.cn.charset.encode(serialized); + encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined; } catch (cause) { - res.statusMessage = req.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g, req.resource!.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': req.backend.cn.language.name, - }); + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); res.end(); - return; } headers['Content-Type'] = [ - req.cn.mediaType.name, - `charset=${req.cn.charset.name}` + resourceReq.backend.cn.mediaType.name, + `charset=${resourceReq.backend.cn.charset.name}`, ].join('; '); - } - const statusMessageKey = middlewareState.statusMessage ? req.cn.language.statusMessages[middlewareState.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource!.state.itemName) ?? ''; - res.writeHead(middlewareState.statusCode, headers); - if (typeof encoded !== 'undefined') { - res.end(encoded); + const statusMessageKey = finalErr.response.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; + res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(finalErr.response.statusCode, headers); + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); return; } - res.end(); - return; - } - if (typeof middlewareState !== 'undefined') { - try { + // TODO extract to separate function + const headers: Record = { + ...( + middlewareState.headers ?? {} + ), + 'Content-Language': resourceReq.cn.language.name, + }; + if (middlewareState instanceof http.ServerResponse) { + // TODO streaming responses + middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); return; - } catch (finalErrRaw) { - const finalErr = finalErrRaw as HttpMiddlewareError; - const headers = finalErr.response.headers ?? {}; + } + if (middlewareState instanceof PlainResponse) { let encoded: Buffer | undefined; - let serialized; - try { - serialized = typeof finalErr.response.body !== 'undefined' ? req.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; - } catch (cause) { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - return; - } + if (typeof middlewareState.body !== 'undefined') { + let serialized; + try { + serialized = resourceReq.cn.mediaType.serialize(middlewareState.body); + } catch (cause) { + const headers: Record = { + 'Content-Language': resourceReq.backend.cn.language.name, + }; + if (resourceReq.method === 'POST') { + headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()).join(','); + } else if (resourceReq.method === 'PATCH') { + headers['Accept-Patch'] = Array.from(resourceReq.backend.app.mediaTypes.keys()).map((m) => { + const [mimeType, mimeSubtype] = m.split('/'); + + // TODO accept only patch document type from request + // TODO implement Vary header (which headers influenced the request) + // TODO implement OPTIONS method for determining the accepted media types and languages + // TODO configure strict and lax accept behavior for content negotiation + return `${mimeType}/merge-patch+${mimeSubtype}`; + }).join(','); + } + res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace( + /\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers); + res.end(); + return; + } - try { - encoded = typeof serialized !== 'undefined' ? req.backend.cn.charset.encode(serialized) : undefined; - } catch (cause) { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - } + try { + encoded = resourceReq.cn.charset.encode(serialized); + } catch (cause) { + res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': resourceReq.backend.cn.language.name, + }); + res.end(); + return; + } - headers['Content-Type'] = [ - req.backend.cn.mediaType.name, - `charset=${req.backend.cn.charset.name}` - ].join('; '); + headers['Content-Type'] = [ + resourceReq.cn.mediaType.name, + `charset=${resourceReq.cn.charset.name}`, + ].join('; '); + } - const statusMessageKey = finalErr.response.statusMessage ? req.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource!.state.itemName) ?? ''; - res.writeHead(finalErr.response.statusCode, headers); + const statusMessageKey = middlewareState.statusMessage ? resourceReq.cn.language.statusMessages[middlewareState.statusMessage] : undefined; + res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(middlewareState.statusCode, headers); if (typeof encoded !== 'undefined') { res.end(encoded); return; @@ -455,25 +473,27 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr res.end(); return; } - } - if (middlewares.length > 0) { - res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, req.resource!.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - Allow: middlewares.map((m) => m[0]).join(', '), - 'Content-Language': req.backend.cn.language.name, + if (middlewares.length > 0) { + res.statusMessage = resourceReq.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + Allow: middlewares.map((m) => m.method).join(', '), + 'Content-Language': resourceReq.backend.cn.language.name, + }); + res.end(); + return; + } + res.statusMessage = resourceReq.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { + 'Content-Language': resourceReq.backend.cn.language.name, }); res.end(); return; } - // TODO error handler in line with authentication - res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, req.resource!.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { - 'Content-Language': req.backend.cn.language.name, - }); - res.end(); - return; + throw new Error('Not implemented'); }; server.on('request', handleRequest);