diff --git a/.changeset/dirty-dolls-heal.md b/.changeset/dirty-dolls-heal.md new file mode 100644 index 00000000000..dd5e6a307ba --- /dev/null +++ b/.changeset/dirty-dolls-heal.md @@ -0,0 +1,19 @@ +--- +'@builder.io/qwik-city': minor +--- + +FEAT: Added rewrite() to the RequestEvent object. It works like redirect but does not change the URL, + think of it as an internal redirect. + +Example usage: +```ts +export const onRequest: RequestHandler = async ({ url, rewrite }) => { + if (url.pathname.includes("/articles/the-best-article-in-the-world")) { + const artistId = db.getArticleByName("the-best-article-in-the-world"); + + // Url will remain /articles/the-best-article-in-the-world, but under the hood, + // will render /articles/${artistId} + throw rewrite(`/articles/${artistId}`); + } +}; +``` \ No newline at end of file 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 f1900fb37ba..4fd4315b9fe 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 @@ -261,6 +261,23 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/cookie.ts", "mdFile": "qwik-city.mergeheaderscookies.md" }, + { + "name": "pathname", + "id": "rewritemessage-pathname", + "hierarchy": [ + { + "name": "RewriteMessage", + "id": "rewritemessage-pathname" + }, + { + "name": "pathname", + "id": "rewritemessage-pathname" + } + ], + "kind": "Property", + "content": "```typescript\nreadonly pathname: string;\n```", + "mdFile": "qwik-city.rewritemessage.pathname.md" + }, { "name": "RedirectMessage", "id": "redirectmessage", @@ -313,7 +330,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface RequestEventBase \n```\n\n\n\n\n\n\n\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[basePathname](#)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\nThe base pathname of the request, which can be configured at build time. Defaults to `/`.\n\n\n
\n\n[cacheControl](#)\n\n\n\n\n`readonly`\n\n\n\n\n(cacheControl: [CacheControl](#cachecontrol), target?: CacheControlTarget) => void\n\n\n\n\nConvenience method to set the Cache-Control header. Depending on your CDN, you may want to add another cacheControl with the second argument set to `CDN-Cache-Control` or any other value (we provide the most common values for auto-complete, but you can use any string you want).\n\nSee https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control and https://qwik.dev/docs/caching/\\#CDN-Cache-Controls for more information.\n\n\n
\n\n[clientConn](#)\n\n\n\n\n`readonly`\n\n\n\n\n[ClientConn](#clientconn)\n\n\n\n\nProvides information about the client connection, such as the IP address and the country the request originated from.\n\n\n
\n\n[cookie](#)\n\n\n\n\n`readonly`\n\n\n\n\n[Cookie](#cookie)\n\n\n\n\nHTTP request and response cookie. Use the `get()` method to retrieve a request cookie value. Use the `set()` method to set a response cookie value.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies\n\n\n
\n\n[env](#)\n\n\n\n\n`readonly`\n\n\n\n\n[EnvGetter](#envgetter)\n\n\n\n\nPlatform provided environment variables.\n\n\n
\n\n[headers](#)\n\n\n\n\n`readonly`\n\n\n\n\nHeaders\n\n\n\n\nHTTP response headers. Notice it will be empty until you first add a header. If you want to read the request headers, use `request.headers` instead.\n\nhttps://developer.mozilla.org/en-US/docs/Glossary/Response\\_header\n\n\n
\n\n[method](#)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\nHTTP request method.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Methods\n\n\n
\n\n[params](#)\n\n\n\n\n`readonly`\n\n\n\n\nReadonly<Record<string, string>>\n\n\n\n\nURL path params which have been parsed from the current url pathname segments. Use `query` to instead retrieve the query string search params.\n\n\n
\n\n[parseBody](#)\n\n\n\n\n`readonly`\n\n\n\n\n() => Promise<unknown>\n\n\n\n\nThis method will check the request headers for a `Content-Type` header and parse the body accordingly. It supports `application/json`, `application/x-www-form-urlencoded`, and `multipart/form-data` content types.\n\nIf the `Content-Type` header is not set, it will return `null`.\n\n\n
\n\n[pathname](#)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\nURL pathname. Does not include the protocol, domain, query string (search params) or hash.\n\nhttps://developer.mozilla.org/en-US/docs/Web/API/URL/pathname\n\n\n
\n\n[platform](#)\n\n\n\n\n`readonly`\n\n\n\n\nPLATFORM\n\n\n\n\nPlatform specific data and functions\n\n\n
\n\n[query](#)\n\n\n\n\n`readonly`\n\n\n\n\nURLSearchParams\n\n\n\n\nURL Query Strings (URL Search Params). Use `params` to instead retrieve the route params found in the url pathname.\n\nhttps://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams\n\n\n
\n\n[request](#)\n\n\n\n\n`readonly`\n\n\n\n\nRequest\n\n\n\n\nHTTP request information.\n\n\n
\n\n[sharedMap](#)\n\n\n\n\n`readonly`\n\n\n\n\nMap<string, any>\n\n\n\n\nShared Map across all the request handlers. Every HTTP request will get a new instance of the shared map. The shared map is useful for sharing data between request handlers.\n\n\n
\n\n[signal](#)\n\n\n\n\n`readonly`\n\n\n\n\nAbortSignal\n\n\n\n\nRequest's AbortSignal (same as `request.signal`). This signal indicates that the request has been aborted.\n\n\n
\n\n[url](#)\n\n\n\n\n`readonly`\n\n\n\n\nURL\n\n\n\n\nHTTP request URL.\n\n\n
", + "content": "```typescript\nexport interface RequestEventBase \n```\n\n\n\n\n\n\n\n\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[basePathname](#)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\nThe base pathname of the request, which can be configured at build time. Defaults to `/`.\n\n\n
\n\n[cacheControl](#)\n\n\n\n\n`readonly`\n\n\n\n\n(cacheControl: [CacheControl](#cachecontrol), target?: CacheControlTarget) => void\n\n\n\n\nConvenience method to set the Cache-Control header. Depending on your CDN, you may want to add another cacheControl with the second argument set to `CDN-Cache-Control` or any other value (we provide the most common values for auto-complete, but you can use any string you want).\n\nSee https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control and https://qwik.dev/docs/caching/\\#CDN-Cache-Controls for more information.\n\n\n
\n\n[clientConn](#)\n\n\n\n\n`readonly`\n\n\n\n\n[ClientConn](#clientconn)\n\n\n\n\nProvides information about the client connection, such as the IP address and the country the request originated from.\n\n\n
\n\n[cookie](#)\n\n\n\n\n`readonly`\n\n\n\n\n[Cookie](#cookie)\n\n\n\n\nHTTP request and response cookie. Use the `get()` method to retrieve a request cookie value. Use the `set()` method to set a response cookie value.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies\n\n\n
\n\n[env](#)\n\n\n\n\n`readonly`\n\n\n\n\n[EnvGetter](#envgetter)\n\n\n\n\nPlatform provided environment variables.\n\n\n
\n\n[headers](#)\n\n\n\n\n`readonly`\n\n\n\n\nHeaders\n\n\n\n\nHTTP response headers. Notice it will be empty until you first add a header. If you want to read the request headers, use `request.headers` instead.\n\nhttps://developer.mozilla.org/en-US/docs/Glossary/Response\\_header\n\n\n
\n\n[method](#)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\nHTTP request method.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Methods\n\n\n
\n\n[originalUrl](#)\n\n\n\n\n`readonly`\n\n\n\n\nURL\n\n\n\n\nThe original HTTP request URL.\n\nThis property was introduced to support the rewrite feature.\n\nIf rewrite is called, the url property will be changed to the rewritten url. while originalUrl will stay the same(e.g the url inserted to the address bar).\n\nIf rewrite is never called as part of the request, the url property and the originalUrl are equal.\n\n\n
\n\n[params](#)\n\n\n\n\n`readonly`\n\n\n\n\nReadonly<Record<string, string>>\n\n\n\n\nURL path params which have been parsed from the current url pathname segments. Use `query` to instead retrieve the query string search params.\n\n\n
\n\n[parseBody](#)\n\n\n\n\n`readonly`\n\n\n\n\n() => Promise<unknown>\n\n\n\n\nThis method will check the request headers for a `Content-Type` header and parse the body accordingly. It supports `application/json`, `application/x-www-form-urlencoded`, and `multipart/form-data` content types.\n\nIf the `Content-Type` header is not set, it will return `null`.\n\n\n
\n\n[pathname](#)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\nURL pathname. Does not include the protocol, domain, query string (search params) or hash.\n\nhttps://developer.mozilla.org/en-US/docs/Web/API/URL/pathname\n\n\n
\n\n[platform](#)\n\n\n\n\n`readonly`\n\n\n\n\nPLATFORM\n\n\n\n\nPlatform specific data and functions\n\n\n
\n\n[query](#)\n\n\n\n\n`readonly`\n\n\n\n\nURLSearchParams\n\n\n\n\nURL Query Strings (URL Search Params). Use `params` to instead retrieve the route params found in the url pathname.\n\nhttps://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams\n\n\n
\n\n[request](#)\n\n\n\n\n`readonly`\n\n\n\n\nRequest\n\n\n\n\nHTTP request information.\n\n\n
\n\n[sharedMap](#)\n\n\n\n\n`readonly`\n\n\n\n\nMap<string, any>\n\n\n\n\nShared Map across all the request handlers. Every HTTP request will get a new instance of the shared map. The shared map is useful for sharing data between request handlers.\n\n\n
\n\n[signal](#)\n\n\n\n\n`readonly`\n\n\n\n\nAbortSignal\n\n\n\n\nRequest's AbortSignal (same as `request.signal`). This signal indicates that the request has been aborted.\n\n\n
\n\n[url](#)\n\n\n\n\n`readonly`\n\n\n\n\nURL\n\n\n\n\nHTTP request URL.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/types.ts", "mdFile": "qwik-city.requesteventbase.md" }, @@ -327,7 +344,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<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
", + "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
\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[rewrite](#)\n\n\n\n\n`readonly`\n\n\n\n\n(pathname: string) => [RewriteMessage](#rewritemessage)\n\n\n\n\nWhen called, qwik-city will execute the path's matching route flow.\n\nThe url in the browser will remain unchanged.\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" }, @@ -401,6 +418,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/types.ts", "mdFile": "qwik-city.resolvevalue.md" }, + { + "name": "RewriteMessage", + "id": "rewritemessage", + "hierarchy": [ + { + "name": "RewriteMessage", + "id": "rewritemessage" + } + ], + "kind": "Class", + "content": "```typescript\nexport declare class RewriteMessage extends AbortMessage \n```\n**Extends:** [AbortMessage](#abortmessage)\n\n\n\n\n
\n\nConstructor\n\n\n\n\nModifiers\n\n\n\n\nDescription\n\n\n
\n\n[(constructor)(pathname)](#)\n\n\n\n\n\n\n\nConstructs a new instance of the `RewriteMessage` class\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[pathname](#rewritemessage-pathname)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/rewrite-handler.ts", + "mdFile": "qwik-city.rewritemessage.md" + }, { "name": "ServerError", "id": "servererror", diff --git a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.mdx b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.mdx index acf25004244..a5255b09275 100644 --- a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.mdx +++ b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.mdx @@ -717,6 +717,12 @@ Headers [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/cookie.ts) +## pathname + +```typescript +readonly pathname: string; +``` + ## RedirectMessage ```typescript @@ -1020,6 +1026,29 @@ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods +[originalUrl](#) + + + +`readonly` + + + +URL + + + +The original HTTP request URL. + +This property was introduced to support the rewrite feature. + +If rewrite is called, the url property will be changed to the rewritten url. while originalUrl will stay the same(e.g the url inserted to the address bar). + +If rewrite is never called as part of the request, the url property and the originalUrl are equal. + + + + [params](#) @@ -1312,6 +1341,25 @@ https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections +[rewrite](#) + + + +`readonly` + + + +(pathname: string) => [RewriteMessage](#rewritemessage) + + + +When called, qwik-city will execute the path's matching route flow. + +The url in the browser will remain unchanged. + + + + [send](#) @@ -1462,6 +1510,76 @@ export interface ResolveValue [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/types.ts) +## RewriteMessage + +```typescript +export declare class RewriteMessage extends AbortMessage +``` + +**Extends:** [AbortMessage](#abortmessage) + + + +
+ +Constructor + + + +Modifiers + + + +Description + +
+ +[(constructor)(pathname)](#) + + + + + +Constructs a new instance of the `RewriteMessage` class + +
+ + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +[pathname](#rewritemessage-pathname) + + + +`readonly` + + + +string + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/rewrite-handler.ts) + ## ServerError ```typescript diff --git a/packages/docs/src/routes/docs/(qwikcity)/guides/rewrites/index.mdx b/packages/docs/src/routes/docs/(qwikcity)/guides/rewrites/index.mdx new file mode 100644 index 00000000000..70ca044e1fb --- /dev/null +++ b/packages/docs/src/routes/docs/(qwikcity)/guides/rewrites/index.mdx @@ -0,0 +1,45 @@ +--- +title: Rewrites | Guides +description: Learn how to use rewrites in Qwik City. +contributors: + - omerman +updated_at: '2025-05-04T19:43:33Z' +created_at: '2025-05-04T23:45:13Z' +--- + +# Rewrites + +Sometimes you want to redirect a user from the current page to another page, +but you want to keep the current URL in the browser history. + +Let's say a user tries to access an article which is indexed by its name, +e.g `/articles/qwik-is-very-quick`. +but in our code, we access it by its id, on our directory structure. + +``` +src/routes/articles/ +├── [id] +├─── index.tsx +``` + + +```tsx title="src/routes/plugin@article-rewrite.tsx" +import type { RequestHandler } from "@builder.io/qwik-city"; + +export const onRequest: RequestHandler = async ({ url, rewrite }) => { + const pattern = /^\/articles\/(.*)$/; + // Detects /articles/, returns null if url does not match the pattern. + const match = url.pathname.match(pattern); + if (match) { + const articleName = match[1]; + const { id } = await db.getArticleByName(articleName); + throw rewrite(`/articles/${id}`); + } +}; +``` + +The `rewrite()` function, which was destructured in the RequestHandler function arguments, is invoked with a pathname string. + +```tsx +throw rewrite(`/articles/777/`); +``` diff --git a/packages/qwik-city/src/buildtime/vite/dev-server.ts b/packages/qwik-city/src/buildtime/vite/dev-server.ts index 310d60ede56..0d6d8fe12d9 100644 --- a/packages/qwik-city/src/buildtime/vite/dev-server.ts +++ b/packages/qwik-city/src/buildtime/vite/dev-server.ts @@ -18,6 +18,7 @@ import { matchRoute } from '../../runtime/src/route-matcher'; import { getMenuLoader } from '../../runtime/src/routing'; import type { ActionInternal, + RebuildRouteInfoInternal, ContentMenu, LoadedRoute, LoaderInternal, @@ -227,10 +228,27 @@ export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) { await server.ssrLoadModule('@qwik-serializer'); const qwikSerializer = { _deserializeData, _serializeData, _verifySerializable }; + const rebuildRouteInfo: RebuildRouteInfoInternal = async (url: URL) => { + const { serverPlugins, loadedRoute } = await resolveRoute(routeModulePaths, url); + const requestHandlers = resolveRequestHandlers( + serverPlugins, + loadedRoute, + req.method ?? 'GET', + false, + renderFn + ); + + return { + loadedRoute, + requestHandlers, + }; + }; + const { completion, requestEv } = runQwikCity( serverRequestEv, loadedRoute, requestHandlers, + rebuildRouteInfo, ctx.opts.trailingSlash, ctx.opts.basePathname, qwikSerializer diff --git a/packages/qwik-city/src/middleware/request-handler/index.ts b/packages/qwik-city/src/middleware/request-handler/index.ts index a5d10b3195a..80a3e6dcbff 100644 --- a/packages/qwik-city/src/middleware/request-handler/index.ts +++ b/packages/qwik-city/src/middleware/request-handler/index.ts @@ -1,6 +1,7 @@ export { getErrorHtml, ServerError } from './error-handler'; export { mergeHeadersCookies } from './cookie'; export { AbortMessage, RedirectMessage } from './redirect-handler'; +export { RewriteMessage } from './rewrite-handler'; export { requestHandler } from './request-handler'; export { _TextEncoderStream_polyfill } from './polyfill'; export type { diff --git a/packages/qwik-city/src/middleware/request-handler/middleware.request-handler.api.md b/packages/qwik-city/src/middleware/request-handler/middleware.request-handler.api.md index 0fde166fead..aac2956b51c 100644 --- a/packages/qwik-city/src/middleware/request-handler/middleware.request-handler.api.md +++ b/packages/qwik-city/src/middleware/request-handler/middleware.request-handler.api.md @@ -112,6 +112,7 @@ export interface RequestEventBase { readonly env: EnvGetter; readonly headers: Headers; readonly method: string; + readonly originalUrl: URL; readonly params: Readonly>; readonly parseBody: () => Promise; readonly pathname: string; @@ -134,6 +135,7 @@ export interface RequestEventCommon extends Request readonly locale: (local?: string) => string; // Warning: (ae-forgotten-export) The symbol "RedirectCode" needs to be exported by the entry point index.d.ts readonly redirect: (statusCode: RedirectCode, url: string) => RedirectMessage; + readonly rewrite: (pathname: string) => RewriteMessage; // Warning: (ae-forgotten-export) The symbol "SendMethod" needs to be exported by the entry point index.d.ts readonly send: SendMethod; // Warning: (ae-forgotten-export) The symbol "StatusCodes" needs to be exported by the entry point index.d.ts @@ -174,6 +176,13 @@ export interface ResolveValue { (action: Action): Promise; } +// @public (undocumented) +export class RewriteMessage extends AbortMessage { + constructor(pathname: string); + // (undocumented) + readonly pathname: string; +} + // @public (undocumented) export class ServerError extends Error { constructor(status: number, data: T); 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 d4ade181aae..4988c731401 100644 --- a/packages/qwik-city/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-city/src/middleware/request-handler/request-event.ts @@ -12,6 +12,7 @@ import { createCacheControl } from './cache-control'; import { Cookie } from './cookie'; import { ServerError } from './error-handler'; import { AbortMessage, RedirectMessage } from './redirect-handler'; +import { RewriteMessage } from './rewrite-handler'; import { encoder } from './resolve-request-handlers'; import type { CacheControl, @@ -36,6 +37,7 @@ export const RequestRouteName = '@routeName'; export const RequestEvSharedActionId = '@actionId'; export const RequestEvSharedActionFormData = '@actionFormData'; export const RequestEvSharedNonce = '@nonce'; +export const RequestEvIsRewrite = '@rewrite'; export function createRequestEvent( serverRequestEv: ServerRequestEvent, @@ -82,6 +84,18 @@ export function createRequestEvent( } }; + const resetRoute = ( + _loadedRoute: LoadedRoute | null, + _requestHandlers: RequestHandler[], + _url = url + ) => { + loadedRoute = _loadedRoute; + requestHandlers = _requestHandlers; + url.pathname = _url.pathname; + url.search = _url.search; + routeModuleIndex = -1; + }; + const check = () => { if (writableStream !== null) { throw new Error('Response already sent'); @@ -133,17 +147,26 @@ export function createRequestEvent( [RequestEvLoaders]: loaders, [RequestEvMode]: serverRequestEv.mode, [RequestEvTrailingSlash]: trailingSlash, - [RequestEvRoute]: loadedRoute, + get [RequestEvRoute]() { + return loadedRoute; + }, [RequestEvQwikSerializer]: qwikSerializer, cookie, headers, env, method: request.method, signal: request.signal, - params: loadedRoute?.[1] ?? {}, - pathname: url.pathname, + originalUrl: new URL(url), + get params() { + return loadedRoute?.[1] ?? {}; + }, + get pathname() { + return url.pathname; + }, platform, - query: url.searchParams, + get query() { + return url.searchParams; + }, request, url, basePathname, @@ -160,6 +183,8 @@ export function createRequestEvent( next, + resetRoute, + exit, cacheControl: (cacheControl: CacheControl, target: CacheControlTarget = 'Cache-Control') => { @@ -220,6 +245,15 @@ export function createRequestEvent( return new RedirectMessage(); }, + rewrite: (pathname: string) => { + check(); + if (pathname.startsWith('http')) { + throw new Error('Rewrite does not support absolute urls'); + } + sharedMap.set(RequestEvIsRewrite, true); + return new RewriteMessage(pathname.replace(/\/+/g, '/')); + }, + defer: (returnData) => { return typeof returnData === 'function' ? returnData : () => returnData; }, @@ -296,6 +330,19 @@ export interface RequestEventInternal extends RequestEvent, RequestEventLoader { * @returns `true`, if `getWritableStream()` has already been called. */ isDirty(): boolean; + + /** + * Reset the request event to the given route data. + * + * @param loadedRoute - The new loaded route. + * @param requestHandlers - The new request handlers. + * @param url - The new URL of the route. + */ + resetRoute( + loadedRoute: LoadedRoute | null, + requestHandlers: RequestHandler[], + url: URL + ): void; } export function getRequestLoaders(requestEv: RequestEventCommon) { diff --git a/packages/qwik-city/src/middleware/request-handler/request-handler.ts b/packages/qwik-city/src/middleware/request-handler/request-handler.ts index 02ab430732d..661f6fdea97 100644 --- a/packages/qwik-city/src/middleware/request-handler/request-handler.ts +++ b/packages/qwik-city/src/middleware/request-handler/request-handler.ts @@ -1,6 +1,6 @@ import type { Render } from '@builder.io/qwik/server'; import { loadRoute } from '../../runtime/src/routing'; -import type { QwikCityPlan } from '../../runtime/src/types'; +import type { RebuildRouteInfoInternal, QwikCityPlan } from '../../runtime/src/types'; import { renderQwikMiddleware, resolveRequestHandlers } from './resolve-request-handlers'; import type { QwikSerializer, ServerRenderOptions, ServerRequestEvent } from './types'; import { getRouteMatchPathname, runQwikCity, type QwikCityRun } from './user-response'; @@ -25,12 +25,33 @@ export async function requestHandler( checkOrigin ?? true, render ); + if (routeAndHandlers) { const [route, requestHandlers] = routeAndHandlers; + + const rebuildRouteInfo: RebuildRouteInfoInternal = async (url: URL) => { + const matchPathname = getRouteMatchPathname(url.pathname, qwikCityPlan.trailingSlash); + const routeAndHandlers = await loadRequestHandlers( + qwikCityPlan, + matchPathname, + serverRequestEv.request.method, + checkOrigin ?? true, + render + ); + + if (routeAndHandlers) { + const [loadedRoute, requestHandlers] = routeAndHandlers; + return { loadedRoute, requestHandlers }; + } else { + return { loadedRoute: null, requestHandlers: [] }; + } + }; + return runQwikCity( serverRequestEv, route, requestHandlers, + rebuildRouteInfo, qwikCityPlan.trailingSlash, qwikCityPlan.basePathname, qwikSerializer 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 37a50b9740a..7ef8f79cbc1 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 @@ -16,6 +16,7 @@ import { HttpStatus } from './http-status-codes'; import { RedirectMessage } from './redirect-handler'; import { RequestEvQwikSerializer, + RequestEvIsRewrite, RequestEvSharedActionId, RequestRouteName, getRequestLoaders, @@ -45,7 +46,9 @@ export const resolveRequestHandlers = ( const routeActions: ActionInternal[] = []; const requestHandlers: RequestHandler[] = []; + const isPageRoute = !!(route && isLastModulePageRoute(route[2])); + if (serverPlugins) { _resolveRequestHandlers( routeLoaders, @@ -93,6 +96,7 @@ export const resolveRequestHandlers = ( requestHandlers.push(renderHandler); } } + return requestHandlers; }; @@ -355,7 +359,8 @@ async function pureServerFunction(ev: RequestEvent) { function fixTrailingSlash(ev: RequestEvent) { const trailingSlash = getRequestTrailingSlash(ev); - const { basePathname, pathname, url, sharedMap } = ev; + const { basePathname, originalUrl, sharedMap } = ev; + const { pathname, search } = originalUrl; const isQData = sharedMap.has(IsQData); if (!isQData && pathname !== basePathname && !pathname.endsWith('.html')) { // only check for slash redirect on pages @@ -363,7 +368,7 @@ function fixTrailingSlash(ev: RequestEvent) { // must have a trailing slash if (!pathname.endsWith('/')) { // add slash to existing pathname - throw ev.redirect(HttpStatus.MovedPermanently, pathname + '/' + url.search); + throw ev.redirect(HttpStatus.MovedPermanently, pathname + '/' + search); } } else { // should not have a trailing slash @@ -371,7 +376,7 @@ function fixTrailingSlash(ev: RequestEvent) { // remove slash from existing pathname throw ev.redirect( HttpStatus.MovedPermanently, - pathname.slice(0, pathname.length - 1) + url.search + pathname.slice(0, pathname.length - 1) + search ); } } @@ -501,6 +506,7 @@ export async function handleRedirect(requestEv: RequestEvent) { if (!isPageDataReq) { return; } + try { await requestEv.next(); } catch (err) { @@ -515,6 +521,7 @@ export async function handleRedirect(requestEv: RequestEvent) { const status = requestEv.status(); const location = requestEv.headers.get('Location'); const isRedirect = status >= 301 && status <= 308 && location; + if (isRedirect) { const adaptedLocation = makeQDataPath(location); if (adaptedLocation) { @@ -540,7 +547,7 @@ export async function renderQData(requestEv: RequestEvent) { } const status = requestEv.status(); - const location = requestEv.headers.get('Location'); + const redirectLocation = requestEv.headers.get('Location'); const trailingSlash = getRequestTrailingSlash(requestEv); const requestHeaders: Record = {}; @@ -552,7 +559,8 @@ export async function renderQData(requestEv: RequestEvent) { action: requestEv.sharedMap.get(RequestEvSharedActionId), status: status !== 200 ? status : 200, href: getPathname(requestEv.url, trailingSlash), - redirect: location ?? undefined, + redirect: redirectLocation ?? undefined, + isRewrite: requestEv.sharedMap.get(RequestEvIsRewrite), }; const writer = requestEv.getWritableStream().getWriter(); const qwikSerializer = (requestEv as RequestEventInternal)[RequestEvQwikSerializer]; diff --git a/packages/qwik-city/src/middleware/request-handler/response-page.ts b/packages/qwik-city/src/middleware/request-handler/response-page.ts index 00981720910..d374f790d89 100644 --- a/packages/qwik-city/src/middleware/request-handler/response-page.ts +++ b/packages/qwik-city/src/middleware/request-handler/response-page.ts @@ -10,7 +10,7 @@ import { } from './request-event'; export function getQwikCityServerData(requestEv: RequestEvent) { - const { url, params, request, status, locale } = requestEv; + const { params, request, status, locale, originalUrl } = requestEv; const requestHeaders: Record = {}; request.headers.forEach((value, key) => (requestHeaders[key] = value)); @@ -19,7 +19,7 @@ export function getQwikCityServerData(requestEv: RequestEvent) { const routeName = requestEv.sharedMap.get(RequestRouteName) as string; const nonce = requestEv.sharedMap.get(RequestEvSharedNonce); const headers = requestEv.request.headers; - const reconstructedUrl = new URL(url.pathname + url.search, url); + const reconstructedUrl = new URL(originalUrl.pathname + originalUrl.search, originalUrl); const host = headers.get('X-Forwarded-Host')!; const protocol = headers.get('X-Forwarded-Proto')!; if (host) { diff --git a/packages/qwik-city/src/middleware/request-handler/rewrite-handler.ts b/packages/qwik-city/src/middleware/request-handler/rewrite-handler.ts new file mode 100644 index 00000000000..143d1db3172 --- /dev/null +++ b/packages/qwik-city/src/middleware/request-handler/rewrite-handler.ts @@ -0,0 +1,8 @@ +import { AbortMessage } from './redirect-handler'; + +/** @public */ +export class RewriteMessage extends AbortMessage { + constructor(readonly pathname: string) { + super(); + } +} diff --git a/packages/qwik-city/src/middleware/request-handler/types.ts b/packages/qwik-city/src/middleware/request-handler/types.ts index ae183b8c721..659ce5bbe74 100644 --- a/packages/qwik-city/src/middleware/request-handler/types.ts +++ b/packages/qwik-city/src/middleware/request-handler/types.ts @@ -1,9 +1,10 @@ +import type { _deserializeData, _serializeData, _verifySerializable } from '@builder.io/qwik'; +import type { Action, FailReturn, Loader, QwikCityPlan } from '@builder.io/qwik-city'; import type { Render, RenderOptions } from '@builder.io/qwik/server'; -import type { QwikCityPlan, FailReturn, Action, Loader } from '@builder.io/qwik-city'; +import type { ServerError } 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'; +import type { RewriteMessage } from './rewrite-handler'; /** @public */ export interface EnvGetter { @@ -201,6 +202,15 @@ export interface RequestEventCommon */ readonly redirect: (statusCode: RedirectCode, url: string) => RedirectMessage; + /** + * When called, qwik-city will execute the path's matching route flow. + * + * The url in the browser will remain unchanged. + * + * @param pathname - The pathname to rewrite to. + */ + readonly rewrite: (pathname: string) => RewriteMessage; + /** * When 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 @@ -287,6 +297,19 @@ export interface RequestEventBase { /** HTTP request URL. */ readonly url: URL; + /** + * The original HTTP request URL. + * + * This property was introduced to support the rewrite feature. + * + * If rewrite is called, the url property will be changed to the rewritten url. while originalUrl + * will stay the same(e.g the url inserted to the address bar). + * + * If rewrite is never called as part of the request, the url property and the originalUrl are + * equal. + */ + readonly originalUrl: URL; + /** The base pathname of the request, which can be configured at build time. Defaults to `/`. */ readonly basePathname: string; 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 6f240dbc157..6802baf7fbe 100644 --- a/packages/qwik-city/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-city/src/middleware/request-handler/user-response.ts @@ -1,5 +1,5 @@ import type { RequestEvent, RequestHandler } from '@builder.io/qwik-city'; -import type { LoadedRoute } from '../../runtime/src/types'; +import type { RebuildRouteInfoInternal, LoadedRoute } from '../../runtime/src/types'; import { ServerError, getErrorHtml, minimalHtmlResponse } from './error-handler'; import { AbortMessage, RedirectMessage } from './redirect-handler'; import { @@ -10,6 +10,7 @@ import { } from './request-event'; import { encoder } from './resolve-request-handlers'; import type { QwikSerializer, ServerRequestEvent, StatusCodes } from './types'; +import { RewriteMessage } from './rewrite-handler'; export interface QwikCityRun { response: Promise; @@ -35,6 +36,7 @@ export function runQwikCity( serverRequestEv: ServerRequestEvent, loadedRoute: LoadedRoute | null, requestHandlers: RequestHandler[], + rebuildRouteInfo: RebuildRouteInfoInternal, trailingSlash = true, basePathname = '/', qwikSerializer: QwikSerializer @@ -50,62 +52,88 @@ export function runQwikCity( qwikSerializer, resolve! ); + return { response: responsePromise, requestEv, completion: asyncStore - ? asyncStore.run(requestEv, runNext, requestEv, resolve!) - : runNext(requestEv, resolve!), + ? asyncStore.run(requestEv, runNext, requestEv, rebuildRouteInfo, resolve!) + : runNext(requestEv, rebuildRouteInfo, resolve!), }; } -async function runNext(requestEv: RequestEventInternal, resolve: (value: any) => void) { - try { - // Run all middlewares - await requestEv.next(); - } catch (e) { - if (e instanceof RedirectMessage) { - const stream = requestEv.getWritableStream(); - await stream.close(); - } else if (e instanceof ServerError) { - if (!requestEv.headersSent) { - const status = e.status as StatusCodes; - 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); +async function runNext( + requestEv: RequestEventInternal, + rebuildRouteInfo: RebuildRouteInfoInternal, + resolve: (value: any) => void +) { + let rewriteAttempt = 1; + + async function _runNext() { + try { + // Run all middlewares + await requestEv.next(); + } catch (e) { + if (e instanceof RedirectMessage) { + const stream = requestEv.getWritableStream(); + await stream.close(); + } else if (e instanceof RewriteMessage) { + if (rewriteAttempt > 50) { + throw new Error(`Infinite rewrite loop`); } - } - } else if (!(e instanceof AbortMessage)) { - if (getRequestMode(requestEv) !== 'dev') { - try { - if (!requestEv.headersSent) { - requestEv.headers.set('content-type', 'text/html; charset=utf-8'); - requestEv.cacheControl({ noCache: true }); - requestEv.status(500); + + rewriteAttempt += 1; + const url = new URL(requestEv.url); + url.pathname = e.pathname; + const { loadedRoute, requestHandlers } = await rebuildRouteInfo(url); + requestEv.resetRoute(loadedRoute, requestHandlers, url); + return await _runNext(); + } else if (e instanceof ServerError) { + if (!requestEv.headersSent) { + const status = e.status as StatusCodes; + 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); } - const stream = requestEv.getWritableStream(); - if (!stream.locked) { - const writer = stream.getWriter(); - await writer.write(encoder.encode(minimalHtmlResponse(500, 'Internal Server Error'))); - await writer.close(); + } + } else if (!(e instanceof AbortMessage)) { + if (getRequestMode(requestEv) !== 'dev') { + try { + if (!requestEv.headersSent) { + requestEv.headers.set('content-type', 'text/html; charset=utf-8'); + requestEv.cacheControl({ noCache: true }); + requestEv.status(500); + } + const stream = requestEv.getWritableStream(); + if (!stream.locked) { + const writer = stream.getWriter(); + await writer.write(encoder.encode(minimalHtmlResponse(500, 'Internal Server Error'))); + await writer.close(); + } + } catch { + console.error('Unable to render error page'); } - } catch { - console.error('Unable to render error page'); } + + return e; } - return e; } + + return undefined; + } + + try { + return await _runNext(); } finally { if (!requestEv.isDirty()) { resolve(null); } } - return undefined; } /** diff --git a/packages/qwik-city/src/runtime/src/qwik-city-component.tsx b/packages/qwik-city/src/runtime/src/qwik-city-component.tsx index 49f801648d8..7baa11ff303 100644 --- a/packages/qwik-city/src/runtime/src/qwik-city-component.tsx +++ b/packages/qwik-city/src/runtime/src/qwik-city-component.tsx @@ -345,13 +345,16 @@ export const QwikCityProvider = component$((props) => { const newHref = pageData.href; const newURL = new URL(newHref, trackUrl); if (!isSamePath(newURL, trackUrl)) { - // Change our path to the canonical path in the response. - trackUrl = newURL; + // Change our path to the canonical path in the response unless rewrite. + if (!pageData.isRewrite) { + trackUrl = newURL; + } + loadRoutePromise = loadRoute( qwikCity.routes, qwikCity.menus, qwikCity.cacheModules, - trackUrl.pathname + newURL.pathname // Load the actual required path. ); } diff --git a/packages/qwik-city/src/runtime/src/types.ts b/packages/qwik-city/src/runtime/src/types.ts index 12845d7c9af..d5f0af33201 100644 --- a/packages/qwik-city/src/runtime/src/types.ts +++ b/packages/qwik-city/src/runtime/src/types.ts @@ -81,6 +81,10 @@ export type RouteStateInternal = { scroll?: boolean; }; +export type RebuildRouteInfoInternal = ( + url: URL +) => Promise<{ loadedRoute: LoadedRoute | null; requestHandlers: RequestHandler[] }>; + /** * @param url - The URL that the user is trying to navigate to, or a number to indicate the user is * trying to navigate back/forward in the application history. If it is missing, the event is sent @@ -307,6 +311,7 @@ export interface ClientPageData extends Omit { status: number; href: string; redirect?: string; + isRewrite?: boolean; } /** @public */ diff --git a/starters/apps/qwikcity-test/src/routes/(common)/products/[id]/index.tsx b/starters/apps/qwikcity-test/src/routes/(common)/products/[id]/index.tsx index 9b34a0b20e4..b7b4bcb8931 100644 --- a/starters/apps/qwikcity-test/src/routes/(common)/products/[id]/index.tsx +++ b/starters/apps/qwikcity-test/src/routes/(common)/products/[id]/index.tsx @@ -68,6 +68,40 @@ export default component$(() => { T-Shirt (Redirect to /products/tshirt) +
  • + + T-Shirt (Rewrite to /products/tshirt) + +
  • +
  • + + T-Shirt (Rewrite to /products/tshirt) + +
  • +
  • + + T-Shirt (Rewrite to /products/tshirt) Also trailing slash should be + added. + +
  • +
  • + + T-Shirt (Rewrite to /products/tshirt) Also trailing slash should be + added. + +
  • = { }; export const useProductLoader = routeLoader$( - async ({ headers, json, error, params, query, redirect, status }) => { + async ({ + headers, + json, + error, + params, + query, + redirect, + rewrite, + status, + url, + }) => { // Serverside Endpoint // During SSR, this method is called directly on the server and returns the data object // On the client, this same data can be requested with fetch() at the same URL, but also @@ -128,6 +172,15 @@ export const useProductLoader = routeLoader$( throw redirect(301, "/qwikcity-test/products/tshirt/"); } + if (id === "shirt-rewrite") { + throw rewrite("/qwikcity-test/products/tshirt/"); + } + + // Should throw an error + if (id === "shirt-rewrite-absolute-url") { + throw rewrite(`${url.origin}/qwikcity-test/products/tshirt/`); + } + if (id === "error") { throw error(500, "Error from server"); } diff --git a/starters/e2e/qwikcity/page.spec.ts b/starters/e2e/qwikcity/page.spec.ts index 1ae8164c37f..55c01ad1758 100644 --- a/starters/e2e/qwikcity/page.spec.ts +++ b/starters/e2e/qwikcity/page.spec.ts @@ -1,5 +1,12 @@ import { expect, test } from "@playwright/test"; -import { assertPage, linkNavigate, load, locator } from "./util.js"; +import { + assertPage, + getPage, + linkNavigate, + load, + locator, + setPage, +} from "./util.js"; test.describe("Qwik City Page", () => { test.describe("mpa", () => { @@ -108,6 +115,55 @@ function tests() { h1: "Product: tshirt", activeHeaderLink: "Products", }); + + /*********** Products: shirt (rewrite to /products/tshirt) ***********/ + await linkNavigate(ctx, '[data-test-link="products-shirt-rewrite"]'); + await assertPage(ctx, { + pathname: "/qwikcity-test/products/shirt-rewrite/", + title: "Product tshirt - Qwik", + layoutHierarchy: ["root"], + h1: "Product: tshirt", + activeHeaderLink: "Products", + }); + + /*********** Products: shirt (rewrite to /products/tshirt) ***********/ + await linkNavigate( + ctx, + '[data-test-link="products-shirt-rewrite-with-search"]', + ); + await assertPage(ctx, { + pathname: "/qwikcity-test/products/shirt-rewrite/", + title: "Product tshirt - Qwik", + layoutHierarchy: ["root"], + h1: "Product: tshirt", + activeHeaderLink: "Products", + searchParams: { search: "true" }, + }); + + /*********** Products: shirt (rewrite to /products/tshirt) ***********/ + await linkNavigate( + ctx, + '[data-test-link="products-shirt-rewrite-absolute-url"]', + 500, + ); + await assertPage(ctx, { + title: "500 Internal Server Error", + }); + // Recover from error + await setPage(ctx, "/qwikcity-test/products/hat/"); + + /*********** Products: shirt (rewrite to /products/tshirt) ***********/ + await linkNavigate( + ctx, + '[data-test-link="products-shirt-rewrite-no-trailing-slash"]', + ); + await assertPage(ctx, { + pathname: "/qwikcity-test/products/shirt-rewrite/", + title: "Product tshirt - Qwik", + layoutHierarchy: ["root"], + h1: "Product: tshirt", + activeHeaderLink: "Products", + }); } /*********** Products: hoodie (404) ***********/ diff --git a/starters/e2e/qwikcity/util.ts b/starters/e2e/qwikcity/util.ts index 2ba2aaed589..8f763ede2b5 100644 --- a/starters/e2e/qwikcity/util.ts +++ b/starters/e2e/qwikcity/util.ts @@ -23,6 +23,16 @@ export async function assertPage(ctx: TestContext, test: AssertPage) { expect(canonicalUrl.pathname).toBe(test.pathname); } + if (test.searchParams) { + if (test.searchParams === "empty") { + expect(pageUrl.searchParams.size).toBe(0); + } else { + for (const [key, value] of Object.entries(test.searchParams)) { + expect(pageUrl.searchParams.get(key)).toBe(value); + } + } + } + if (test.title) { const title = head.locator("title"); expect(await title.innerText()).toBe(test.title); @@ -76,6 +86,7 @@ export async function assertPage(ctx: TestContext, test: AssertPage) { interface AssertPage { pathname?: string; + searchParams?: Record | "empty"; title?: string; h1?: string; layoutHierarchy?: string[]; @@ -166,6 +177,16 @@ export function getPage(ctx: TestContext) { return ctx.browserContext.pages()[0]!; } +export async function setPage(ctx: TestContext, pathname: string) { + const page = getPage(ctx); + const response = (await page.goto(pathname))!; + const status = response.status(); + if (status !== 200) { + const text = await response.text(); + expect(status, `${pathname} (${status})\n${text}`).toBe(200); + } +} + export async function load( browserContext: BrowserContext, javaScriptEnabled: boolean | undefined,