fix: allow users to set custom error pages (#179)
* fix: allow users to set custom error pages * ci: disable failOnStatusCode Fixes #168 #178
This commit is contained in:
Родитель
cd7821ba23
Коммит
39fbb9c085
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"viewportWidth": 1300,
|
||||
"viewportHeight": 900
|
||||
"viewportHeight": 900,
|
||||
"projectId": "jctfne"
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
{}
|
|
@ -0,0 +1 @@
|
|||
<h1>custom 401</h1>
|
|
@ -0,0 +1 @@
|
|||
<h1>custom 403</h1>
|
|
@ -0,0 +1 @@
|
|||
<h1>custom 404</h1>
|
|
@ -53,22 +53,33 @@
|
|||
{
|
||||
"route": "/logout",
|
||||
"rewrite": "/.auth/logout"
|
||||
},
|
||||
{
|
||||
"route": "/status-code-401.txt",
|
||||
"statusCode": "401"
|
||||
},
|
||||
{
|
||||
"route": "/status-code-403.txt",
|
||||
"statusCode": "403"
|
||||
},
|
||||
{
|
||||
"route": "/status-code-404.txt",
|
||||
"statusCode": "404"
|
||||
}
|
||||
],
|
||||
"navigationFallback": {
|
||||
"rewrite": "/index.html",
|
||||
"exclude": ["*.{txt}"]
|
||||
},
|
||||
"responseErrorOverrides": {
|
||||
"400": {
|
||||
"rewrite": "/custom_error.html",
|
||||
"statusCode": 201
|
||||
"responseOverrides": {
|
||||
"401": {
|
||||
"rewrite": "/custom-401.html"
|
||||
},
|
||||
"500": {
|
||||
"redirect": "/custom_error.html"
|
||||
"403": {
|
||||
"rewrite": "/custom-403.html"
|
||||
},
|
||||
"501": {
|
||||
"rewrite": "custom_error.html"
|
||||
"404": {
|
||||
"rewrite": "/custom-404.html"
|
||||
}
|
||||
},
|
||||
"mimeTypes": {
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/// <reference types="cypress" />
|
||||
|
||||
const testSuite = (code) => () => {
|
||||
it(`should respond with ${code} status code`, () => {
|
||||
cy.request({ url: `http://0.0.0.0:1234/status-code-${code}.txt`, failOnStatusCode: false }).then((response) => {
|
||||
expect(response.status).to.eq(code);
|
||||
});
|
||||
});
|
||||
|
||||
it(`should display custom ${code} page`, () => {
|
||||
cy.visit({ url: `http://0.0.0.0:1234/status-code-${code}.txt`, failOnStatusCode: false });
|
||||
cy.get("h1").should("contain.text", `custom ${code}`);
|
||||
});
|
||||
};
|
||||
|
||||
context("Error pages", () => {
|
||||
const ERROR_STATUS_CODES = [401, 403, 404];
|
||||
for (let index = 0; index < ERROR_STATUS_CODES.length; index++) {
|
||||
const code = ERROR_STATUS_CODES[index];
|
||||
describe(`Custom ${code} page`, testSuite(code));
|
||||
}
|
||||
});
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="cypress" />
|
||||
|
||||
context("route rules engine", { defaultCommandTimeout: 20000 /* set this for Windows */ }, () => {
|
||||
context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout: 20000 /* set this for Windows */ }, () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("http://0.0.0.0:1234");
|
||||
});
|
||||
|
|
|
@ -109,7 +109,7 @@ export async function start(startContext: string, options: SWACLIConfig) {
|
|||
SWA_CLI_DEBUG: options.verbose,
|
||||
SWA_CLI_API_PORT: `${apiPort}`,
|
||||
SWA_CLI_APP_LOCATION: userConfig?.appLocation as string,
|
||||
SWA_CLI_APP_ARTIFACT_LOCATION: userConfig?.outputLocation as string,
|
||||
SWA_CLI_OUTPUT_LOCATION: userConfig?.outputLocation as string,
|
||||
SWA_CLI_API_LOCATION: userConfig?.apiLocation as string,
|
||||
SWA_CLI_ROUTES_LOCATION: options.routesLocation,
|
||||
SWA_CLI_HOST: options.host,
|
||||
|
|
|
@ -16,4 +16,5 @@ export const DEFAULT_CONFIG: SWACLIConfig = {
|
|||
swaConfigFilenameLegacy: "routes.json",
|
||||
run: undefined,
|
||||
verbose: "log",
|
||||
customUrlScheme: "swa://",
|
||||
};
|
||||
|
|
|
@ -20,24 +20,26 @@ import { responseOverrides } from "./rules/responseOverrides";
|
|||
*/
|
||||
export async function applyRules(req: IncomingMessage, res: ServerResponse, userConfig: SWAConfigFile) {
|
||||
const userDefinedRoute = userConfig.routes?.find(matchRoute(req, userConfig.isLegacyConfigFile));
|
||||
const filepath = path.join(process.env.SWA_CLI_APP_ARTIFACT_LOCATION!, req.url!);
|
||||
const filepath = path.join(process.env.SWA_CLI_OUTPUT_LOCATION!, req.url!);
|
||||
const isFileFound = fs.existsSync(filepath);
|
||||
|
||||
logger.silly("checking rule...");
|
||||
logger.silly({
|
||||
appArticatLocation: process.env.SWA_CLI_APP_ARTIFACT_LOCATION,
|
||||
matchedRoute: userDefinedRoute,
|
||||
filepath,
|
||||
isFileFound,
|
||||
});
|
||||
logger.silly("checking rules...");
|
||||
|
||||
if (isFileFound === false && userConfig.navigationFallback) {
|
||||
await navigationFallback(req, res, userConfig.navigationFallback);
|
||||
}
|
||||
// note: these rules are mutating the req and res objects
|
||||
|
||||
await navigationFallback(req, res, userConfig.navigationFallback);
|
||||
|
||||
await globalHeaders(req, res, userConfig.globalHeaders);
|
||||
await mimeTypes(req, res, userConfig.mimeTypes);
|
||||
|
||||
await customRoutes(req, res, userDefinedRoute);
|
||||
await responseOverrides(req, res, userConfig.responseOverrides);
|
||||
|
||||
logger.silly({
|
||||
outputLocation: process.env.SWA_CLI_OUTPUT_LOCATION,
|
||||
matchedRoute: userDefinedRoute,
|
||||
filepath,
|
||||
isFileFound,
|
||||
navigationFallback: { statusCode: res.statusCode, url: req.url },
|
||||
});
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ describe("navigationFallback()", () => {
|
|||
rewrite: "/bar",
|
||||
exclude: [],
|
||||
};
|
||||
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
|
||||
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
|
||||
|
||||
await navigationFallback(req, res, userConfig);
|
||||
|
||||
|
@ -69,7 +69,7 @@ describe("navigationFallback()", () => {
|
|||
rewrite: "/bar",
|
||||
exclude: ["/images/*.{png,jpg,gif}"],
|
||||
};
|
||||
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
|
||||
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
|
||||
|
||||
mockFs({
|
||||
"/images/foo.png": "",
|
||||
|
@ -86,7 +86,7 @@ describe("navigationFallback()", () => {
|
|||
rewrite: "/bar",
|
||||
exclude: ["/images/*.{png,jpg,gif}"],
|
||||
};
|
||||
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
|
||||
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
|
||||
|
||||
mockFs({
|
||||
"/no-file": "",
|
||||
|
@ -103,7 +103,7 @@ describe("navigationFallback()", () => {
|
|||
rewrite: "/bar",
|
||||
exclude: ["/images/*.{gif}"],
|
||||
};
|
||||
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
|
||||
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
|
||||
|
||||
mockFs({
|
||||
"/images/foo.png": "",
|
||||
|
@ -120,7 +120,7 @@ describe("navigationFallback()", () => {
|
|||
rewrite: "/bar",
|
||||
exclude: ["/images/*.{gif}"],
|
||||
};
|
||||
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
|
||||
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
|
||||
|
||||
mockFs({
|
||||
"/no-file": "",
|
||||
|
@ -137,7 +137,7 @@ describe("navigationFallback()", () => {
|
|||
rewrite: "/bar",
|
||||
exclude: ["/*.{png}"],
|
||||
};
|
||||
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
|
||||
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
|
||||
|
||||
mockFs({
|
||||
"/no-file": "",
|
||||
|
@ -154,7 +154,7 @@ describe("navigationFallback()", () => {
|
|||
rewrite: "/bar",
|
||||
exclude: ["/images/*.{png}"],
|
||||
};
|
||||
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
|
||||
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
|
||||
|
||||
mockFs({
|
||||
"/no-file": "",
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import http from "http";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import globalyzer from "globalyzer";
|
||||
import globrex from "globrex";
|
||||
import http from "http";
|
||||
import path from "path";
|
||||
import { logger } from "../../../core";
|
||||
|
||||
// See: https://docs.microsoft.com/en-us/azure/static-web-apps/configuration#fallback-routes
|
||||
|
||||
export const navigationFallback = async (
|
||||
req: http.IncomingMessage,
|
||||
_res: http.ServerResponse,
|
||||
res: http.ServerResponse,
|
||||
navigationFallback: SWAConfigFileNavigationFallback
|
||||
) => {
|
||||
let originlUrl = req.url;
|
||||
|
@ -31,7 +32,7 @@ export const navigationFallback = async (
|
|||
// is the requested file available on disk?
|
||||
const filename = originlUrl?.endsWith("/") ? `${originlUrl}/index.html` : originlUrl;
|
||||
|
||||
const filepath = path.join(process.env.SWA_CLI_APP_ARTIFACT_LOCATION!, filename!);
|
||||
const filepath = path.join(process.env.SWA_CLI_OUTPUT_LOCATION!, filename!);
|
||||
|
||||
const isFileFoundOnDisk = fs.existsSync(filepath);
|
||||
|
||||
|
@ -39,50 +40,50 @@ export const navigationFallback = async (
|
|||
const isMatchedFilter = navigationFallback.exclude.some((filter) => {
|
||||
// we don't support full globs in the config file.
|
||||
// add this little workaround to convert a wildcard into a valid glob pattern
|
||||
filter = filter.replace("/*", "/**/*");
|
||||
filter = filter.replace("*", "**/*");
|
||||
|
||||
// extract glob metadata
|
||||
const globSegments = globalyzer(filter);
|
||||
|
||||
if (originlUrl?.startsWith(globSegments.base)) {
|
||||
const { regex } = globrex(globSegments.glob, { globstar: true, extended: true });
|
||||
const { regex } = globrex(globSegments.glob, { globstar: true, extended: true, filepath: true });
|
||||
|
||||
// extract the last segment (what comes after the base) from the URL:
|
||||
// / => <empty string>
|
||||
// /bar.gif => bar.gif
|
||||
// /images/foo/bar.gif => bar.gif
|
||||
let lastSegmentFromUrl = originlUrl.replace(`${globSegments.base}`, "");
|
||||
// globrex generates regex that doesn't match leading forwardslash, so we remove it
|
||||
// before processing the regex
|
||||
let originlUrlWithoutLeadingSlash = originlUrl?.replace(/^\//, "");
|
||||
|
||||
// globrex generates regex that doesn't match leading forwardslash, so we remove it
|
||||
// before processing the regex
|
||||
lastSegmentFromUrl = lastSegmentFromUrl.replace(/^\//, "");
|
||||
|
||||
return regex.test(lastSegmentFromUrl!);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return regex.test(originlUrlWithoutLeadingSlash!);
|
||||
});
|
||||
|
||||
// rules logic:
|
||||
// if no exclude rules are provided, rewrite by default
|
||||
// if a file exists on disk, and match exclusion => return it
|
||||
// if a file doesn't exist on disk, and match exclusion => 404
|
||||
// if a file exists on disk, and doesn't match exclusion => /index.html
|
||||
// if a file doesn't exist on disk, and doesn't match exclusion => /index.html
|
||||
// 1. if no exclude rules are provided, rewrite by default
|
||||
// 2. if a file exists on disk, and match exclusion => return it
|
||||
// 3. if a file doesn't exist on disk, and match exclusion => 404
|
||||
// 4. if a file exists on disk, and doesn't match exclusion => /index.html
|
||||
// 5. if a file doesn't exist on disk, and doesn't match exclusion => /index.html
|
||||
|
||||
// note: given the complexity of all possible combinations, don't refactor the code below
|
||||
let newUrl = req.url;
|
||||
// 1.
|
||||
if (!navigationFallback.exclude || navigationFallback.exclude.length === 0) {
|
||||
newUrl = navigationFallback.rewrite;
|
||||
} else if (isFileFoundOnDisk === true && isMatchedFilter === true) {
|
||||
}
|
||||
// 2.
|
||||
else if (isFileFoundOnDisk === true && isMatchedFilter === true) {
|
||||
newUrl = req.url;
|
||||
} else if (isFileFoundOnDisk === false && isMatchedFilter === true) {
|
||||
newUrl = req.url;
|
||||
} else if (isFileFoundOnDisk === true && isMatchedFilter === false) {
|
||||
}
|
||||
// 3.
|
||||
else if (isFileFoundOnDisk === false && isMatchedFilter === true) {
|
||||
res.statusCode = 404;
|
||||
}
|
||||
// 4.
|
||||
else if (isFileFoundOnDisk === true && isMatchedFilter === false) {
|
||||
newUrl = navigationFallback.rewrite;
|
||||
} else if (isFileFoundOnDisk === false && isMatchedFilter === false) {
|
||||
}
|
||||
// 5.
|
||||
else if (isFileFoundOnDisk === false && isMatchedFilter === false) {
|
||||
newUrl = navigationFallback.rewrite;
|
||||
}
|
||||
|
||||
logger.silly({ filepath, isMatchedFilter });
|
||||
req.url = newUrl;
|
||||
};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { DEFAULT_CONFIG } from "../../../config";
|
||||
import { responseOverrides } from "./responseOverrides";
|
||||
|
||||
describe("responseOverrides()", () => {
|
||||
|
@ -52,7 +53,7 @@ describe("responseOverrides()", () => {
|
|||
};
|
||||
await responseOverrides(req, res, userConfig);
|
||||
|
||||
expect(req.url).toBe("/foo");
|
||||
expect(req.url).toBe(`${DEFAULT_CONFIG.customUrlScheme}/foo`);
|
||||
});
|
||||
|
||||
it("should override redirect rule", async () => {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import http from "http";
|
||||
import { DEFAULT_CONFIG } from "../../../config";
|
||||
|
||||
// See: https://docs.microsoft.com/en-us/azure/static-web-apps/configuration#response-overrides
|
||||
export const responseOverrides = async (req: http.IncomingMessage, res: http.ServerResponse, responseOverrides: SWAConfigFileResponseOverrides) => {
|
||||
|
@ -15,7 +16,7 @@ export const responseOverrides = async (req: http.IncomingMessage, res: http.Ser
|
|||
res.setHeader("Location", overridenStatusCode.redirect);
|
||||
}
|
||||
if (overridenStatusCode.rewrite && req.url !== overridenStatusCode.rewrite) {
|
||||
req.url = overridenStatusCode.rewrite;
|
||||
req.url = `${DEFAULT_CONFIG.customUrlScheme}${overridenStatusCode.rewrite}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ const SWA_CLI_PORT = parseInt((process.env.SWA_CLI_PORT || DEFAULT_CONFIG.port)
|
|||
const SWA_CLI_API_URI = address(SWA_CLI_HOST, process.env.SWA_CLI_API_PORT);
|
||||
const SWA_CLI_APP_LOCATION = (process.env.SWA_CLI_APP_LOCATION || DEFAULT_CONFIG.appLocation) as string;
|
||||
const SWA_CLI_ROUTES_LOCATION = (process.env.SWA_CLI_ROUTES_LOCATION || DEFAULT_CONFIG.routesLocation) as string;
|
||||
const SWA_CLI_APP_ARTIFACT_LOCATION = (process.env.SWA_CLI_APP_ARTIFACT_LOCATION || DEFAULT_CONFIG.outputLocation) as string;
|
||||
const SWA_CLI_OUTPUT_LOCATION = (process.env.SWA_CLI_OUTPUT_LOCATION || DEFAULT_CONFIG.outputLocation) as string;
|
||||
const SWA_CLI_API_LOCATION = (process.env.SWA_CLI_API_LOCATION || DEFAULT_CONFIG.apiLocation) as string;
|
||||
const SWA_CLI_APP_SSL = process.env.SWA_CLI_APP_SSL === "true" || DEFAULT_CONFIG.ssl === true;
|
||||
const SWA_CLI_APP_SSL_KEY = process.env.SWA_CLI_APP_SSL_KEY as string;
|
||||
|
@ -29,7 +29,7 @@ const PROTOCOL = SWA_CLI_APP_SSL ? `https` : `http`;
|
|||
|
||||
const proxyApi = httpProxy.createProxyServer({ autoRewrite: true });
|
||||
const proxyApp = httpProxy.createProxyServer({ autoRewrite: true });
|
||||
const isStaticDevServer = isHttpUrl(SWA_CLI_APP_ARTIFACT_LOCATION);
|
||||
const isStaticDevServer = isHttpUrl(SWA_CLI_OUTPUT_LOCATION);
|
||||
const isApiDevServer = isHttpUrl(SWA_CLI_API_LOCATION);
|
||||
|
||||
if (!isHttpUrl(SWA_CLI_API_URI)) {
|
||||
|
@ -142,21 +142,28 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
|
|||
await applyRules(req, res, userConfig);
|
||||
|
||||
if ([401, 403, 404].includes(res.statusCode)) {
|
||||
logRequest(req, null, res.statusCode);
|
||||
const isCustomUrl = req.url.startsWith(DEFAULT_CONFIG.customUrlScheme!);
|
||||
|
||||
switch (res.statusCode) {
|
||||
case 401:
|
||||
req.url = "unauthorized.html";
|
||||
break;
|
||||
case 403:
|
||||
// @TODO provide a Forbidden HTML template
|
||||
req.url = "unauthorized.html";
|
||||
break;
|
||||
case 404:
|
||||
req.url = "404.html";
|
||||
break;
|
||||
if (isCustomUrl) {
|
||||
// extract user custom url
|
||||
req.url = req.url.replace(`${DEFAULT_CONFIG.customUrlScheme}`, "");
|
||||
} else {
|
||||
switch (res.statusCode) {
|
||||
case 401:
|
||||
req.url = "unauthorized.html";
|
||||
break;
|
||||
case 403:
|
||||
// @TODO provide a Forbidden HTML template
|
||||
req.url = "unauthorized.html";
|
||||
break;
|
||||
case 404:
|
||||
req.url = "404.html";
|
||||
break;
|
||||
}
|
||||
return serve(SWA_PUBLIC_DIR, req, res);
|
||||
}
|
||||
return serve(SWA_PUBLIC_DIR, req, res);
|
||||
|
||||
logRequest(req, null, res.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -204,7 +211,7 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
|
|||
|
||||
// proxy APP requests
|
||||
else {
|
||||
const target = SWA_CLI_APP_ARTIFACT_LOCATION;
|
||||
const target = SWA_CLI_OUTPUT_LOCATION;
|
||||
|
||||
// is this a dev server?
|
||||
if (isStaticDevServer) {
|
||||
|
@ -237,7 +244,7 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
|
|||
|
||||
const onWsUpgrade = function (req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
|
||||
socketConnection = socket;
|
||||
const target = SWA_CLI_APP_ARTIFACT_LOCATION;
|
||||
const target = SWA_CLI_OUTPUT_LOCATION;
|
||||
if (isStaticDevServer) {
|
||||
logger.log(chalk.green("** WebSocket connection established **"));
|
||||
|
||||
|
@ -259,13 +266,13 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
|
|||
// prettier-ignore
|
||||
logger.log(
|
||||
`\nUsing dev server for static content:\n`+
|
||||
` ${chalk.green(SWA_CLI_APP_ARTIFACT_LOCATION)}`
|
||||
` ${chalk.green(SWA_CLI_OUTPUT_LOCATION)}`
|
||||
);
|
||||
} else {
|
||||
// prettier-ignore
|
||||
logger.log(
|
||||
`\nServing static content:\n` +
|
||||
` ${chalk.green(SWA_CLI_APP_ARTIFACT_LOCATION)}`
|
||||
` ${chalk.green(SWA_CLI_OUTPUT_LOCATION)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ declare global {
|
|||
SWA_CLI_DEBUG: DebugFilterLevel;
|
||||
SWA_CLI_API_URI: string;
|
||||
SWA_CLI_APP_URI: string;
|
||||
SWA_CLI_APP_ARTIFACT_LOCATION: string;
|
||||
SWA_CLI_OUTPUT_LOCATION: string;
|
||||
SWA_CLI_ROUTES_LOCATION: String;
|
||||
SWA_CLI_HOST: string;
|
||||
SWA_CLI_PORT: string;
|
||||
|
@ -73,6 +73,7 @@ declare type SWACLIConfig = GithubActionWorkflow & {
|
|||
verbose?: string;
|
||||
run?: string;
|
||||
routesLocation?: string;
|
||||
customUrlScheme?: string;
|
||||
};
|
||||
|
||||
declare type ResponseOptions = {
|
||||
|
|
Загрузка…
Ссылка в новой задаче