From 33356f0525d79702f9252654b62c3ce97cbb94a6 Mon Sep 17 00:00:00 2001 From: Iain Lane Date: Mon, 27 May 2024 09:18:06 +0000 Subject: [PATCH] static: Fix last-modified headers, move index handler to static `serverless-esbuild`, which we use to build the project, doesn't preserve file modification times. We make use of these times to generate the `last-modified` headers for static files, which we bundle in the Lambda `.zip`s. I [submitted a PR upstream][pr] to fix this. Switch to using a fork containing this fix. The `index` handler was implementing very similar logic to the `static` handler, but slightly differently. We move the `index` handler to the `static` module and make it use the same `fileData` as the `static` handler. This allows us to remove the `index` handler entirely. Add tests for both handlers. This uses a test file stored in the repo. But note that `git` doesn't preserve file modification times, so we have to work these out dynamically in the tests. Fix a nit too: handlers don't have to be async. They can simply return their result directly, no need to wrap it in a `Promise.resolve`. [pr]: https://github.com/floydspace/serverless-esbuild/pull/539 --- app/src/handlers/static/fileinfo.test.ts | 27 +++ app/src/handlers/static/fileinfo.ts | 59 ++++++ app/src/handlers/static/index.ts | 130 +------------ app/src/handlers/static/indexhandler.test.ts | 137 ++++++++++++++ .../index.ts => static/indexhandler.ts} | 44 ++--- app/src/handlers/static/static.test.ts | 173 ++++++++++++++++++ app/src/handlers/static/static.ts | 100 ++++++++++ app/src/lib/util/index.ts | 1 + app/src/lib/util/str.test.ts | 19 ++ app/src/lib/util/str.ts | 19 ++ app/src/testdata/test.txt | 1 + package.json | 2 +- serverless.yml | 2 +- yarn.lock | 10 +- 14 files changed, 570 insertions(+), 154 deletions(-) create mode 100644 app/src/handlers/static/fileinfo.test.ts create mode 100644 app/src/handlers/static/fileinfo.ts create mode 100644 app/src/handlers/static/indexhandler.test.ts rename app/src/handlers/{index/index.ts => static/indexhandler.ts} (61%) create mode 100644 app/src/handlers/static/static.test.ts create mode 100644 app/src/handlers/static/static.ts create mode 100644 app/src/lib/util/str.test.ts create mode 100644 app/src/lib/util/str.ts create mode 100644 app/src/testdata/test.txt diff --git a/app/src/handlers/static/fileinfo.test.ts b/app/src/handlers/static/fileinfo.test.ts new file mode 100644 index 00000000..25e27593 --- /dev/null +++ b/app/src/handlers/static/fileinfo.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "@jest/globals"; +import { createHash } from "crypto"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { precomputeFileData } from "./fileinfo"; + +describe("precomputeFileData", () => { + it("returns correct file data", async () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const staticFilesDir = path.join(__dirname, "..", "..", "testdata"); + const fileData = await precomputeFileData(staticFilesDir); + + const text = "test\n"; + const sha256 = createHash("sha256").update(text).digest("hex"); + + expect(fileData).toEqual({ + "test.txt": { + buffer: Buffer.from(text), + contentType: "text/plain; charset=utf-8", + etag: `"${sha256}"`, + lastModified: expect.anything(), + size: 5, + }, + }); + }); +}); diff --git a/app/src/handlers/static/fileinfo.ts b/app/src/handlers/static/fileinfo.ts new file mode 100644 index 00000000..865665f6 --- /dev/null +++ b/app/src/handlers/static/fileinfo.ts @@ -0,0 +1,59 @@ +import { createHash } from "crypto"; +import { readFile, readdir, stat } from "fs/promises"; +import { contentType } from "mime-types"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +export interface staticFileInfo { + buffer: Buffer; + etag: string; + lastModified: Date; + contentType: string; + size: number; +} + +/** + * Precompute file data for a directory of static files. This is useful to avoid + * reading files on every request. + * + * @param dir Directory to read files from + * @returns Object containing file data + */ +export async function precomputeFileData(dir: string) { + const fileInfo: { [path: string]: staticFileInfo } = {}; + + const files = await readdir(dir, { withFileTypes: true }); + + await Promise.all( + files.map(async (dirent) => { + const dir = dirent.parentPath; + const filePath = path.join(dir, dirent.name); + const fileBuffer = await readFile(filePath); + const hashSum = createHash("sha256").update(fileBuffer).digest("hex"); + + const stats = await stat(filePath); + const lastModified = stats.mtime; + // HTTP timestamps can't have milliseconds + lastModified.setMilliseconds(0); + + const fileContentType = + contentType(path.extname(dirent.name)) || "application/octet-stream"; + + fileInfo[dirent.name] = { + buffer: fileBuffer, + contentType: fileContentType, + etag: `"${hashSum}"`, + lastModified: lastModified, + size: stats.size, + }; + }), + ); + + return fileInfo; +} + +// Read all files from `/static` and precompute their metadata. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const staticFilesDir = path.resolve(__dirname, "../../../static"); + +export const staticFileData = await precomputeFileData(staticFilesDir); diff --git a/app/src/handlers/static/index.ts b/app/src/handlers/static/index.ts index cd0191e3..9799bfb0 100644 --- a/app/src/handlers/static/index.ts +++ b/app/src/handlers/static/index.ts @@ -1,128 +1,6 @@ -import type { - APIGatewayProxyEventV2, - APIGatewayProxyResultV2, -} from "aws-lambda"; -import { createHash } from "crypto"; -import { readFile, readdir, stat } from "fs/promises"; -import { StatusCodes } from "http-status-codes"; -import { contentType } from "mime-types"; -import * as path from "path"; -import { fileURLToPath } from "url"; +import { staticFileData } from "./fileinfo"; +import { staticHandlerFactory } from "./static"; -import { handlerFactory } from "@/lib/handler-factory"; -import { LoggerContext } from "@/lib/logger"; +export const staticHandler = staticHandlerFactory(staticFileData); -const { NOT_FOUND, NOT_MODIFIED, OK } = StatusCodes; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const staticFilesDir = path.resolve(__dirname, "../../../static"); - -// Read all files from `/static` and precompute their metadata. -const fileData = await (async () => { - const fileInfo: { - [key: string]: { - buffer: Buffer; - etag: string; - lastModified: Date; - contentType: string; - size: number; - }; - } = {}; - - const files = await readdir(staticFilesDir); - - await Promise.all( - files.map(async (file) => { - const filePath = path.join(staticFilesDir, file); - const fileBuffer = await readFile(filePath); - const hashSum = createHash("sha256"); - hashSum.update(fileBuffer); - const hex = hashSum.digest("hex"); - - const stats = await stat(filePath); - const lastModified = stats.mtime; - - const fileContentType = - contentType(path.extname(file)) || "application/octet-stream"; - - fileInfo[file] = { - buffer: fileBuffer, - contentType: fileContentType, - etag: hex, - lastModified: lastModified, - size: stats.size, - }; - }), - ); - - return fileInfo; -})(); - -const notFoundFiles = new Set(["favicon.ico"]); - -function staticFileHandler( - event: APIGatewayProxyEventV2, - { logger }: LoggerContext, -): Promise { - const p = path.relative("/", event.requestContext.http.path); - const fd = fileData[p]; - - const log = logger.createChild({ - persistentLogAttributes: { - handler: "static", - path: p, - }, - }); - - if (!fd) { - return Promise.resolve({ - statusCode: NOT_FOUND, - body: `File ${p} was not found.\n`, - ...(notFoundFiles.has(p) - ? { - headers: { - "cache-control": "public, max-age=3600", - }, - } - : {}), - }); - } - - const { buffer, contentType, etag, lastModified, size } = fd; - - const ifNoneMatch = event.headers["If-None-Match"]; - const ifModifiedSince = event.headers["If-Modified-Since"]; - - const cacheHeaders = { - "cache-control": "public, s-maxage=60", - ETag: `"${etag}"`, - "last-modified": lastModified.toUTCString(), - }; - - if ( - ifNoneMatch === etag || - (ifModifiedSince && new Date(ifModifiedSince) >= lastModified) - ) { - log.debug("Not modified, sending 304"); - - return Promise.resolve({ - statusCode: NOT_MODIFIED, - headers: cacheHeaders, - }); - } - - log.debug("Sending file"); - - return Promise.resolve({ - statusCode: OK, - headers: { - "content-length": size, - "content-type": contentType, - ...cacheHeaders, - }, - body: buffer.toString("base64"), - isBase64Encoded: true, - }); -} - -export const staticHandler = handlerFactory(staticFileHandler); +export { indexHandler } from "./indexhandler"; diff --git a/app/src/handlers/static/indexhandler.test.ts b/app/src/handlers/static/indexhandler.test.ts new file mode 100644 index 00000000..a3274f9f --- /dev/null +++ b/app/src/handlers/static/indexhandler.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "@jest/globals"; +import { Event } from "@middy/http-header-normalizer"; +import { APIGatewayProxyEventV2 } from "aws-lambda"; +import { StatusCodes } from "http-status-codes"; +import { mock } from "jest-mock-extended"; + +import { GeoCodeContext } from "@/lib/geocode"; +import { GeoLocateContext } from "@/lib/geolocate"; +import { LoggerContext } from "@/lib/logger"; + +import { indexHandler } from "."; + +const mockContext = mock(); + +describe("index (/) handler", () => { + it.each<{ + description: string; + acceptHeader: string; + path: string; + queryString?: string; + expectedStatus: StatusCodes; + expectedHeaders?: { [key: string]: string }; + }>([ + { + description: "redirects to /:unknown if client doesn't accept HTML", + acceptHeader: "text/plain", + path: "/", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + expectedHeaders: { + "cache-control": "public, max-age=3600", + location: "/:unknown", + }, + }, + { + description: "preserves query string when redirecting", + acceptHeader: "text/plain", + path: "/", + queryString: "foo=bar", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + expectedHeaders: { + "cache-control": "public, max-age=3600", + location: "/:unknown?foo=bar", + }, + }, + { + description: "redirects when accept header is */*", + acceptHeader: "*/*", + path: "/", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + }, + { + description: "returns index.html if client explicitly accepts HTML", + acceptHeader: "text/html", + path: "/", + expectedStatus: StatusCodes.OK, + expectedHeaders: { + "cache-control": "public, s-maxage=60", + }, + }, + { + description: "preserves the prefix when redirecting", + acceptHeader: "text/plain", + path: "/foo", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + expectedHeaders: { + location: "/foo/:unknown", + }, + }, + { + description: "preserves the prefix when redirecting, with query string", + acceptHeader: "text/plain", + path: "/foo", + queryString: "bar=baz", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + expectedHeaders: { + location: "/foo/:unknown?bar=baz", + }, + }, + { + description: "preserves the prefix when redirecting (trailing slash)", + acceptHeader: "text/plain", + path: "/foo/", + queryString: "bar=baz", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + expectedHeaders: { + location: "/foo/:unknown?bar=baz", + }, + }, + { + description: "handles a complex accept header", + acceptHeader: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + path: "/", + expectedStatus: StatusCodes.OK, + }, + { + description: "redirects if the client prefers text/plain over text/html", + acceptHeader: "text/plain, text/html", + path: "/", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + }, + { + description: + "redirects if the client prefers text/plain over text/html, using quality values", + acceptHeader: "text/html;q=0.5, text/plain;q=0.8", + path: "/", + expectedStatus: StatusCodes.TEMPORARY_REDIRECT, + }, + ])( + "$description", + async ({ + acceptHeader, + path, + queryString, + expectedStatus, + expectedHeaders, + }) => { + const event = mock({ + rawPath: path, + rawQueryString: queryString ?? "", + headers: { + accept: acceptHeader, + }, + }); + + await expect(indexHandler(event, mockContext)).resolves.toEqual( + expect.objectContaining({ + headers: expect.objectContaining(expectedHeaders ?? {}), + ...(expectedStatus === StatusCodes.OK && { + body: expect.anything(), + }), + statusCode: expectedStatus, + }), + ); + }, + ); +}); diff --git a/app/src/handlers/index/index.ts b/app/src/handlers/static/indexhandler.ts similarity index 61% rename from app/src/handlers/index/index.ts rename to app/src/handlers/static/indexhandler.ts index 2cd433e6..0a81bdf7 100644 --- a/app/src/handlers/index/index.ts +++ b/app/src/handlers/static/indexhandler.ts @@ -5,53 +5,55 @@ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, } from "aws-lambda"; -import * as fs from "fs"; import { StatusCodes } from "http-status-codes"; import Negotiator from "negotiator"; -import * as path from "path"; -import { fileURLToPath } from "url"; import { cacheControlMiddleware } from "@/lib/cachecontrol"; -import { loggerMiddleware } from "@/lib/logger"; +import { LoggerContext, loggerMiddleware } from "@/lib/logger"; -// Read HTML content from file during the cold start of the Lambda function -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const htmlFilePath = path.resolve(__dirname, "../../../static/index.html"); -const htmlContent = fs.readFileSync(htmlFilePath, "utf8"); +import { staticFileData } from "./fileinfo"; +import { sendFileInfo } from "./static"; -const { OK } = StatusCodes; +const { TEMPORARY_REDIRECT } = StatusCodes; + +const indexHtml = + staticFileData["index.html"] ?? + (() => { + throw new Error("index.html not found"); + })(); function handler( event: APIGatewayProxyEventV2, -): Promise { + { logger }: LoggerContext, +): APIGatewayProxyResultV2 { const negotiator = new Negotiator(event); - const type = negotiator.mediaType(["text/html"]); + const type = negotiator.mediaType(["text/html", "text/plain"]); const mostPreferredType = negotiator.mediaType(); // Check if the client accepts HTML. But not */*, as this would mean we return // HTML all of the time. So we only return HTML if the client explicitly wants // it. if (type === "text/html" && mostPreferredType !== "*/*") { - return Promise.resolve({ - statusCode: OK, - headers: { - "Content-Type": "text/html; charset=utf-8", + const log = logger.createChild({ + persistentLogAttributes: { + handler: "index", }, - body: htmlContent, }); + + return sendFileInfo(log, indexHtml, event); } // Redirect to `:unknown` (relative to the current page) if the client doesn't // accept HTML const rawPath = event.rawPath + (event.rawPath.endsWith("/") ? "" : "/"); const queryString = event.rawQueryString ? `?${event.rawQueryString}` : ""; - return Promise.resolve({ - statusCode: 302, + + return { + statusCode: TEMPORARY_REDIRECT, headers: { - Location: `${rawPath}:unknown${queryString}`, + location: `${rawPath}:unknown${queryString}`, }, - body: "", - }); + }; } export const indexHandler = middy< diff --git a/app/src/handlers/static/static.test.ts b/app/src/handlers/static/static.test.ts new file mode 100644 index 00000000..2ad08568 --- /dev/null +++ b/app/src/handlers/static/static.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "@jest/globals"; +import { Event } from "@middy/http-header-normalizer"; +import { APIGatewayProxyEventV2 } from "aws-lambda"; +import { StatusCodes } from "http-status-codes"; +import { mock } from "jest-mock-extended"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { GeoCodeContext } from "@/lib/geocode"; +import { GeoLocateContext } from "@/lib/geolocate"; +import { LoggerContext } from "@/lib/logger"; + +import { precomputeFileData } from "./fileinfo"; +import { staticHandlerFactory } from "./static"; + +//#region Test data +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const staticFilesDir = path.join(__dirname, "..", "..", "testdata"); +const fileData = await precomputeFileData(staticFilesDir); + +//#region file modification times +// `git` doesn't store file modification times, so we need to set them +// dynamically. + +// We know that `test.txt` will be in `fileData`. +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const testTxtMtime = fileData["test.txt"]!.lastModified; + +const oneYearAgo = new Date(testTxtMtime); +oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + +const oneYearFromNow = new Date(testTxtMtime); +oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); +//#endregion + +const correctEtag = `"f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2"`; +const base64content = Buffer.from("test\n").toString("base64"); +//#endregion + +const handler = staticHandlerFactory(fileData); + +const mockContext = mock(); + +describe("static file handler", () => { + it("returns 404 for unknown files", async () => { + const event = mock({ + requestContext: { + http: { + path: "/unknown", + }, + }, + }); + + await expect(handler(event, mockContext)).resolves.toEqual( + expect.objectContaining({ + statusCode: StatusCodes.NOT_FOUND, + }), + ); + }); + + it("should cache the 404 for /favicon.ico", async () => { + const event = mock({ + requestContext: { + http: { + path: "/favicon.ico", + }, + }, + }); + + await expect(handler(event, mockContext)).resolves.toEqual( + expect.objectContaining({ + statusCode: StatusCodes.NOT_FOUND, + headers: { + "cache-control": expect.stringContaining("public"), + }, + }), + ); + }); + + it.each<{ + description: string; + ifModifiedSince?: string; + ifNoneMatch?: string; + expectedStatus: StatusCodes; + expectedHeaders?: { [key: string]: string }; + }>([ + { + description: "returns OK if no cache headers are provided", + expectedStatus: StatusCodes.OK, + expectedHeaders: { + "cache-control": "public, s-maxage=60", + etag: correctEtag, + "last-modified": testTxtMtime.toUTCString(), + }, + }, + { + description: "returns NOT_MODIFIED if etag matches", + ifNoneMatch: correctEtag, + expectedStatus: StatusCodes.NOT_MODIFIED, + }, + { + description: + "returns NOT_MODIFIED if if-modified-since is after last-modified", + ifModifiedSince: oneYearFromNow.toUTCString(), + expectedStatus: StatusCodes.NOT_MODIFIED, + }, + { + description: "returns OK if if-modified-since is before last-modified", + ifModifiedSince: oneYearAgo.toUTCString(), + expectedStatus: StatusCodes.OK, + }, + { + description: "if-none-match takes precedence over if-modified-since", + ifNoneMatch: "foo", + ifModifiedSince: testTxtMtime.toUTCString(), + expectedStatus: StatusCodes.OK, + }, + { + description: "returns OK if if-modified-since header is invalid", + ifModifiedSince: "invalid-date", + expectedStatus: StatusCodes.OK, + }, + { + description: + "returns NOT_MODIFIED if if-none-match matches, even if if-modified-since is invalid", + ifNoneMatch: correctEtag, + ifModifiedSince: "invalid-date", + expectedStatus: StatusCodes.NOT_MODIFIED, + }, + { + description: "returns NOT_MODIFIED if if-none-match is a weak match", + ifNoneMatch: `W/${correctEtag}`, + expectedStatus: StatusCodes.NOT_MODIFIED, + }, + { + description: + "returns OK if neither if-none-match nor if-modified-since condition is met", + ifNoneMatch: "non-matching-etag", + ifModifiedSince: oneYearAgo.toUTCString(), + expectedStatus: StatusCodes.OK, + }, + ])( + "$description", + async ({ + ifModifiedSince, + ifNoneMatch, + expectedStatus, + expectedHeaders, + }) => { + const event = mock({ + requestContext: { + http: { + path: "/test.txt", + }, + }, + headers: { + ...(ifModifiedSince && { "if-modified-since": ifModifiedSince }), + ...(ifNoneMatch && { "if-none-match": ifNoneMatch }), + }, + }); + + await expect(handler(event, mockContext)).resolves.toEqual( + expect.objectContaining({ + headers: expect.objectContaining(expectedHeaders ?? {}), + ...(expectedStatus === StatusCodes.OK && { + body: base64content, + }), + statusCode: expectedStatus, + }), + ); + }, + ); +}); diff --git a/app/src/handlers/static/static.ts b/app/src/handlers/static/static.ts new file mode 100644 index 00000000..cf3ec09d --- /dev/null +++ b/app/src/handlers/static/static.ts @@ -0,0 +1,100 @@ +import type { + APIGatewayProxyEventV2, + APIGatewayProxyResultV2, +} from "aws-lambda"; +import { StatusCodes } from "http-status-codes"; +import * as path from "path"; + +import { removePrefix } from "@/lib/util"; +import { handlerFactory } from "@/lib/handler-factory"; +import { Logger, LoggerContext } from "@/lib/logger"; + +import { staticFileInfo } from "./fileinfo"; + +const { NOT_FOUND, NOT_MODIFIED, OK } = StatusCodes; + +// Some files will never be found, so we can cache that information. +const notFoundCache = new Set(["favicon.ico"]); + +export function sendFileInfo( + log: Logger, + fileInfo: staticFileInfo, + { headers }: APIGatewayProxyEventV2, +): APIGatewayProxyResultV2 { + const { buffer, contentType, etag, lastModified, size } = fileInfo; + + const ifNoneMatch = removePrefix("W/", headers["if-none-match"]); + const ifModifiedSince = headers["if-modified-since"]; + + const cacheHeaders = { + etag, + "cache-control": "public, s-maxage=60", + "last-modified": lastModified.toUTCString(), + }; + + if ( + ifNoneMatch === etag || + (!ifNoneMatch && + ifModifiedSince && + new Date(ifModifiedSince) >= lastModified) + ) { + log.debug("Not modified, sending 304"); + + return { + statusCode: NOT_MODIFIED, + headers: cacheHeaders, + }; + } + + log.debug("Sending file"); + + return { + statusCode: OK, + headers: { + "content-length": size, + "content-type": contentType, + ...cacheHeaders, + }, + body: buffer.toString("base64"), + isBase64Encoded: true, + }; +} + +function staticFileHandler( + fileData: { [path: string]: staticFileInfo }, + event: APIGatewayProxyEventV2, + { logger }: LoggerContext, +): APIGatewayProxyResultV2 { + const p = path.relative("/", event.requestContext.http.path); + const fileInfo = fileData[p]; + + const log = logger.createChild({ + persistentLogAttributes: { + handler: "static", + path: p, + }, + }); + + if (!fileInfo) { + return { + statusCode: NOT_FOUND, + body: `File ${p} was not found.\n`, + ...(notFoundCache.has(p) && { + headers: { + "cache-control": "public, max-age=3600", + }, + }), + }; + } + + return sendFileInfo(log, fileInfo, event); +} + +export function staticHandlerFactory(fileInfo: { + [path: string]: staticFileInfo; +}) { + return handlerFactory( + (event: APIGatewayProxyEventV2, context: LoggerContext) => + staticFileHandler(fileInfo, event, context), + ); +} diff --git a/app/src/lib/util/index.ts b/app/src/lib/util/index.ts index 84a30b97..e5e4316b 100644 --- a/app/src/lib/util/index.ts +++ b/app/src/lib/util/index.ts @@ -1 +1,2 @@ export * from "./maths"; +export * from "./str"; diff --git a/app/src/lib/util/str.test.ts b/app/src/lib/util/str.test.ts new file mode 100644 index 00000000..75068202 --- /dev/null +++ b/app/src/lib/util/str.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "@jest/globals"; + +import { removePrefix } from "./str"; + +describe("string utils", () => { + it.each<{ + prefix: string; + str: string | undefined; + expected: string | undefined; + }>([ + { prefix: "", str: "hello", expected: "hello" }, + { prefix: "", str: "world", expected: "world" }, + { prefix: "hello", str: "hello world", expected: " world" }, + { prefix: "world", str: "hello world", expected: "hello world" }, + { prefix: "hello", str: undefined, expected: undefined }, + ])("remove '$prefix' from '$str'", ({ prefix, str, expected }) => { + expect(removePrefix(prefix, str)).toEqual(expected); + }); +}); diff --git a/app/src/lib/util/str.ts b/app/src/lib/util/str.ts new file mode 100644 index 00000000..3aa86185 --- /dev/null +++ b/app/src/lib/util/str.ts @@ -0,0 +1,19 @@ +/** + * Removes the prefix from the string. If the string does not start with the + * prefix, it is returned as is. If the string is undefined, undefined is + * returned. + * + * @param prefix The prefix to remove + * @param str The string to remove the prefix from, or undefined + * @returns `str` with `prefix` removed, or undefined if `str` is undefined + */ +export function removePrefix( + prefix: string, + str: string | undefined, +): string | undefined { + if (!str) { + return undefined; + } + + return str.startsWith(prefix) ? str.slice(prefix.length) : str; +} diff --git a/app/src/testdata/test.txt b/app/src/testdata/test.txt new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/app/src/testdata/test.txt @@ -0,0 +1 @@ +test diff --git a/package.json b/package.json index 9562b496..bf869a63 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "serverless-certificate-creator": "^1.6.0", "serverless-domain-manager": "^7.3.8", "serverless-dynamodb": "^0.2.53", - "serverless-esbuild": "^1.52.1", + "serverless-esbuild": "https://github.com/iainlane/serverless-esbuild#iainlane/zip-use-mtime-from-filesystem-built", "serverless-offline": "^13.6.0", "serverless-plugin-typescript": "^2.1.5", "typescript": "^5.4.5", diff --git a/serverless.yml b/serverless.yml index 37b22430..64af166d 100644 --- a/serverless.yml +++ b/serverless.yml @@ -140,7 +140,7 @@ functions: method: GET index: - handler: ./app/src/handlers/index/index.indexHandler + handler: ./app/src/handlers/static/index.indexHandler events: - httpApi: path: / diff --git a/yarn.lock b/yarn.lock index ab1dd703..7257802e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11577,7 +11577,7 @@ __metadata: serverless-certificate-creator: "npm:^1.6.0" serverless-domain-manager: "npm:^7.3.8" serverless-dynamodb: "npm:^0.2.53" - serverless-esbuild: "npm:^1.52.1" + serverless-esbuild: "https://github.com/iainlane/serverless-esbuild#iainlane/zip-use-mtime-from-filesystem-built" serverless-offline: "npm:^13.6.0" serverless-plugin-typescript: "npm:^2.1.5" typescript: "npm:^5.4.5" @@ -11755,9 +11755,9 @@ __metadata: languageName: node linkType: hard -"serverless-esbuild@npm:^1.52.1": - version: 1.52.1 - resolution: "serverless-esbuild@npm:1.52.1" +"serverless-esbuild@https://github.com/iainlane/serverless-esbuild#iainlane/zip-use-mtime-from-filesystem-built": + version: 0.0.0-development + resolution: "serverless-esbuild@https://github.com/iainlane/serverless-esbuild.git#commit=67b60b348d954cb4755d30288d7ca43976322430" dependencies: acorn: "npm:^8.8.1" acorn-walk: "npm:^8.2.0" @@ -11778,7 +11778,7 @@ __metadata: peerDependenciesMeta: esbuild-node-externals: optional: true - checksum: 10c0/503906901804dc2eccfd77c033908347e1dada82d08199427570faa4a3f55832bd9b5907dd05143dca845f1ec56dd430e78006facd270b6853e322496f842cd6 + checksum: 10c0/5559741ab1e3ff342b41ce2dc336b7f123e7df26a3aa10761c6ad52c482e317d021ec9baaaaa5aa0019efd0101a30a30b6e32c2728a94be7b6d2ecb782ba56c1 languageName: node linkType: hard