Skip to content

Commit

Permalink
Catch all dynamic routes (#298)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielcondemarin committed Feb 16, 2020
1 parent d0d5491 commit 33ab9cc
Show file tree
Hide file tree
Showing 11 changed files with 151 additions and 61 deletions.
6 changes: 5 additions & 1 deletion packages/serverless-nextjs-component/__tests__/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ describe("build tests", () => {
"/customers/:customer/profile": {
file: "pages/customers/[customer]/profile.js",
regex: expect.any(String)
},
"/customers/:catchAll*": {
file: "pages/customers/[...catchAll].js",
regex: expect.any(String)
}
});

Expand Down Expand Up @@ -151,7 +155,7 @@ describe("build tests", () => {

// html pages should not be included in the default lambda
expect(pages).toEqual(["_error.js", "blog.js", "customers"]);
expect(customerPages).toEqual(["[post].js"]);
expect(customerPages).toEqual(["[...catchAll].js", "[post].js"]);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ describe("Lambda@Edge", () => {
describe("Routing", () => {
describe("HTML pages routing", () => {
it.each`
path | expectedPage
${"/"} | ${"/index.html"}
${"/index"} | ${"/index.html"}
${"/terms"} | ${"/terms.html"}
${"/users/batman"} | ${"/users/[user].html"}
${"/john/123"} | ${"/[username]/[id].html"}
path | expectedPage
${"/"} | ${"/index.html"}
${"/index"} | ${"/index.html"}
${"/terms"} | ${"/terms.html"}
${"/users/batman"} | ${"/users/[user].html"}
${"/users/test/catch/all"} | ${"/users/[...user].html"}
${"/john/123"} | ${"/[username]/[id].html"}
`(
"serves page $expectedPage for path $path",
async ({ path, expectedPage }) => {
Expand Down Expand Up @@ -86,28 +87,39 @@ describe("Lambda@Edge", () => {
});

describe("SSR Pages routing", () => {
it("renders page", async () => {
const event = createCloudFrontEvent({
uri: "/customers",
host: "mydistribution.cloudfront.net",
origin: {
s3: {
domainName: "my-bucket.amazonaws.com"
it.each`
path | expectedPage
${"/abc"} | ${"pages/[root].js"}
${"/blog/foo"} | ${"pages/blog/[id].js"}
${"/customers/superman"} | ${"pages/customers/[customer].js"}
${"/customers/superman/howtofly"} | ${"pages/customers/[customer]/[post].js"}
${"/customers/superman/profile"} | ${"pages/customers/[customer]/profile.js"}
${"/customers/test/catch/all"} | ${"pages/customers/[...catchAll].js"}
`(
"renders page $expectedPage for path $path",
async ({ path, expectedPage }) => {
const event = createCloudFrontEvent({
uri: path,
host: "mydistribution.cloudfront.net",
origin: {
s3: {
domainName: "my-bucket.amazonaws.com"
}
}
}
});
});

mockPageRequire("pages/customers/index.js");
mockPageRequire(expectedPage);

const response = await handler(event, {});
const response = await handler(event, {});

const decodedBody = new Buffer(response.body, "base64").toString(
"utf8"
);
const decodedBody = new Buffer(response.body, "base64").toString(
"utf8"
);

expect(decodedBody).toEqual("pages/customers/index.js");
expect(response.status).toEqual(200);
});
expect(decodedBody).toEqual(expectedPage);
expect(response.status).toEqual(200);
}
);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
render: (req, res) => {
res.end("pages/customers/[...catchAll].js");
}
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module.exports = {
render: (req, res) => {
res.end("pages/[customers]/[customer]/profile.js");
res.end("pages/customers/[customer]/profile.js");
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@
"file": "pages/customers/[customer].js",
"regex": "^/customers/([^/]+?)(?:/)?$"
},
"/customers/:customer/:post": {
"file": "pages/customers/:customer/:post",
"regex": "^/customers/([^/]+?)/([^/]+?)(?:/)?$"
},
"/customers/:customer/profile": {
"file": "pages/customers/[customer]/profile.js",
"regex": "^/customers/([^/]+?)/profile(?:/)?$"
},
"/customers/:customer/:post": {
"file": "pages/customers/[customer]/[post].js",
"regex": "^/customers/([^/]+?)/([^/]+?)(?:/)?$"
},
"/customers/:catchAll*": {
"file": "pages/customers/[...catchAll].js",
"regex": "^/customers(?:/((?:[^/#?]+?)(?:/(?:[^/#?]+?))*))?[/#?]?$"
}
},
"nonDynamic": {
Expand All @@ -50,6 +54,10 @@
"file": "pages/users/[user].html",
"regex": "^/users/([^/]+?)(?:/)?$"
},
"/users/:user*": {
"file": "pages/users/[...user].html",
"regex": "^/users(?:/((?:[^/#?]+?)(?:/(?:[^/#?]+?))*))?[/#?]?$"
},
"/:username/:id": {
"file": "pages/[username]/[id].html",
"regex": "^/([^/]+?)/([^/]+?)(?:/)?$"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"/customers/[customer]/[post]": "pages/customers/[customer]/[post].js",
"/customers/new": "pages/customers/new.js",
"/customers/[customer]/profile": "pages/customers/[customer]/profile.js",
"/customers/[...catchAll]": "pages/customers/[...catchAll].js",
"/api/customers": "pages/api/customers.js",
"/api/customers/[id]": "pages/api/customers/[id].js",
"/api/customers/new": "pages/api/customers/new.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"license": "MIT",
"dependencies": {
"aws-sdk": "^2.521.0",
"next": "^9.0.5",
"next": "^9.2.1",
"react": "^16.9.0",
"react-dom": "^16.9.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// converts a nextjs dynamic route /[param]/ to express style /:param/
// converts a nextjs dynamic route /[param]/ -> /:param
// also handles catch all routes /[...param]/ -> /:param*
module.exports = dynamicRoute => {
return dynamicRoute.replace(/\[(?<param>.*?)]/g, ":$<param>");
// replace any catch all group first
let expressified = dynamicRoute.replace(/\[\.\.\.(.*)]$/, ":$1*");

// now replace other dynamic route groups
return expressified.replace(/\[(.*?)]/g, ":$1");
};
112 changes: 84 additions & 28 deletions packages/serverless-nextjs-component/lib/sortedRoutes.js
Original file line number Diff line number Diff line change
@@ -1,84 +1,139 @@
// This file Taken was from next.js repo
// https://github.com/zeit/next.js/blob/820a9790baafd36f14a79cf416162e3263cb00c4/packages/next/next-server/lib/router/utils/sorted-routes.ts#L89
// This file taken was from next.js repo and converted to JS.
// https://github.com/zeit/next.js/blob/canary/packages/next/next-server/lib/router/utils/sorted-routes.ts

class UrlNode {
constructor() {
this.placeholder = true;
this.children = new Map();
this.slugName = null;
this.restSlugName = null;
}
hasSlug() {
return this.slugName != null;
}

insert(urlPath) {
this._insert(urlPath.split("/").filter(Boolean));
this._insert(urlPath.split("/").filter(Boolean), [], false);
}

smoosh() {
return this._smoosh();
}

_smoosh(prefix = "/") {
const childrenPaths = [...this.children.keys()].sort();
if (this.hasSlug()) {
if (this.slugName !== null) {
childrenPaths.splice(childrenPaths.indexOf("[]"), 1);
}
if (this.restSlugName !== null) {
childrenPaths.splice(childrenPaths.indexOf("[...]"), 1);
}

const routes = childrenPaths
.map(c => this.children.get(c)._smoosh(`${prefix}${c}/`))
.reduce((prev, curr) => [...prev, ...curr], []);
if (this.hasSlug()) {

if (this.slugName !== null) {
routes.push(
...this.children.get("[]")._smoosh(`${prefix}[${this.slugName}]/`)
);
}

if (!this.placeholder) {
routes.unshift(prefix === "/" ? "/" : prefix.slice(0, -1));
}

if (this.restSlugName !== null) {
routes.push(
...this.children
.get("[...]")
._smoosh(`${prefix}[...${this.restSlugName}]/`)
);
}

return routes;
}
_insert(urlPaths, slugNames = []) {

_insert(urlPaths, slugNames, isCatchAll) {
if (urlPaths.length === 0) {
this.placeholder = false;
return;
}

if (isCatchAll) {
throw new Error(`Catch-all must be the last part of the URL.`);
}

// The next segment in the urlPaths list
let nextSegment = urlPaths[0];

// Check if the segment matches `[something]`
if (nextSegment.startsWith("[") && nextSegment.endsWith("]")) {
// Strip `[` and `]`, leaving only `something`
const slugName = nextSegment.slice(1, -1);
// If the specific segment already has a slug but the slug is not `something`
// This prevents collisions like:
// pages/[post]/index.js
// pages/[id]/index.js
// Because currently multiple dynamic params on the same segment level are not supported
if (this.hasSlug() && slugName !== this.slugName) {
// TODO: This error seems to be confusing for users, needs an err.sh link, the description can be based on above comment.
throw new Error(
"You cannot use different slug names for the same dynamic path."
);
let segmentName = nextSegment.slice(1, -1);
if (segmentName.startsWith("...")) {
segmentName = segmentName.substring(3);
isCatchAll = true;
}
if (slugNames.indexOf(slugName) !== -1) {

if (segmentName.startsWith(".")) {
throw new Error(
`You cannot have the same slug name "${slugName}" repeat within a single dynamic path`
`Segment names may not start with erroneous periods ('${segmentName}').`
);
}
slugNames.push(slugName);
// slugName is kept as it can only be one particular slugName
this.slugName = slugName;
// nextSegment is overwritten to [] so that it can later be sorted specifically
nextSegment = "[]";

function handleSlug(previousSlug, nextSlug) {
if (previousSlug !== null) {
// If the specific segment already has a slug but the slug is not `something`
// This prevents collisions like:
// pages/[post]/index.js
// pages/[id]/index.js
// Because currently multiple dynamic params on the same segment level are not supported
if (previousSlug !== nextSlug) {
// TODO: This error seems to be confusing for users, needs an err.sh link, the description can be based on above comment.
throw new Error(
`You cannot use different slug names for the same dynamic path ('${previousSlug}' !== '${nextSlug}').`
);
}
}

if (slugNames.indexOf(nextSlug) !== -1) {
throw new Error(
`You cannot have the same slug name "${nextSlug}" repeat within a single dynamic path`
);
}

slugNames.push(nextSlug);
}

if (isCatchAll) {
handleSlug(this.restSlugName, segmentName);
// slugName is kept as it can only be one particular slugName
this.restSlugName = segmentName;
// nextSegment is overwritten to [] so that it can later be sorted specifically
nextSegment = "[...]";
} else {
handleSlug(this.slugName, segmentName);
// slugName is kept as it can only be one particular slugName
this.slugName = segmentName;
// nextSegment is overwritten to [] so that it can later be sorted specifically
nextSegment = "[]";
}
}

// If this UrlNode doesn't have the nextSegment yet we create a new child UrlNode
if (!this.children.has(nextSegment)) {
this.children.set(nextSegment, new UrlNode());
}
this.children.get(nextSegment)._insert(urlPaths.slice(1), slugNames);

this.children
.get(nextSegment)
._insert(urlPaths.slice(1), slugNames, isCatchAll);
}
}

module.exports = function getSortedRoutes(normalizedPages) {
// First the UrlNode is created, and every UrlNode can have only 1 dynamic segment
// Eg you can't have pages/[post]/abc.js and pages/[hello]/something-else.js
// Only 1 dynamic segment per nesting level

// So in the case that is test/integration/dynamic-routing it'll be this:
// pages/[post]/comments.js
// pages/blog/[post]/comment/[id].js
Expand All @@ -87,6 +142,7 @@ module.exports = function getSortedRoutes(normalizedPages) {
// And since your PR passed through `slugName` as an array basically it'd including it in too many possibilities
// Instead what has to be passed through is the upwards path's dynamic names
const root = new UrlNode();

// Here the `root` gets injected multiple paths, and insert will break them up into sublevels
normalizedPages.forEach(pagePath => root.insert(pagePath));
// Smoosh will then sort those sublevels up to the point where you get the correct route definition priority
Expand Down
1 change: 0 additions & 1 deletion packages/serverless-nextjs-component/serverless.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ class NextjsComponent extends Component {
Object.entries(pagesManifest).forEach(([route, pageFile]) => {
const dynamicRoute = isDynamicRoute(route);
const expressRoute = dynamicRoute ? expressifyDynamicRoute(route) : null;

if (isHtmlPage(pageFile)) {
if (dynamicRoute) {
htmlPages.dynamic[expressRoute] = {
Expand Down

0 comments on commit 33ab9cc

Please sign in to comment.