diff --git a/.changeset/quiet-squids-run.md b/.changeset/quiet-squids-run.md new file mode 100644 index 00000000000..33bb9067ea5 --- /dev/null +++ b/.changeset/quiet-squids-run.md @@ -0,0 +1,8 @@ +--- +'@builder.io/qwik-city': minor +--- + +fix: server$ functions now correctly throw 4xx errors on the client +fix: server$ errors can be caught by @plugin middleware +refactor: Error types are standardised across server$ functions and routeLoaders +feat: 499 is now a valid status code diff --git a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/api.json b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/api.json index a33da95ec2b..f1900fb37ba 100644 --- a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/api.json +++ b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/api.json @@ -327,7 +327,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface RequestEventCommon extends RequestEventBase \n```\n**Extends:** [RequestEventBase](#requesteventbase)<PLATFORM>\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[error](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: ErrorCodes, message: string) => ErrorResponse\n\n\n\n\nWhen called, the response will immediately end with the given status code. This could be useful to end a response with `404`, and use the 404 handler in the routes directory. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used.\n\n\n
\n\n[exit](#)\n\n\n\n\n`readonly`\n\n\n\n\n() => [AbortMessage](#abortmessage)\n\n\n\n\n\n
\n\n[html](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, html: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an HTML body response. The response will be automatically set the `Content-Type` header to`text/html; charset=utf-8`. An `html()` response can only be called once.\n\n\n
\n\n[json](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, data: any) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to JSON stringify the data and send it in the response. The response will be automatically set the `Content-Type` header to `application/json; charset=utf-8`. A `json()` response can only be called once.\n\n\n
\n\n[locale](#)\n\n\n\n\n`readonly`\n\n\n\n\n(local?: string) => string\n\n\n\n\nWhich locale the content is in.\n\nThe locale value can be retrieved from selected methods using `getLocale()`:\n\n\n
\n\n[redirect](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: RedirectCode, url: string) => [RedirectMessage](#redirectmessage)\n\n\n\n\nURL to redirect to. When called, the response will immediately end with the correct redirect status and headers.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections\n\n\n
\n\n[send](#)\n\n\n\n\n`readonly`\n\n\n\n\nSendMethod\n\n\n\n\nSend a body response. The `Content-Type` response header is not automatically set when using `send()` and must be set manually. A `send()` response can only be called once.\n\n\n
\n\n[status](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode?: StatusCodes) => number\n\n\n\n\nHTTP response status code. Sets the status code when called with an argument. Always returns the status code, so calling `status()` without an argument will can be used to return the current status code.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Status\n\n\n
\n\n[text](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, text: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an text body response. The response will be automatically set the `Content-Type` header to`text/plain; charset=utf-8`. An `text()` response can only be called once.\n\n\n
", + "content": "```typescript\nexport interface RequestEventCommon extends RequestEventBase \n```\n**Extends:** [RequestEventBase](#requesteventbase)<PLATFORM>\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[error](#)\n\n\n\n\n`readonly`\n\n\n\n\n<T = any>(statusCode: ErrorCodes, message: T) => [ServerError](#servererror)<T>\n\n\n\n\nWhen called, the response will immediately end with the given status code. This could be useful to end a response with `404`, and use the 404 handler in the routes directory. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used.\n\n\n
\n\n[exit](#)\n\n\n\n\n`readonly`\n\n\n\n\n() => [AbortMessage](#abortmessage)\n\n\n\n\n\n
\n\n[html](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, html: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an HTML body response. The response will be automatically set the `Content-Type` header to`text/html; charset=utf-8`. An `html()` response can only be called once.\n\n\n
\n\n[json](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, data: any) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to JSON stringify the data and send it in the response. The response will be automatically set the `Content-Type` header to `application/json; charset=utf-8`. A `json()` response can only be called once.\n\n\n
\n\n[locale](#)\n\n\n\n\n`readonly`\n\n\n\n\n(local?: string) => string\n\n\n\n\nWhich locale the content is in.\n\nThe locale value can be retrieved from selected methods using `getLocale()`:\n\n\n
\n\n[redirect](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: RedirectCode, url: string) => [RedirectMessage](#redirectmessage)\n\n\n\n\nURL to redirect to. When called, the response will immediately end with the correct redirect status and headers.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections\n\n\n
\n\n[send](#)\n\n\n\n\n`readonly`\n\n\n\n\nSendMethod\n\n\n\n\nSend a body response. The `Content-Type` response header is not automatically set when using `send()` and must be set manually. A `send()` response can only be called once.\n\n\n
\n\n[status](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode?: StatusCodes) => number\n\n\n\n\nHTTP response status code. Sets the status code when called with an argument. Always returns the status code, so calling `status()` without an argument will can be used to return the current status code.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Status\n\n\n
\n\n[text](#)\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, text: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an text body response. The response will be automatically set the `Content-Type` header to`text/plain; charset=utf-8`. An `text()` response can only be called once.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/types.ts", "mdFile": "qwik-city.requesteventcommon.md" }, @@ -411,7 +411,7 @@ } ], "kind": "Class", - "content": "```typescript\nexport declare class ServerError> extends Error \n```\n**Extends:** Error\n\n\n\n\n
\n\nConstructor\n\n\n\n\nModifiers\n\n\n\n\nDescription\n\n\n
\n\n[(constructor)(status, data)](#)\n\n\n\n\n\n\n\nConstructs a new instance of the `ServerError` class\n\n\n
\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[data](#servererror-data)\n\n\n\n\n\n\n\nT\n\n\n\n\n\n
\n\n[status](#servererror-status)\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
", + "content": "```typescript\nexport declare class ServerError extends Error \n```\n**Extends:** Error\n\n\n\n\n
\n\nConstructor\n\n\n\n\nModifiers\n\n\n\n\nDescription\n\n\n
\n\n[(constructor)(status, data)](#)\n\n\n\n\n\n\n\nConstructs a new instance of the `ServerError` class\n\n\n
\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[data](#servererror-data)\n\n\n\n\n\n\n\nT\n\n\n\n\n\n
\n\n[status](#servererror-status)\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/error-handler.ts", "mdFile": "qwik-city.servererror.md" }, diff --git a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md index f5301225b60..acf25004244 100644 --- a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md +++ b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md @@ -1216,7 +1216,7 @@ Description -(statusCode: ErrorCodes, message: string) => ErrorResponse +<T = any>(statusCode: ErrorCodes, message: T) => [ServerError](#servererror)<T> @@ -1465,7 +1465,7 @@ export interface ResolveValue ## ServerError ```typescript -export declare class ServerError> extends Error +export declare class ServerError extends Error ``` **Extends:** Error diff --git a/packages/docs/src/routes/docs/(qwikcity)/error-handling/index.mdx b/packages/docs/src/routes/docs/(qwikcity)/error-handling/index.mdx new file mode 100644 index 00000000000..97f9db21a8f --- /dev/null +++ b/packages/docs/src/routes/docs/(qwikcity)/error-handling/index.mdx @@ -0,0 +1,112 @@ +--- +title: Error handling | Qwik City +contributors: + - DustinJSilk +updated_at: '2025-01-11T18:00:00Z' +created_at: '2025-01-11T18:00:00Z' +--- + +# Error handling + +When an error is thrown in a loader or `server$` function, a 500 error is returned to the client along with the error. This is useful during development but isn't always desirable for production systems. Qwik provides the tools necessary to customise how errors are handled. + +Throwing a `ServerError` instance allows you to return custom errors to the browser with a different status code and serialised data. + +> Loaders also provide a helper function on the event object to easily create new ServerErrors. + +```tsx +// Throw ServerErrors from a routerLoader$ +const useProduct = routeLoader$(async (ev) => { + const product = await fetch('api/product/1') + + if (!product) { + // Throw a 404 with a custom payload + throw new ServerError(404, 'Product not found') + + // Or use the existing helper function + throw ev.error(404, 'Product not found') + } + + return product +}) + +// Throw ServerErrors from a server$ +const getPrices = server$(() => { + if (!isAuthenticated()) { + throw new ServerError(401, { code: 401 }) + } + + return fetch('api/product/1/prices') +}) + +export default component$(() => { + const product = useProduct() + + useVisibleTask(() => { + getPrices() + .then() + .catch(err => { + // The payload from a ServerError is deserialised as the error caught in the client + if (err.code === 401) { + // Navigate to login page + } + + // Show generic error + }) + }) + + return
Product page
+}) +``` + +## Error interceptor + +Intercepting errors with middleware has a few usecases: you might want to hide error details in production systems, add structured error logging, or map the error status codes from RPC API calls to HTTP status codes. This is all achieveable with middleware in a `plugin` file. + +```tsx +// src/routes/plugin@errors.ts +import { type RequestHandler } from '@builder.io/qwik-city' +import { RedirectMessage } from '@builder.io/qwik-city/middleware/request-handler' +import { isDev } from '@builder.io/qwik/build' + +export const onRequest: RequestHandler = async ({ next }) => { + try { + return await next(); + } catch (err) { + // Pass through 3xx redirects + if (isRedirectMessage(err)) { + throw err + } + + // Pass through ServerErrors + if (isServerError(err)) { + throw err + } + + // Log unknown errors + console.error('unknown error', err) + + if (isDev) { + throw err + } else { + throw new ServerError(500, 'Internal server error'); + } + } +}; + +function isServerError(err: unknown): err is ServerError { + return ( + err instanceof ServerError || + // This is required for dev environments due to an issue with vite. + (isDev && err instanceof Error && err.constructor.name === "ServerError") + ); +} + +function isRedirectMessage(err: unknown): err is RedirectMessage { + return ( + err instanceof RedirectMessage || + // This is required for dev environments due to an issue with vite. + (isDev && err instanceof Error && err.constructor.name === "RedirectMessage") + ); +} +``` diff --git a/packages/docs/src/routes/docs/menu.md b/packages/docs/src/routes/docs/menu.md index 98b58646ff3..264e3abe745 100644 --- a/packages/docs/src/routes/docs/menu.md +++ b/packages/docs/src/routes/docs/menu.md @@ -31,6 +31,7 @@ - [Endpoints]() - [Middleware]() - [server$]() +- [Error handling]() - [Re-exporting loaders](/docs/(qwikcity)/re-exporting-loaders/index.mdx) - [Caching]() - [HTML attributes]() diff --git a/packages/qwik-city/src/middleware/request-handler/api.md b/packages/qwik-city/src/middleware/request-handler/api.md index 91fb8b63b50..31f770acd26 100644 --- a/packages/qwik-city/src/middleware/request-handler/api.md +++ b/packages/qwik-city/src/middleware/request-handler/api.md @@ -126,8 +126,7 @@ export interface RequestEventBase { // @public (undocumented) export interface RequestEventCommon extends RequestEventBase { // Warning: (ae-forgotten-export) The symbol "ErrorCodes" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "ErrorResponse" needs to be exported by the entry point index.d.ts - readonly error: (statusCode: ErrorCodes, message: string) => ErrorResponse; + readonly error: (statusCode: ErrorCodes, message: T) => ServerError; // (undocumented) readonly exit: () => AbortMessage; readonly html: (statusCode: StatusCodes, html: string) => AbortMessage; @@ -176,7 +175,7 @@ export interface ResolveValue { } // @public (undocumented) -export class ServerError> extends Error { +export class ServerError extends Error { constructor(status: number, data: T); // (undocumented) data: T; diff --git a/packages/qwik-city/src/middleware/request-handler/error-handler.ts b/packages/qwik-city/src/middleware/request-handler/error-handler.ts index a8c8bace6ea..5c748ad26a6 100644 --- a/packages/qwik-city/src/middleware/request-handler/error-handler.ts +++ b/packages/qwik-city/src/middleware/request-handler/error-handler.ts @@ -1,5 +1,5 @@ /** @public */ -export class ServerError> extends Error { +export class ServerError extends Error { constructor( public status: number, public data: T @@ -8,6 +8,7 @@ export class ServerError> extends Error { } } +/** @deprecated */ export class ErrorResponse extends Error { constructor( public status: number, diff --git a/packages/qwik-city/src/middleware/request-handler/request-event.ts b/packages/qwik-city/src/middleware/request-handler/request-event.ts index 2def677b08a..a4a3eb63780 100644 --- a/packages/qwik-city/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-city/src/middleware/request-handler/request-event.ts @@ -18,7 +18,6 @@ import type { FailReturn, } from '../../runtime/src/types'; import { Cookie } from './cookie'; -import { ErrorResponse } from './error-handler'; import { AbortMessage, RedirectMessage } from './redirect-handler'; import { encoder } from './resolve-request-handlers'; import { createCacheControl } from './cache-control'; @@ -27,6 +26,7 @@ import type { QwikManifest, ResolvedManifest } from '@builder.io/qwik/optimizer' import { IsQData, QDATA_JSON, QDATA_JSON_LEN } from './user-response'; import { isPromise } from './../../runtime/src/utils'; import { QDATA_KEY } from '../../runtime/src/constants'; +import { ServerError } from './error-handler'; const RequestEvLoaders = Symbol('RequestEvLoaders'); const RequestEvMode = Symbol('RequestEvMode'); @@ -192,9 +192,9 @@ export function createRequestEvent( return locale || ''; }, - error: (statusCode: number, message: string) => { + error: (statusCode: number, message: T) => { status = statusCode; - return new ErrorResponse(statusCode, message); + return new ServerError(statusCode, message); }, redirect: (statusCode: number, url: string) => { diff --git a/packages/qwik-city/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-city/src/middleware/request-handler/resolve-request-handlers.ts index 1ba0ef5b3f3..e7c3d8f64b0 100644 --- a/packages/qwik-city/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-city/src/middleware/request-handler/resolve-request-handlers.ts @@ -12,7 +12,6 @@ import type { RouteModule, ValidatorReturn, } from '../../runtime/src/types'; -import { ServerError } from './error-handler'; import { HttpStatus } from './http-status-codes'; import { RedirectMessage } from './redirect-handler'; import { @@ -305,23 +304,12 @@ async function pureServerFunction(ev: RequestEvent) { const [qrl, ...args] = data; if (isQrl(qrl) && qrl.getHash() === fn) { let result: unknown; - try { - if (isDev) { - result = await measure(ev, `server_${qrl.getSymbol()}`, () => - (qrl as Function).apply(ev, args) - ); - } else { - result = await (qrl as Function).apply(ev, args); - } - } catch (err) { - if (err instanceof ServerError) { - ev.headers.set('Content-Type', 'application/qwik-json'); - ev.send(err.status, await qwikSerializer._serializeData(err.data, true)); - return; - } - ev.headers.set('Content-Type', 'application/qwik-json'); - ev.send(500, await qwikSerializer._serializeData(err, true)); - return; + if (isDev) { + result = await measure(ev, `server_${qrl.getSymbol()}`, () => + (qrl as Function).apply(ev, args) + ); + } else { + result = await (qrl as Function).apply(ev, args); } if (isAsyncIterator(result)) { ev.headers.set('Content-Type', 'text/qwik-json-stream'); diff --git a/packages/qwik-city/src/middleware/request-handler/types.ts b/packages/qwik-city/src/middleware/request-handler/types.ts index e8faa4894c5..ae183b8c721 100644 --- a/packages/qwik-city/src/middleware/request-handler/types.ts +++ b/packages/qwik-city/src/middleware/request-handler/types.ts @@ -1,9 +1,9 @@ import type { Render, RenderOptions } from '@builder.io/qwik/server'; import type { QwikCityPlan, FailReturn, Action, Loader } from '@builder.io/qwik-city'; -import type { ErrorResponse } from './error-handler'; import type { AbortMessage, RedirectMessage } from './redirect-handler'; import type { RequestEventInternal } from './request-event'; import type { _deserializeData, _serializeData, _verifySerializable } from '@builder.io/qwik'; +import type { ServerError } from './error-handler'; /** @public */ export interface EnvGetter { @@ -154,7 +154,8 @@ export type ClientErrorCode = | 428 // Precondition Required | 429 // Too Many Requests | 431 // Request Header Fields Too Large - | 451; // Unavailable For Legal Reasons + | 451 // Unavailable For Legal Reasons + | 499; // Client closed request /** * HTTP Server Error Status Codes Status codes in the 5xx range indicate that the server encountered @@ -205,7 +206,7 @@ export interface RequestEventCommon * to end a response with `404`, and use the 404 handler in the routes directory. See * https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used. */ - readonly error: (statusCode: ErrorCodes, message: string) => ErrorResponse; + readonly error: (statusCode: ErrorCodes, message: T) => ServerError; /** * Convenience method to send an text body response. The response will be automatically set the diff --git a/packages/qwik-city/src/middleware/request-handler/user-response.ts b/packages/qwik-city/src/middleware/request-handler/user-response.ts index 522286950c3..ce6f5b3eecf 100644 --- a/packages/qwik-city/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-city/src/middleware/request-handler/user-response.ts @@ -1,7 +1,12 @@ import type { QwikSerializer, ServerRequestEvent, StatusCodes } from './types'; import type { RequestEvent, RequestHandler } from '@builder.io/qwik-city'; -import { createRequestEvent, getRequestMode, type RequestEventInternal } from './request-event'; -import { ErrorResponse, getErrorHtml, minimalHtmlResponse } from './error-handler'; +import { + RequestEvQwikSerializer, + createRequestEvent, + getRequestMode, + type RequestEventInternal, +} from './request-event'; +import { ServerError, getErrorHtml, minimalHtmlResponse } from './error-handler'; import { AbortMessage, RedirectMessage } from './redirect-handler'; import type { LoadedRoute } from '../../runtime/src/types'; import { encoder } from './resolve-request-handlers'; @@ -65,12 +70,18 @@ async function runNext(requestEv: RequestEventInternal, resolve: (value: any) => if (e instanceof RedirectMessage) { const stream = requestEv.getWritableStream(); await stream.close(); - } else if (e instanceof ErrorResponse) { - console.error(e); + } else if (e instanceof ServerError) { if (!requestEv.headersSent) { - const html = getErrorHtml(e.status, e); const status = e.status as StatusCodes; - requestEv.html(status, html); + const accept = requestEv.request.headers.get('Accept'); + if (accept && !accept.includes('text/html')) { + const qwikSerializer = requestEv[RequestEvQwikSerializer]; + requestEv.headers.set('Content-Type', 'application/qwik-json'); + requestEv.send(status, await qwikSerializer._serializeData(e.data, true)); + } else { + const html = getErrorHtml(e.status, e.data); + requestEv.html(status, html); + } } } else if (!(e instanceof AbortMessage)) { if (getRequestMode(requestEv) !== 'dev') { diff --git a/packages/qwik-city/src/runtime/src/server-functions.ts b/packages/qwik-city/src/runtime/src/server-functions.ts index 6a40fcca57d..193362a61ab 100644 --- a/packages/qwik-city/src/runtime/src/server-functions.ts +++ b/packages/qwik-city/src/runtime/src/server-functions.ts @@ -445,6 +445,7 @@ export const serverQrl = ( headers: { ...headers, 'Content-Type': 'application/qwik-json', + Accept: 'application/json, application/qwik-json, text/qwik-json-stream, text/plain', // Required so we don't call accidentally 'X-QRL': qrlHash, }, @@ -479,19 +480,19 @@ export const serverQrl = ( } else if (contentType === 'application/qwik-json') { const str = await res.text(); const obj = await _deserializeData(str, ctxElm ?? document.documentElement); - if (res.status >= 500) { + if (res.status >= 400) { throw obj; } return obj; } else if (contentType === 'application/json') { const obj = await res.json(); - if (res.status >= 500) { + if (res.status >= 400) { throw obj; } return obj; } else if (contentType === 'text/plain' || contentType === 'text/html') { const str = await res.text(); - if (res.status >= 500) { + if (res.status >= 400) { throw str; } return str; diff --git a/starters/apps/qwikcity-test/src/routes/(common)/loaders/loader-error/index.tsx b/starters/apps/qwikcity-test/src/routes/(common)/loaders/loader-error/index.tsx new file mode 100644 index 00000000000..cd75fd3a01c --- /dev/null +++ b/starters/apps/qwikcity-test/src/routes/(common)/loaders/loader-error/index.tsx @@ -0,0 +1,11 @@ +import { component$ } from "@builder.io/qwik"; +import { routeLoader$ } from "@builder.io/qwik-city"; + +const useError = routeLoader$(async function ({ error }): Promise { + throw error(401, "loader-error-uncaught"); +}); + +export default component$(() => { + useError(); + return <>; +}); diff --git a/starters/apps/qwikcity-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx b/starters/apps/qwikcity-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx new file mode 100644 index 00000000000..a88916c6a8d --- /dev/null +++ b/starters/apps/qwikcity-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx @@ -0,0 +1,16 @@ +import { component$ } from "@builder.io/qwik"; +import { routeLoader$, server$ } from "@builder.io/qwik-city"; +import { ServerError } from "@builder.io/qwik-city/middleware/request-handler"; + +export const serverError = server$(() => { + throw new ServerError(401, "server-error-data"); +}); + +const useCatchServerErrorInLoader = routeLoader$(async () => { + await serverError(); +}); + +export default component$(() => { + useCatchServerErrorInLoader(); + return <>; +}); diff --git a/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/index.tsx b/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/index.tsx index 9b20ebee7fa..dd941a4f308 100644 --- a/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/index.tsx +++ b/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/index.tsx @@ -3,34 +3,47 @@ import { server$ } from "@builder.io/qwik-city"; import { ServerError } from "@builder.io/qwik-city/middleware/request-handler"; import { delay } from "../../actions/login"; -type ResponseTuple = [null | string, string]; +type ErrorReason = { + reason: string; + middleware: string; +}; -const serverFunctionA = server$(async function a(): Promise { - throw new ServerError<[string]>(401, ["my error"]); +const serverFunctionA = server$(async function a(): Promise { + throw new ServerError(401, { + reason: "my error", + middleware: "server-error-uncaught", + }); }); -const serverFunctionB = server$(async function b(): Promise { - return [null, this.method || ""]; +const serverFunctionB = server$(async function b(): Promise { + return this.method; }); export const MultipleServerFunctionsInvokedInTask = component$(() => { - const methodA = useSignal(""); + const errorReason = useSignal(""); + const errorMiddleware = useSignal(""); const methodB = useSignal(""); useVisibleTask$(async () => { - const [error /*, data */] = await serverFunctionA(); - if (error) { - methodA.value = error; + try { + await serverFunctionA(); + } catch (err: any) { + if (isErrorReason(err)) { + errorReason.value = err.reason; + errorMiddleware.value = err.middleware; + } } + await delay(1); - // err, method - const [, method] = await serverFunctionB(); + + const method = await serverFunctionB(); methodB.value = method; }); return (
- {methodA.value} + {errorReason.value} + {errorMiddleware.value} {methodB.value}
); @@ -43,3 +56,11 @@ export default component$(() => { ); }); + +export function isErrorReason(err: any): err is ErrorReason { + if (typeof err.reason === "string" && typeof err.middleware === "string") { + return true; + } + + return false; +} diff --git a/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/loader/index.tsx b/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/loader/index.tsx new file mode 100644 index 00000000000..dfbd603d854 --- /dev/null +++ b/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/loader/index.tsx @@ -0,0 +1,24 @@ +import { component$ } from "@builder.io/qwik"; +import { routeLoader$, server$ } from "@builder.io/qwik-city"; +import { ServerError } from "@builder.io/qwik-city/middleware/request-handler"; + +export const serverError = server$(() => { + throw new ServerError(401, "loader-error-data"); +}); + +const useCatchServerErrorInLoader = routeLoader$(async () => { + try { + await serverError(); + } catch (err: any) { + if (err instanceof ServerError && typeof err.data === "string") { + return err.data; + } + } + + return "unknown error"; +}); + +export default component$(() => { + const error = useCatchServerErrorInLoader(); + return
{error.value}
; +}); diff --git a/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/primitive/index.tsx b/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/primitive/index.tsx new file mode 100644 index 00000000000..24eef47ca28 --- /dev/null +++ b/starters/apps/qwikcity-test/src/routes/(common)/server-func/server-error/primitive/index.tsx @@ -0,0 +1,37 @@ +import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik"; +import { server$ } from "@builder.io/qwik-city"; +import { ServerError } from "@builder.io/qwik-city/middleware/request-handler"; + +const serverFunctionA = server$(async () => { + throw new ServerError(401, 1); +}); + +const serverFunctionB = server$(async () => { + throw new ServerError(500, "error"); +}); + +export default component$(() => { + const errorA = useSignal(); + const errorB = useSignal(); + + useVisibleTask$(async () => { + try { + await serverFunctionA(); + } catch (err: any) { + errorA.value = err; + } + + try { + await serverFunctionB(); + } catch (err: any) { + errorB.value = err; + } + }); + + return ( +
+ {errorA.value} + {errorB.value} +
+ ); +}); diff --git a/starters/apps/qwikcity-test/src/routes/plugin@errors.tsx b/starters/apps/qwikcity-test/src/routes/plugin@errors.tsx new file mode 100644 index 00000000000..f5c2e5c5706 --- /dev/null +++ b/starters/apps/qwikcity-test/src/routes/plugin@errors.tsx @@ -0,0 +1,32 @@ +import { type RequestHandler } from "@builder.io/qwik-city"; +import { ServerError } from "@builder.io/qwik-city/middleware/request-handler"; +import { isDev } from "@builder.io/qwik/build"; +import { isErrorReason } from "./(common)/server-func/server-error"; + +export const onRequest: RequestHandler = async ({ next, error }) => { + try { + return await next(); + } catch (err) { + // Intercept and update ServerErrors to test middleware + if (isServerError(err)) { + // Update for (common)/server-func/server-error + if (isErrorReason(err.data)) { + err.data.middleware = "server-error-caught"; + } + + // Update for (common)/loaders/loader-error + if (err.data === "loader-error-uncaught") { + err.data = "loader-error-caught"; + } + } + + throw err; + } +}; + +function isServerError(err: unknown): err is ServerError { + return ( + err instanceof ServerError || + (isDev && err instanceof Error && err.constructor.name === "ServerError") + ); +} diff --git a/starters/e2e/qwikcity/loaders.spec.ts b/starters/e2e/qwikcity/loaders.spec.ts index 32d997ee1b2..5da2b0d0ecd 100644 --- a/starters/e2e/qwikcity/loaders.spec.ts +++ b/starters/e2e/qwikcity/loaders.spec.ts @@ -119,5 +119,29 @@ test.describe("loaders", () => { ]); } }); + test("should modify ServerError in middleware", async ({ page }) => { + const response = await page.goto("/qwikcity-test/loaders/loader-error"); + const contentType = await response?.headerValue("Content-Type"); + const status = response?.status(); + + expect(status).toEqual(401); + expect(contentType).toEqual("text/html; charset=utf-8"); + const body = page.locator("body"); + await expect(body).toContainText("loader-error-caught"); + }); + test("should return html with uncaught ServerErrors thrown in loaders", async ({ + page, + }) => { + const response = await page.goto( + "/qwikcity-test/loaders/loader-error/uncaught-server", + ); + const contentType = await response?.headerValue("Content-Type"); + const status = response?.status(); + + expect(status).toEqual(401); + expect(contentType).toEqual("text/html; charset=utf-8"); + const body = page.locator("body"); + await expect(body).toContainText("server-error-data"); + }); } }); diff --git a/starters/e2e/qwikcity/server.spec.ts b/starters/e2e/qwikcity/server.spec.ts index cfe3878d67c..88676896e4f 100644 --- a/starters/e2e/qwikcity/server.spec.ts +++ b/starters/e2e/qwikcity/server.spec.ts @@ -112,11 +112,23 @@ test.describe("server$", () => { "POST--MyCustomValue-GET--MyCustomValue", ); }); - test("should allow for ServerError", async ({ page }) => { + test("should modify ServerError in middleware", async ({ page }) => { await page.goto("/qwikcity-test/server-func/server-error"); const serverConfigContainer = page.locator("#server-error"); - await expect(serverConfigContainer).toContainText("my errorPOST"); + await expect(serverConfigContainer).toContainText( + "my errorserver-error-caughtPOST", + ); + }); + test("should catch ServerError in routeLoader", async ({ page }) => { + await page.goto("/qwikcity-test/server-func/server-error/loader"); + const serverConfigContainer = page.locator("#server-error"); + await expect(serverConfigContainer).toContainText("loader-error-data"); + }); + test("should allow primitive ServerError data", async ({ page }) => { + await page.goto("/qwikcity-test/server-func/server-error/primitive"); + const serverConfigContainer = page.locator("#server-error"); + await expect(serverConfigContainer).toContainText("1error"); }); }); });