diff --git a/README.md b/README.md index 611f3fc..f0890cf 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ - [Examples folder structure](#examples-folder-structure) - [Basic Setup](#basic-setup) - [Monorepo](#monorepo) -- [Versioning](#versioning) +- [Known Issues](#known-issues) +- [Contributing](#contributing) - [License](#license) @@ -100,7 +101,7 @@ Next 9 added [built-in zero-config typescript support](https://nextjs.org/blog/n If you are having issues with unexpected tokens, files not emitting when building for production, warnings about `allowJs` and `declaration` not being used together, and other typescript related errors; see the `tsconfig.server.json` [file in the example project](/examples/basic/tsconfig.server.json) for the full config. -#### Pass-through 404s +#### Pass-through 404s Instead of having Nest handle the response for requests that 404, they can be forwarded to Next's request handler. @@ -295,11 +296,18 @@ outside of both projects. Changes in it during "dev" runs trigger recompilation /IndexPage.ts package.json - To run this project, the "ui" and "server" project must be built, in any order. The "dto" project will be implicitly built by the "server" project. After both of those builds, the "server" project can be started in either dev or production mode. It is important that "ui" references to "dto" refer to the TypeScript files (.ts files in the "src" folder), and NOT the declaration files (.d.ts files in the "dist" folder), due to how Next not being compiled in the same fashion as the server. +### Known issues + +Currently Next ["catch all routes"](https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes) pages do not work correctly. See issue [#101](https://github.com/kyle-mccarthy/nest-next/issues/101) for details. + +### Contributing + +To contribute make sure your changes pass the test suite. To run test suite `docker`, `docker-compose` are required. Run `npm run test` to run tests. Playwright will be installed with required packages. To run tests in Next development mode run `npm run test-dev`. You can also specify `NODE_VERSION` and major `NEXT_VERSION` variables to run tests in specific environments. + ### License MIT License diff --git a/lib/render.module.ts b/lib/render.module.ts index 746f946..60e0d2b 100644 --- a/lib/render.module.ts +++ b/lib/render.module.ts @@ -1,9 +1,13 @@ import { DynamicModule, Module } from '@nestjs/common'; import { ApplicationConfig, HttpAdapterHost } from '@nestjs/core'; + import Server from 'next'; +import type { DynamicRoutes } from 'next/dist/server/router'; + import { RenderFilter } from './render.filter'; import { RenderService } from './render.service'; -import { RendererConfig } from './types'; + +import type { RendererConfig } from './types'; @Module({ providers: [RenderService], @@ -30,7 +34,15 @@ export class RenderModule { ? nextConfig.basePath : nextServer.nextConfig.basePath; - const config = { basePath, ...options }; + const dynamicRoutes = (nextServer.dynamicRoutes as DynamicRoutes).map( + (route) => route.page, + ); + + const config: Partial = { + basePath, + dynamicRoutes, + ...options, + }; return { exports: [RenderService], diff --git a/lib/render.service.ts b/lib/render.service.ts index 64d310d..8d81048 100644 --- a/lib/render.service.ts +++ b/lib/render.service.ts @@ -11,6 +11,10 @@ import { RequestHandler, } from './types'; +import { getNamedRouteRegex } from './vendor/next/route-regex'; +import { interpolateDynamicPath } from './vendor/next/interpolate-dynamic-path'; +import { isDynamicRoute } from './vendor/next/is-dynamic'; + export class RenderService { public static init( config: Partial, @@ -38,6 +42,10 @@ export class RenderService { passthrough404: false, viewsDir: '/views', }; + private dynamicRouteRegexes = new Map< + string, + ReturnType + >(); /** * Merge the default config with the config obj passed to method @@ -56,6 +64,9 @@ export class RenderService { if (typeof config.basePath === 'string') { this.config.basePath = config.basePath; } + if (config.dynamicRoutes?.length) { + this.initializeDynamicRouteRegexes(config.dynamicRoutes); + } } /** @@ -167,6 +178,14 @@ export class RenderService { return isInternalUrl(url); } + public initializeDynamicRouteRegexes(routes: string[] = []) { + for (const route of routes) { + const pathname = this.getNormalizedPath(route); + + this.dynamicRouteRegexes.set(route, getNamedRouteRegex(pathname)); + } + } + /** * Check if the service has been initialized by the module */ @@ -198,7 +217,7 @@ export class RenderService { if (isFastify) { response.sent = true; } - return renderer(req, res, getViewPath(view), data); + return renderer(req, res, getViewPath(view, req.params), data); } else if (!renderer) { throw new InternalServerErrorException( 'RenderService: renderer is not set', @@ -228,7 +247,10 @@ export class RenderService { if (isFastifyAdapter) { server .getInstance() - .decorateReply('render', function(view: string, data?: ParsedUrlQuery) { + .decorateReply('render', function ( + view: string, + data?: ParsedUrlQuery, + ) { const res = this.res; const req = this.request.raw; @@ -240,7 +262,7 @@ export class RenderService { this.sent = true; - return renderer(req, res, getViewPath(view), data); + return renderer(req, res, getViewPath(view, req.params), data); } as RenderableResponse['render']); } else { server.getInstance().use((req: any, res: any, next: () => any) => { @@ -251,7 +273,7 @@ export class RenderService { ); } - return renderer(req, res, getViewPath(view), data); + return renderer(req, res, getViewPath(view, req.params), data); }) as RenderableResponse['render']; next(); @@ -259,13 +281,38 @@ export class RenderService { } } + public getNormalizedPath(view: string) { + const basePath = this.getViewsDir() ?? ''; + const denormalizedPath = [basePath, view].join( + view.startsWith('/') ? '' : '/', + ); + + const pathname = path.posix.normalize(denormalizedPath); + + return pathname; + } + /** - * Format the path to the view + * Format the path to the view including path parameters interpolation + * Copied Next.js code is used for interpolation * @param view + * @param params */ - protected getViewPath(view: string) { - const baseDir = this.getViewsDir(); - const basePath = baseDir ? baseDir : ''; - return path.posix.normalize(`${basePath}/${view}`); + protected getViewPath(view: string, params: ParsedUrlQuery) { + const pathname = this.getNormalizedPath(view); + + if (!isDynamicRoute(pathname)) { + return pathname; + } + + const regex = this.dynamicRouteRegexes.get(pathname); + + if (!regex) { + console.warn( + `RenderService: view ${view} is dynamic and has no route regex`, + ); + } + + return interpolateDynamicPath(pathname, params, regex); } } diff --git a/lib/types.ts b/lib/types.ts index 0c3b181..96a8b42 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -34,6 +34,7 @@ export interface RendererConfig { basePath?: string; dev: boolean; passthrough404?: boolean; + dynamicRoutes?: string[]; } export interface ErrorResponse { diff --git a/lib/vendor/next/LICENSE b/lib/vendor/next/LICENSE new file mode 100644 index 0000000..4bdf5bf --- /dev/null +++ b/lib/vendor/next/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/lib/vendor/next/escape-regexp.ts b/lib/vendor/next/escape-regexp.ts new file mode 100644 index 0000000..1eff36a --- /dev/null +++ b/lib/vendor/next/escape-regexp.ts @@ -0,0 +1,11 @@ +// regexp is based on https://github.com/sindresorhus/escape-string-regexp +const reHasRegExp = /[|\\{}()[\]^$+*?.-]/; +const reReplaceRegExp = /[|\\{}()[\]^$+*?.-]/g; + +export function escapeStringRegexp(str: string) { + // see also: https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/escapeRegExp.js#L23 + if (reHasRegExp.test(str)) { + return str.replace(reReplaceRegExp, '\\$&'); + } + return str; +} diff --git a/lib/vendor/next/interpolate-dynamic-path.ts b/lib/vendor/next/interpolate-dynamic-path.ts new file mode 100644 index 0000000..ec70fc1 --- /dev/null +++ b/lib/vendor/next/interpolate-dynamic-path.ts @@ -0,0 +1,41 @@ +import { ParsedUrlQuery } from 'querystring'; +import { getNamedRouteRegex } from './route-regex'; + +export function interpolateDynamicPath( + pathname: string, + params: ParsedUrlQuery, + defaultRouteRegex?: ReturnType | undefined, +) { + if (!defaultRouteRegex) return pathname; + + for (const param of Object.keys(defaultRouteRegex.groups)) { + const { optional, repeat } = defaultRouteRegex.groups[param]; + let builtParam = `[${repeat ? '...' : ''}${param}]`; + + if (optional) { + builtParam = `[${builtParam}]`; + } + + const paramIdx = pathname!.indexOf(builtParam); + + if (paramIdx > -1) { + let paramValue: string; + const value = params[param]; + + if (Array.isArray(value)) { + paramValue = value.map((v) => v && encodeURIComponent(v)).join('/'); + } else if (value) { + paramValue = encodeURIComponent(value); + } else { + paramValue = ''; + } + + pathname = + pathname.slice(0, paramIdx) + + paramValue + + pathname.slice(paramIdx + builtParam.length); + } + } + + return pathname; +} diff --git a/lib/vendor/next/is-dynamic.ts b/lib/vendor/next/is-dynamic.ts new file mode 100644 index 0000000..df6c23e --- /dev/null +++ b/lib/vendor/next/is-dynamic.ts @@ -0,0 +1,6 @@ +// Identify /[param]/ in route string +const TEST_ROUTE = /\/\[[^/]+?\](?=\/|$)/; + +export function isDynamicRoute(route: string): boolean { + return TEST_ROUTE.test(route); +} diff --git a/lib/vendor/next/remove-trailing-slash.ts b/lib/vendor/next/remove-trailing-slash.ts new file mode 100644 index 0000000..328dff9 --- /dev/null +++ b/lib/vendor/next/remove-trailing-slash.ts @@ -0,0 +1,10 @@ +/** + * Removes the trailing slash for a given route or page path. Preserves the + * root page. Examples: + * - `/foo/bar/` -> `/foo/bar` + * - `/foo/bar` -> `/foo/bar` + * - `/` -> `/` + */ +export function removeTrailingSlash(route: string) { + return route.replace(/\/$/, '') || '/'; +} diff --git a/lib/vendor/next/route-regex.ts b/lib/vendor/next/route-regex.ts new file mode 100644 index 0000000..c98558f --- /dev/null +++ b/lib/vendor/next/route-regex.ts @@ -0,0 +1,175 @@ +import { escapeStringRegexp } from './escape-regexp'; +import { removeTrailingSlash } from './remove-trailing-slash'; + +export interface Group { + pos: number; + repeat: boolean; + optional: boolean; +} + +export interface RouteRegex { + groups: { [groupName: string]: Group }; + re: RegExp; +} + +/** + * Parses a given parameter from a route to a data structure that can be used + * to generate the parametrized route. Examples: + * - `[...slug]` -> `{ name: 'slug', repeat: true, optional: true }` + * - `[foo]` -> `{ name: 'foo', repeat: false, optional: true }` + * - `bar` -> `{ name: 'bar', repeat: false, optional: false }` + */ +function parseParameter(param: string) { + const optional = param.startsWith('[') && param.endsWith(']'); + if (optional) { + param = param.slice(1, -1); + } + const repeat = param.startsWith('...'); + if (repeat) { + param = param.slice(3); + } + return { key: param, repeat, optional }; +} + +function getParametrizedRoute(route: string) { + const segments = removeTrailingSlash(route).slice(1).split('/'); + const groups: { [groupName: string]: Group } = {}; + let groupIndex = 1; + return { + parameterizedRoute: segments + .map((segment) => { + if (segment.startsWith('[') && segment.endsWith(']')) { + const { key, optional, repeat } = parseParameter( + segment.slice(1, -1), + ); + groups[key] = { pos: groupIndex++, repeat, optional }; + return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)'; + } else { + return `/${escapeStringRegexp(segment)}`; + } + }) + .join(''), + groups, + }; +} + +/** + * From a normalized route this function generates a regular expression and + * a corresponding groups object intended to be used to store matching groups + * from the regular expression. + */ +export function getRouteRegex(normalizedRoute: string): RouteRegex { + const { parameterizedRoute, groups } = getParametrizedRoute(normalizedRoute); + return { + re: new RegExp(`^${parameterizedRoute}(?:/)?$`), + groups: groups, + }; +} + +/** + * Builds a function to generate a minimal routeKey using only a-z and minimal + * number of characters. + */ +function buildGetSafeRouteKey() { + let routeKeyCharCode = 97; + let routeKeyCharLength = 1; + + return () => { + let routeKey = ''; + for (let i = 0; i < routeKeyCharLength; i++) { + routeKey += String.fromCharCode(routeKeyCharCode); + routeKeyCharCode++; + + if (routeKeyCharCode > 122) { + routeKeyCharLength++; + routeKeyCharCode = 97; + } + } + return routeKey; + }; +} + +function getNamedParametrizedRoute(route: string) { + const segments = removeTrailingSlash(route).slice(1).split('/'); + const getSafeRouteKey = buildGetSafeRouteKey(); + const routeKeys: { [named: string]: string } = {}; + return { + namedParameterizedRoute: segments + .map((segment) => { + if (segment.startsWith('[') && segment.endsWith(']')) { + const { key, optional, repeat } = parseParameter( + segment.slice(1, -1), + ); + // replace any non-word characters since they can break + // the named regex + let cleanedKey = key.replace(/\W/g, ''); + let invalidKey = false; + + // check if the key is still invalid and fallback to using a known + // safe key + if (cleanedKey.length === 0 || cleanedKey.length > 30) { + invalidKey = true; + } + if (!isNaN(parseInt(cleanedKey.slice(0, 1)))) { + invalidKey = true; + } + + if (invalidKey) { + cleanedKey = getSafeRouteKey(); + } + + routeKeys[cleanedKey] = key; + return repeat + ? optional + ? `(?:/(?<${cleanedKey}>.+?))?` + : `/(?<${cleanedKey}>.+?)` + : `/(?<${cleanedKey}>[^/]+?)`; + } else { + return `/${escapeStringRegexp(segment)}`; + } + }) + .join(''), + routeKeys, + }; +} + +/** + * This function extends `getRouteRegex` generating also a named regexp where + * each group is named along with a routeKeys object that indexes the assigned + * named group with its corresponding key. + */ +export function getNamedRouteRegex(normalizedRoute: string) { + const result = getNamedParametrizedRoute(normalizedRoute); + return { + ...getRouteRegex(normalizedRoute), + namedRegex: `^${result.namedParameterizedRoute}(?:/)?$`, + routeKeys: result.routeKeys, + }; +} + +/** + * Generates a named regexp. + * This is intended to be using for build time only. + */ +export function getNamedMiddlewareRegex( + normalizedRoute: string, + options: { + catchAll?: boolean; + }, +) { + const { parameterizedRoute } = getParametrizedRoute(normalizedRoute); + const { catchAll = true } = options; + if (parameterizedRoute === '/') { + const catchAllRegex = catchAll ? '.*' : ''; + return { + namedRegex: `^/${catchAllRegex}$`, + }; + } + + const { namedParameterizedRoute } = + getNamedParametrizedRoute(normalizedRoute); + const catchAllGroupedRegex = catchAll ? '(?:(/.*)?)' : ''; + return { + namedRegex: `^${namedParameterizedRoute}${catchAllGroupedRegex}$`, + }; +} diff --git a/package.json b/package.json index 3d1b13a..dba2990 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "build": "del-cli ./dist --force && tsc -p tsconfig.json", "prepublish": "yarn run build", "publish-public": "yarn publish --access public", - "lint": "node ./node_modules/eslint/bin/eslint.js lib/**" + "lint": "node ./node_modules/eslint/bin/eslint.js lib/**", + "test": "bash run_tests.sh test-app", + "test-dev": "bash run_tests.sh test-app-dev" }, "devDependencies": { "@nestjs/common": "^8.4.4", diff --git a/run_tests.sh b/run_tests.sh new file mode 100644 index 0000000..108b90d --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +npm run build && cp -r dist tests/test-app/nest-next-dist + +(cd tests; NODE_VERSION=${NODE_VERSION:-14} NEXT_VERSION=${NEXT_VERSION:-12} docker-compose up -d --build $1) + +(cd tests/e2e; npm install; npx playwright install; npx playwright test) diff --git a/tests/e2e/specs/gssp-dynamic.spec.ts b/tests/e2e/specs/gssp-dynamic.spec.ts index 3ee21ca..b7a1dc8 100644 --- a/tests/e2e/specs/gssp-dynamic.spec.ts +++ b/tests/e2e/specs/gssp-dynamic.spec.ts @@ -20,4 +20,11 @@ test.describe('getServerSideProps dynamic pages', () => { await expect(page.locator('h1')).toHaveText('FALLBACK BLOG POSTS'); }); + + // FIXME nested slug paths [...slug] dont work - see https://github.com/kyle-mccarthy/nest-next/issues/101 + test.skip('any about page', async ({ page }) => { + await page.goto('/about/any-page/nested'); + + await expect(page.locator('h1')).toHaveText('ALL ABOUT'); + }); });