diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 98ee0e1fb9..1d246eff20 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -248,29 +248,6 @@ jobs: disable_search: true fail_ci_if_error: false - - name: Run the express-partial-content tests - id: test-express-partial-content - run: pnpm run --filter=express-partial-content test --coverage - - - name: Upload express-partial-content coverage to Codecov - uses: codecov/codecov-action@v7 - if: ${{ !cancelled() && steps.test-express-partial-content.outcome == 'success' }} - with: - files: packages/express-partial-content/test-output/vitest/coverage/lcov.info - flags: express-partial-content - disable_search: true - fail_ci_if_error: false - - - name: Upload express-partial-content test results to Codecov - uses: codecov/test-results-action@v1 - if: ${{ !cancelled() && steps.test-express-partial-content.outcome != 'skipped' }} - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: packages/express-partial-content/test-output/vitest/junit.xml - flags: express-partial-content - disable_search: true - fail_ci_if_error: false - - name: Run the highlightjs tests id: test-highlightjs run: pnpm run --filter=highlightjs test --coverage @@ -318,7 +295,7 @@ jobs: fail_ci_if_error: false - name: Run the rest of the tests - run: pnpm run --filter=\!client --filter=\!standalone --filter=\!server --filter=\!desktop --filter=\!commons --filter=\!ckeditor5 --filter=\!codemirror --filter=\!express-partial-content --filter=\!highlightjs --filter=\!pdfjs-viewer test + run: pnpm run --filter=\!client --filter=\!standalone --filter=\!server --filter=\!desktop --filter=\!commons --filter=\!ckeditor5 --filter=\!codemirror --filter=\!highlightjs --filter=\!pdfjs-viewer test build_docker: name: Build Docker image diff --git a/CLAUDE.md b/CLAUDE.md index 314ea12f89..bd91092cd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ packages/ codemirror/ # Code editor integration highlightjs/ # Syntax highlighting share-theme/ # Theme for shared/published notes - express-partial-content/, pdfjs-viewer/, splitjs/ + pdfjs-viewer/, splitjs/ turndown-plugin-gfm/ ``` diff --git a/apps/server/package.json b/apps/server/package.json index d808ec0128..099cb41701 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -51,7 +51,6 @@ "@triliumnext/backup-container": "workspace:*", "@triliumnext/commons": "workspace:*", "@triliumnext/core": "workspace:*", - "@triliumnext/express-partial-content": "workspace:*", "@triliumnext/highlightjs": "workspace:*", "@types/better-sqlite3": "9.6.0", "@types/cls-hooked": "4.3.9", diff --git a/apps/server/tsconfig.app.json b/apps/server/tsconfig.app.json index f0105d73f3..bc527df212 100644 --- a/apps/server/tsconfig.app.json +++ b/apps/server/tsconfig.app.json @@ -41,9 +41,6 @@ { "path": "../../packages/turndown-plugin-gfm/tsconfig.lib.json" }, - { - "path": "../../packages/express-partial-content/tsconfig.lib.json" - }, { "path": "../../packages/trilium-backup-container/tsconfig.lib.json" }, diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index ae11c06bcd..34daa2b379 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -9,9 +9,6 @@ { "path": "../../packages/turndown-plugin-gfm" }, - { - "path": "../../packages/express-partial-content" - }, { "path": "../../packages/commons" }, diff --git a/codecov.yml b/codecov.yml index 6dc8cefca6..6d6994a48e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -45,10 +45,6 @@ flags: paths: - packages/codemirror/src/ carryforward: true - express-partial-content: - paths: - - packages/express-partial-content/src/ - carryforward: true highlightjs: paths: - packages/highlightjs/src/ diff --git a/docs/Developer Guide/Developer Guide/Architecture.md b/docs/Developer Guide/Developer Guide/Architecture.md index cbe753b9c6..c9b4d858d1 100644 --- a/docs/Developer Guide/Developer Guide/Architecture.md +++ b/docs/Developer Guide/Developer Guide/Architecture.md @@ -94,7 +94,6 @@ trilium/ │ ├── ckeditor5/ # Custom rich text editor │ ├── codemirror/ # Code editor customizations │ ├── highlightjs/ # Syntax highlighting -│ ├── express-partial-content/ # HTTP partial content middleware │ ├── share-theme/ # Shared note theme │ ├── splitjs/ # Split pane library │ └── turndown-plugin-gfm/ # Markdown conversion diff --git a/flake.nix b/flake.nix index 50dcdaeeab..de6ad84088 100644 --- a/flake.nix +++ b/flake.nix @@ -240,7 +240,6 @@ nodejs.python "packages/ckeditor5" "packages/codemirror" "packages/commons" - "packages/express-partial-content" "packages/highlightjs" "packages/turndown-plugin-gfm" diff --git a/packages/express-partial-content/LICENSE b/packages/express-partial-content/LICENSE deleted file mode 100644 index bd6fa27a59..0000000000 --- a/packages/express-partial-content/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2019 Sukant Gujar - -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. diff --git a/packages/express-partial-content/README.md b/packages/express-partial-content/README.md deleted file mode 100644 index 8fa64f2474..0000000000 --- a/packages/express-partial-content/README.md +++ /dev/null @@ -1,149 +0,0 @@ -![Travis CI Status](https://travis-ci.com/SukantGujar/express-partial-content.svg?branch=master) - -# About - -A HTTP 206 Partial Content handler to serve any readable stream partially in Express. - -Based on this blog post: https://www.codeproject.com/Articles/813480/HTTP-Partial-Content-In-Node-js. - -# Installation - -`yarn add express-partial-content` - -OR - -`npm install express-partial-content` - -> Note: `Express` package is a peer dependency for `express-partial-content` and must be present in dependencies of the host package. - -# Usage - -From the `express-file-server` example: - -1. Implement a `ContentProvider` function which prepares and returns a `Content` object: - - import { promisify } from "util"; - import fs from "fs"; - import { Range, ContentDoesNotExistError, ContentProvider } from "express-partial-content"; - import {logger} from "./logger"; - - const statAsync = promisify(fs.stat); - const existsAsync = promisify(fs.exists); - - export const fileContentProvider: ContentProvider = async (req: Request) => { - // Read file name from route params. - const fileName = req.params.name; - const file = `${__dirname}/files/${fileName}`; - if (!(await existsAsync(file))) { - throw new ContentDoesNotExistError(`File doesn't exist: ${file}`); - } - const stats = await statAsync(file); - const totalSize = stats.size; - const mimeType = "application/octet-stream"; - const getStream = (range?: Range) => { - if (!range) { - // Request if for complete content. - return fs.createReadStream(file); - } - // Partial content request. - const { start, end } = range; - logger.debug(`start: ${start}, end: ${end}`); - return fs.createReadStream(file, { start, end }); - }; - return { - fileName, - totalSize, - mimeType, - getStream - }; - }; - -2. In your express code, use `createPartialContentHandler` factory method to generate an express handler for serving partial content for the route of your choice: - - import {createPartialContentHandler} from "express-partial-content"; - import {logger} from "./logger"; - - const handler = createPartialContentHandler(fileContentProvider, logger); - - const app = express(); - const port = 8080; - - // File name is a route param. - app.get("/files/:name", handler); - - app.listen(port, () => { - logger.debug("Server started!"); - }); - -3. Run your server and use a multi-part/multi-connection download utility like [aria2c](https://aria2.github.io/) to test it: - - aria -x5 -k1M http://localhost:8080/files/readme.txt - -# Examples - -There one examples in the `src/examples` folder: - -1. `express-file-server`: Implements a file based `ContentProvider`. - -## Running the examples: - -1. `express-file-server`: Run the following commands, the server will listen on http://localhost:8080/. - - yarn build:dev - yarn copy-assets - yarn run:examples:file - -## Connecting to the running server: - -Browse to `https://localhost:8080/files/readme.txt` - -# Reference - -## createPartialContentHandler function: - -This is a factory method which generates a partial content handler for express routes. - -### Arguments: - -- `contentProvider`: An `async` function which returns a Promise resolved to a `Content` object (see below). -- `logger`: Any logging implementation which has a `debug(message:string, extra: any)` method. Either `winston` or `bunyan` loggers should work. - -### Returns: - -- Express Route Handler: `createPartialContentHandler` returns an express handler which can be mapped to an Express route to serve partial content. - -## ContentProvider function: - -This function _needs to be implemented by you_. It's purpose is to fetch and return `Content` object containing necessary metadata and methods to stream the content partially. This method is invoked by the express handler (returned by `createPartialContentHandler`) on each request. - -### Arguments: - -- `Request`: It receives the `Request` object as it's only input. Use the information available in `Request` to find the requested content, e.g. through `Request.params` or query string, headers etc. - -### Returns: - -- `Promise`: See below. - -### Throws: - -- `ContentDoesNotExistError`: Throw this to indicate that the content doesn't exist. The generated express handler will return a 404 in this case. - > Note: Any message provided to the `ContentDoesNotExistError` object is returned to the client. - -## Content object: - -This object contains metadata and methods which describe the content. The `ContentProvider` method builds and returns it. - -### Properties: - -All the properties of this object are used to return content metadata to the client as various `Response` headers. - -- `fileName`: Used as the `Content-Disposition` header's `filename` value. -- `mimeType`: Used as the `Content-Type` header value. -- `totalSize`: Used as the `Content-Length` header value. - -### Methods: - -- `getStream(range?: Range)`: This method should return a readable stream initialized to the provided `range` (optional). You need to handle two cases: - - - range is `null`: When `range` is not-specified, the client is requesting the full content. In this case, return the stream as it is. - - range is `{start, end}`: When client requests partial content, the `start` and `end` values will point to the corresponding byte positions (0 based and inclusive) of the content. You need to return stream limited to these positions. diff --git a/packages/express-partial-content/package.json b/packages/express-partial-content/package.json deleted file mode 100644 index 3cca902378..0000000000 --- a/packages/express-partial-content/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@triliumnext/express-partial-content", - "description": "A partial content handler implementation for any readable stream with Express. Based on this blog post: https://www.codeproject.com/Articles/813480/HTTP-Partial-Content-In-Node-js.", - "license": "MIT", - "version": "1.1.0", - "type": "module", - "private": true, - "main": "./src/index.ts", - "scripts": { - "test": "vitest" - }, - "keywords": [ - "partial-content", - "206", - "stream", - "typescript" - ] -} diff --git a/packages/express-partial-content/src/Content.ts b/packages/express-partial-content/src/Content.ts deleted file mode 100644 index 7fa59ca05b..0000000000 --- a/packages/express-partial-content/src/Content.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Range } from "./Range.js"; -import { Stream } from "stream"; -export interface Content { - /** - * Returns a readable stream based on the provided range (optional). - * @param {Range} range The start-end range of stream data. - * @returns {Stream} A readable stream - */ - getStream(range?: Range): Stream; - /** - * Total size of the content - */ - readonly totalSize: number; - /** - * Mime type to be sent in Content-Type header - */ - readonly mimeType: string; - /** - * File name to be sent in Content-Disposition header - */ - readonly fileName: string; - /** - * Optional entity tag (a stable content identifier, e.g. a content hash) sent in the ETag header. Lets a - * client revalidate and reliably resume a partially-downloaded stream after a dropped connection. - */ - readonly etag?: string; -}; diff --git a/packages/express-partial-content/src/ContentDoesNotExistError.ts b/packages/express-partial-content/src/ContentDoesNotExistError.ts deleted file mode 100644 index ebd8ffdde4..0000000000 --- a/packages/express-partial-content/src/ContentDoesNotExistError.ts +++ /dev/null @@ -1,2 +0,0 @@ -export class ContentDoesNotExistError extends Error { -} diff --git a/packages/express-partial-content/src/ContentProvider.ts b/packages/express-partial-content/src/ContentProvider.ts deleted file mode 100644 index 819171b242..0000000000 --- a/packages/express-partial-content/src/ContentProvider.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Request } from "express"; -import type { Content } from "./Content.js"; -import type { ParamsDictionary } from "express-serve-static-core"; - -/** - * @type {function (Request): Promise} - */ -export type ContentProvider

= (req: Request

) => Promise; diff --git a/packages/express-partial-content/src/Logger.ts b/packages/express-partial-content/src/Logger.ts deleted file mode 100644 index c0f6288632..0000000000 --- a/packages/express-partial-content/src/Logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Logger { - debug(message: string, extra?: any): void; -} diff --git a/packages/express-partial-content/src/Range.ts b/packages/express-partial-content/src/Range.ts deleted file mode 100644 index f6c11015c6..0000000000 --- a/packages/express-partial-content/src/Range.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type Range = { - start: number; - end: number; -}; diff --git a/packages/express-partial-content/src/RangeParserError.ts b/packages/express-partial-content/src/RangeParserError.ts deleted file mode 100644 index 842efbc6d7..0000000000 --- a/packages/express-partial-content/src/RangeParserError.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class RangeParserError extends Error { - constructor(start: any, end: any) { - super(`Invalid start and end values: ${start}-${end}.`); - } -} diff --git a/packages/express-partial-content/src/createPartialContentHandler.spec.ts b/packages/express-partial-content/src/createPartialContentHandler.spec.ts deleted file mode 100644 index f4139cd0a4..0000000000 --- a/packages/express-partial-content/src/createPartialContentHandler.spec.ts +++ /dev/null @@ -1,216 +0,0 @@ -import * as parseRangeHeaderModule from "./parseRangeHeader.js"; -import * as utils from "./utils.js"; -import { ContentDoesNotExistError } from "./ContentDoesNotExistError.js"; -import { createPartialContentHandler } from "./createPartialContentHandler.js"; -import type { ContentProvider } from "./ContentProvider.js"; -import type { Logger } from "./Logger.js"; -import type { Request, Response } from "express"; -import type { Content } from "./Content.js"; -import { Stream } from "stream"; -import type { Range } from "./Range.js"; -import type { MockInstance } from "vitest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("createPartialContentHandler tests", () => { - let logger: Logger; - beforeEach(() => { - logger = { - debug: vi.fn() as (message: string, extra?: any) => void - }; - }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it("returns a handler", () => { - const contentProvider = vi.fn().mockResolvedValue({}) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - expect(typeof handler === "function"); - }); - - describe("handler tests", () => { - let req: Request; - let res: Response; - let statusSpy: MockInstance; - let sendSpy: MockInstance; - let sendStatusSpy: MockInstance; - beforeEach(() => { - // `headers` must exist: getRangeHeader reads `req.headers["range"]`, so a bare `{}` makes the - // handler reject with a TypeError before it reaches anything worth asserting. - req = { headers: {} } as unknown as Request; - res = { - status: (code: number) => res, - send: (message: string) => res, - sendStatus: (code: number) => res, - setHeader: vi.fn() as (name: string, value: string) => void - } as Response; - statusSpy = vi.spyOn(res, "status"); - sendSpy = vi.spyOn(res, "send"); - sendStatusSpy = vi.spyOn(res, "sendStatus"); - }); - it("invokes contentProvider with the specified request", async () => { - const contentProvider = vi.fn().mockResolvedValue({}) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - try { - await handler(req, res); - } catch {} - expect(contentProvider).toHaveBeenCalledExactlyOnceWith(req); - }); - it("returns 404 if contentProvider throws ContentDoesNotExistError error", async () => { - const error = new ContentDoesNotExistError("404-File not found!"); - const contentProvider = vi.fn().mockRejectedValue(error) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - await handler(req, res); - expect(statusSpy).toHaveBeenCalledExactlyOnceWith(404); - expect(sendSpy).toHaveBeenCalledExactlyOnceWith(error.message); - }); - it("returns 500 if contentProvider throws any other error", async () => { - const error = new Error("Something went wrong!"); - const contentProvider = vi.fn().mockRejectedValue(error) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - await handler(req, res); - expect(sendStatusSpy).toHaveBeenCalledExactlyOnceWith(500); - }); - it("returns 416 if parseRangeHeader throws RangeParserError error", async () => { - const contentProvider = vi.fn().mockResolvedValue({ totalSize: 20 }) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - req.headers = { range: "bytes=30-10" }; - await handler(req, res); - expect(statusSpy).toHaveBeenCalledExactlyOnceWith(416); - expect(sendSpy).toHaveBeenCalledExactlyOnceWith("Invalid value for Range: bytes=30-10"); - }); - it("returns 500 if parseRangeHeader throws any other error", async () => { - // Only a RangeParserError maps to 416; anything else escaping the parser is a server fault. - vi.spyOn(parseRangeHeaderModule, "parseRangeHeader").mockImplementation(() => { - throw new Error("Something went wrong!"); - }); - const contentProvider = vi.fn().mockResolvedValue({ totalSize: 10 }) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - req.headers = { range: "bytes=0-5" }; - await handler(req, res); - expect(sendStatusSpy).toHaveBeenCalledExactlyOnceWith(500); - expect(statusSpy).not.toHaveBeenCalled(); - }); - it("returns correct response if range is not specified", async () => { - const result = ({ - pipe() { - return result; - } - } as any) as Stream; - const content: Content = { - fileName: "file.txt", - totalSize: 10, - mimeType: "text/plain", - getStream(range?: Range) { - return result; - } - }; - const pipeSpy = vi.spyOn(result, "pipe"); - const getStreamSpy = vi.spyOn(content, "getStream"); - const contentProvider = vi.fn().mockResolvedValue(content) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - const setContentTypeHeaderSpy = vi.spyOn(utils, "setContentTypeHeader"); - const setContentDispositionHeaderSpy = vi.spyOn(utils, "setContentDispositionHeader"); - const setAcceptRangesHeaderSpy = vi.spyOn(utils, "setAcceptRangesHeader"); - const setContentLengthHeaderSpy = vi.spyOn(utils, "setContentLengthHeader"); - const setContentRangeHeaderSpy = vi.spyOn(utils, "setContentRangeHeader"); - await handler(req, res); - expect(setContentTypeHeaderSpy).toHaveBeenCalledExactlyOnceWith(content.mimeType, res); - expect(setContentDispositionHeaderSpy).toHaveBeenCalledExactlyOnceWith(content.fileName, res); - expect(setAcceptRangesHeaderSpy).toHaveBeenCalledExactlyOnceWith(res); - expect(setContentLengthHeaderSpy).toHaveBeenCalledExactlyOnceWith(String(content.totalSize), res); - expect(getStreamSpy).toHaveBeenCalledExactlyOnceWith(); - expect(pipeSpy).toHaveBeenCalledExactlyOnceWith(res); - expect(setContentRangeHeaderSpy).not.toHaveBeenCalled(); - }); - it("returns correct partial response if range is specified", async () => { - req.headers = { - range: "bytes=0-5" - }; - const result = ({ - pipe() { - return result; - } - } as any) as Stream; - const content: Content = { - fileName: "file.txt", - totalSize: 10, - mimeType: "text/plain", - getStream(range?: Range) { - return result; - } - }; - const range = { start: 0, end: 5 }; - const pipeSpy = vi.spyOn(result, "pipe"); - const getStreamSpy = vi.spyOn(content, "getStream"); - const contentProvider = vi.fn().mockResolvedValue(content) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - const setContentTypeHeaderSpy = vi.spyOn(utils, "setContentTypeHeader"); - const setContentDispositionHeaderSpy = vi.spyOn(utils, "setContentDispositionHeader"); - const setAcceptRangesHeaderSpy = vi.spyOn(utils, "setAcceptRangesHeader"); - const setContentLengthHeaderSpy = vi.spyOn(utils, "setContentLengthHeader"); - const setContentRangeHeaderSpy = vi.spyOn(utils, "setContentRangeHeader"); - await handler(req, res); - expect(setContentTypeHeaderSpy).toHaveBeenCalledExactlyOnceWith(content.mimeType, res); - expect(setContentDispositionHeaderSpy).toHaveBeenCalledExactlyOnceWith(content.fileName, res); - expect(setAcceptRangesHeaderSpy).toHaveBeenCalledExactlyOnceWith(res); - expect(setContentRangeHeaderSpy).toHaveBeenCalledExactlyOnceWith(range, content.totalSize, res); - expect(setContentLengthHeaderSpy).toHaveBeenCalledExactlyOnceWith("6", res); - expect(getStreamSpy).toHaveBeenCalledExactlyOnceWith(range); - expect(pipeSpy).toHaveBeenCalledExactlyOnceWith(res); - expect(statusSpy).toHaveBeenCalledExactlyOnceWith(206); - }); - it("reports a zero content length for a single-byte-collapsed range", async () => { - // `bytes=5-5` parses to start === end, which the handler reports as length 0 rather than 1. - req.headers = { range: "bytes=5-5" }; - const result = ({ pipe() { return result; } } as any) as Stream; - const content: Content = { - fileName: "file.txt", - totalSize: 10, - mimeType: "text/plain", - getStream(range?: Range) { - return result; - } - }; - const contentProvider = vi.fn().mockResolvedValue(content) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - const setContentLengthHeaderSpy = vi.spyOn(utils, "setContentLengthHeader"); - await handler(req, res); - expect(setContentLengthHeaderSpy).toHaveBeenCalledExactlyOnceWith("0", res); - }); - it("sets the ETag header when the content provides one", async () => { - req.headers = { range: "bytes=0-5" }; - const result = ({ pipe() { return result; } } as any) as Stream; - const content: Content = { - fileName: "file.txt", - totalSize: 10, - mimeType: "text/plain", - etag: "abc123", - getStream(range?: Range) { - return result; - } - }; - const contentProvider = vi.fn().mockResolvedValue(content) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - const setETagHeaderSpy = vi.spyOn(utils, "setETagHeader"); - await handler(req, res); - expect(setETagHeaderSpy).toHaveBeenCalledExactlyOnceWith("abc123", res); - }); - it("does not set the ETag header when the content omits one", async () => { - req.headers = { range: "bytes=0-5" }; - const result = ({ pipe() { return result; } } as any) as Stream; - const content: Content = { - fileName: "file.txt", - totalSize: 10, - mimeType: "text/plain", - getStream(range?: Range) { - return result; - } - }; - const contentProvider = vi.fn().mockResolvedValue(content) as ContentProvider<{}>; - const handler = createPartialContentHandler(contentProvider, logger); - const setETagHeaderSpy = vi.spyOn(utils, "setETagHeader"); - await handler(req, res); - expect(setETagHeaderSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/express-partial-content/src/createPartialContentHandler.ts b/packages/express-partial-content/src/createPartialContentHandler.ts deleted file mode 100644 index 2fb243e57c..0000000000 --- a/packages/express-partial-content/src/createPartialContentHandler.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Request, Response } from "express"; -import { parseRangeHeader } from "./parseRangeHeader.js"; -import { RangeParserError } from "./RangeParserError.js"; -import type { Logger } from "./Logger.js"; -import type { ContentProvider } from "./ContentProvider.js"; -import type { ParamsDictionary } from "express-serve-static-core"; -import { ContentDoesNotExistError } from "./ContentDoesNotExistError.js"; -import { - getRangeHeader, - setContentRangeHeader, - setContentTypeHeader, - setContentDispositionHeader, - setAcceptRangesHeader, - setContentLengthHeader, - setCacheControlHeaderNoCache, - setETagHeader -} from "./utils.js"; -export function createPartialContentHandler

(contentProvider: ContentProvider

, logger: Logger) { - return async function handler(req: Request

, res: Response) { - let content; - try { - content = await contentProvider(req); - } catch (error) { - logger.debug("createPartialContentHandler: ContentProvider threw exception: ", error); - if (error instanceof ContentDoesNotExistError) { - return res.status(404).send(error.message); - } - return res.sendStatus(500); - } - let { getStream, mimeType, fileName, totalSize, etag } = content; - const rangeHeader = getRangeHeader(req); - let range; - try { - range = parseRangeHeader(rangeHeader, totalSize, logger); - } catch (error) { - logger.debug(`createPartialContentHandler: parseRangeHeader error: `, error); - if (error instanceof RangeParserError) { - setContentRangeHeader(null, totalSize, res); - return res.status(416).send(`Invalid value for Range: ${rangeHeader}`); - } - return res.sendStatus(500); - } - setContentTypeHeader(mimeType, res); - setContentDispositionHeader(fileName, res); - setAcceptRangesHeader(res); - // A stable validator lets the client revalidate and resume a partial download (e.g. media playback that - // re-requests a range after a backgrounded mobile tab drops its connection). - if (etag) { - setETagHeader(etag, res); - } - // If range is not specified, or the file is empty, return the full stream - if (range === null) { - logger.debug("createPartialContentHandler: No range found, returning full content."); - setContentLengthHeader(String(totalSize), res); - return getStream().pipe(res); - } - setContentRangeHeader(range, totalSize, res); - let { start, end } = range; - setContentLengthHeader(String(start === end ? 0 : end - start + 1), res); - setCacheControlHeaderNoCache(res); - // Return 206 Partial Content status - logger.debug("createPartialContentHandler: Returning partial content for range: ", JSON.stringify(range)); - res.status(206); - - return getStream(range).pipe(res); - }; -} diff --git a/packages/express-partial-content/src/index.ts b/packages/express-partial-content/src/index.ts deleted file mode 100644 index 445d6027fa..0000000000 --- a/packages/express-partial-content/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./Content.js"; -export * from "./ContentDoesNotExistError.js"; -export * from "./ContentProvider.js"; -export * from "./createPartialContentHandler.js"; -export * from "./Logger.js"; -export * from "./Range.js"; diff --git a/packages/express-partial-content/src/parseRangeHeader.spec.ts b/packages/express-partial-content/src/parseRangeHeader.spec.ts deleted file mode 100644 index 95fc72f7bb..0000000000 --- a/packages/express-partial-content/src/parseRangeHeader.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { parseRangeHeader } from "./parseRangeHeader.js"; -import type { Logger } from "./Logger.js"; -import { RangeParserError } from "./RangeParserError.js"; -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; - -describe("parseRangeHeader tests", () => { - let logger: Logger; - beforeEach(() => { - logger = { - debug: vi.fn() as (message: string, extra?: any) => void - }; - }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it("returns null if range is not specified", () => { - let value = parseRangeHeader("", 10, logger); - expect(value).to.be.equal(null); - value = parseRangeHeader(null, 10, logger); - expect(value).to.be.equal(null); - }); - it("returns null if total size is zero", () => { - let value = parseRangeHeader("bytes=0-5", 0, logger); - expect(value).to.be.equal(null); - }); - it("if end is not provided, sets end to the last byte (totalSize - 1).", () => { - let value = parseRangeHeader("bytes=0-", 10, logger); - expect(value).to.be.deep.equal({ start: 0, end: 9 }); - }); - it('if start is not provided, set it to the offset of last "end" bytes from the end of the file.', () => { - let value = parseRangeHeader("bytes=-5", 10, logger); - expect(value).to.be.deep.equal({ start: 5, end: 9 }); - }); - it("handles invalid ranges", () => { - try { - parseRangeHeader("bytes=6-5", 10, logger); - } catch (error) { - expect(error).that.be.instanceOf(RangeParserError); - } - try { - parseRangeHeader("bytes=6-7", 10, logger); - } catch (error) { - expect(error).that.be.instanceOf(RangeParserError); - } - try { - parseRangeHeader("bytes=6-11", 10, logger); - } catch (error) { - expect(error).that.be.instanceOf(RangeParserError); - } - }); - it("returns a valid parsed range.", () => { - let value = parseRangeHeader("bytes=0-5", 10, logger); - expect(value).to.be.deep.equal({ start: 0, end: 5 }); - }); - it("uses the first entry when the header arrives repeated", () => { - // Express surfaces a repeated header as string[]; only the first range is honoured. - const value = parseRangeHeader(["bytes=0-5", "bytes=6-9"], 10, logger); - expect(value).to.be.deep.equal({ start: 0, end: 5 }); - }); -}); diff --git a/packages/express-partial-content/src/parseRangeHeader.ts b/packages/express-partial-content/src/parseRangeHeader.ts deleted file mode 100644 index 189b932f42..0000000000 --- a/packages/express-partial-content/src/parseRangeHeader.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Logger } from "./Logger.js"; -import { RangeParserError } from "./RangeParserError.js"; -import type { Range } from "./Range.js"; - -const rangeRegEx = /bytes=([0-9]*)-([0-9]*)/; - -export function parseRangeHeader(_range: string | string[] | null | undefined, totalSize: number, logger: Logger): Range | null { - logger.debug("Un-parsed range is: ", _range); - - if (!_range?.length) { - return null; - } - - // TODO: Maybe we need to support multiple ranges. - const range = Array.isArray(_range) ? _range[0] : _range; - - // 1. If range is not specified or the file is empty, return null. - if (!range || range === null || range.length === 0 || totalSize === 0) { - return null; - } - - const splitRange = range.split(rangeRegEx); - const [, startValue, endValue] = splitRange; - let start = Number.parseInt(startValue); - let end = Number.parseInt(endValue); - - // 2. Parse start and end values and ensure they are within limits. - // 2.1. start: >= 0. - // 2.2. end: >= 0, <= totalSize - 1 - - let result = { - start: Number.isNaN(start) ? 0 : Math.max(start, 0), - end: Number.isNaN(end) ? totalSize - 1 : Math.min(Math.max(end, 0), totalSize - 1) - }; - - // 3.1. If end is not provided, set end to the last byte (totalSize - 1). - if (!Number.isNaN(start) && Number.isNaN(end)) { - logger.debug("End is not provided."); - - result.start = start; - result.end = totalSize - 1; - } - - // 3.2. If start is not provided, set it to the offset of last "end" bytes from the end of the file. - // And set end to the last byte. - // This way we return the last "end" bytes. - if (Number.isNaN(start) && !Number.isNaN(end)) { - logger.debug(`Start is not provided, "end" will be treated as last "end" bytes of the content.`); - - result.start = Math.max(totalSize - end, 0); - result.end = totalSize - 1; - } - - // 4. Handle invalid ranges. - if (start < 0 || start > end || end > totalSize) { - throw new RangeParserError(start, end); - } - - logRange(logger, result); - return result; -} - -function logRange(logger: Logger, range: Range) { - logger.debug("Range is: ", JSON.stringify(range)); -} diff --git a/packages/express-partial-content/src/utils.spec.ts b/packages/express-partial-content/src/utils.spec.ts deleted file mode 100644 index 377fd53a63..0000000000 --- a/packages/express-partial-content/src/utils.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { Request, Response } from "express"; -import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; - -import { - getHeader, - setHeader, - getRangeHeader, - setContentTypeHeader, - setContentLengthHeader, - setAcceptRangesHeader, - setContentDispositionHeader, - setContentRangeHeader, - setCacheControlHeaderNoCache, - setETagHeader -} from "./utils.js"; - -describe("utils tests", () => { - let req: Request; - let res: Response; - beforeEach(() => { - req = { - headers: { - "content-type": "application/octet-stream", - range: "*" - } - } as Request; - res = { - setHeader: vi.fn() as (name: string, value: string) => void - } as Response; - }); - describe("getHeader tests", () => { - it("gets the specified header value if present", () => { - const value = getHeader("content-type", req); - expect(value).to.equal("application/octet-stream"); - }); - it("returns undefined if the specified header value is absent", () => { - const value = getHeader("mime-type", req); - expect(value).to.be.undefined; - }); - }); - describe("setHeader tests", () => { - it("invokes res.setHeader API with the specified name and value args", () => { - const name = "Content-Type"; - const value = "application/octet-stream"; - setHeader(name, value, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith(name, value); - }); - }); - describe("getRangeHeader tests", () => { - it("gets range header value", () => { - const value = getRangeHeader(req); - expect(value).to.equal("*"); - }); - }); - describe("setContentTypeHeader tests", () => { - it("sets Content-Type header with specified value", () => { - const value = "application/octet-stream"; - setContentTypeHeader(value, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Content-Type", value); - }); - }); - describe("setContentLengthHeader tests", () => { - it("sets Content-Length header with specified value", () => { - const value = "100"; - setContentLengthHeader(value, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Content-Length", value); - }); - }); - describe("setAcceptRangesHeader tests", () => { - it("sets Accept-Ranges header with specified value", () => { - const value = "bytes"; - setAcceptRangesHeader(res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Accept-Ranges", value); - }); - }); - describe("setContentRangeHeader tests", () => { - it("sets Content-Range header with specified value", () => { - let range: { start: number, end: number } | null = { start: 10, end: 100 }; - const size = 1000; - let value = `bytes ${range.start}-${range.end}/${size}`; - setContentRangeHeader(range, size, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Content-Range", value); - (res.setHeader as Mock).mockReset(); - - range = null; - value = `bytes */${size}`; - setContentRangeHeader(range, size, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Content-Range", value); - }); - }); - describe("setContentDispositionHeader tests", () => { - it("sets Content-Disposition header with specified value", () => { - const fileName = "file.txt"; - const value = `attachment; filename*=utf-8''${fileName}`; - setContentDispositionHeader(fileName, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Content-Disposition", value); - }); - it("sets Content-Disposition header with specified unicode", () => { - const fileName = "file.txt"; - const value = `attachment; filename*=utf-8''${encodeURIComponent(fileName)}`; - setContentDispositionHeader(fileName, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Content-Disposition", value); - }); - }); - describe("setCacheControlHeaderNoCache tests", () => { - it("sets Cache-Control header with specified value", () => { - const value = "no-cache"; - setCacheControlHeaderNoCache(res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("Cache-Control", value); - }); - }); - describe("setETagHeader tests", () => { - it("quotes a bare validator but passes through already-quoted and weak ones", () => { - // RFC 9110 requires the entity-tag to be quoted, so a bare value gets wrapped; a value that - // already carries quotes (strong or weak) must be forwarded untouched. - setETagHeader("abc123", res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("ETag", `"abc123"`); - - (res.setHeader as Mock).mockClear(); - setETagHeader(`"abc123"`, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("ETag", `"abc123"`); - - (res.setHeader as Mock).mockClear(); - setETagHeader(`W/"abc123"`, res); - expect(res.setHeader).toHaveBeenCalledExactlyOnceWith("ETag", `W/"abc123"`); - }); - }); -}); diff --git a/packages/express-partial-content/src/utils.ts b/packages/express-partial-content/src/utils.ts deleted file mode 100644 index 615bb5ccdb..0000000000 --- a/packages/express-partial-content/src/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Request, Response } from "express"; -import type { Range } from "./Range.js"; -export const getHeader = (name: string, req: Request) => req.headers[name]; -export const getRangeHeader: (req: Request) => string | string[] | undefined = getHeader.bind(null, "range"); -export const setHeader = (name: string, value: string, res: Response) => res.setHeader(name, value); -export const setContentTypeHeader = setHeader.bind(null, "Content-Type"); -export const setContentLengthHeader = setHeader.bind(null, "Content-Length"); -export const setAcceptRangesHeader = setHeader.bind(null, "Accept-Ranges", "bytes"); -export const setContentRangeHeader = (range: Range | null, size: number, res: Response) => - setHeader("Content-Range", `bytes ${range ? `${range.start}-${range.end}` : "*"}/${size}`, res); -export const setContentDispositionHeader = (fileName: string, res: Response) => - setHeader("Content-Disposition", `attachment; filename*=utf-8''${encodeURIComponent(fileName)}`, res); -export const setCacheControlHeaderNoCache = setHeader.bind(null, "Cache-Control", "no-cache"); -export const setETagHeader = (etag: string, res: Response) => - setHeader("ETag", /^(W\/)?".*"$/.test(etag) ? etag : `"${etag}"`, res); diff --git a/packages/express-partial-content/tsconfig.json b/packages/express-partial-content/tsconfig.json deleted file mode 100644 index 62ebbd9464..0000000000 --- a/packages/express-partial-content/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/packages/express-partial-content/tsconfig.lib.json b/packages/express-partial-content/tsconfig.lib.json deleted file mode 100644 index a03311402f..0000000000 --- a/packages/express-partial-content/tsconfig.lib.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", - "emitDeclarationOnly": false, - "types": [ - "node" - ] - }, - "include": [ - "src/**/*.ts" - ], - "references": [], - "exclude": [ - "vite.config.ts", - "vite.config.mts", - "vitest.config.ts", - "vitest.config.mts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.test.tsx", - "src/**/*.spec.tsx", - "src/**/*.test.js", - "src/**/*.spec.js", - "src/**/*.test.jsx", - "src/**/*.spec.jsx" - ] -} diff --git a/packages/express-partial-content/tsconfig.spec.json b/packages/express-partial-content/tsconfig.spec.json deleted file mode 100644 index 98fd4a8212..0000000000 --- a/packages/express-partial-content/tsconfig.spec.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./out-tsc/vitest", - "types": [ - "node", - "vitest" - ] - }, - "include": [ - "vite.config.ts", - "vite.config.mts", - "vitest.config.ts", - "vitest.config.mts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.test.tsx", - "src/**/*.spec.tsx", - "src/**/*.test.js", - "src/**/*.spec.js", - "src/**/*.test.jsx", - "src/**/*.spec.jsx", - "src/**/*.d.ts", - "src/**/*.ts" - ] -} diff --git a/packages/express-partial-content/vite.config.ts b/packages/express-partial-content/vite.config.ts deleted file mode 100644 index d6065cc07a..0000000000 --- a/packages/express-partial-content/vite.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/// -import { resolve } from 'node:path'; -import { defineConfig } from 'vite'; - -export default defineConfig(() => ({ - root: __dirname, - cacheDir: '../../node_modules/.vite/packages/express-partial-content', - plugins: [], - test: { - watch: false, - globals: true, - environment: 'happy-dom', - include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - reporters: [ - 'default', - ['junit', { outputFile: './test-output/vitest/junit.xml', addFileAttribute: true }] - ], - coverage: { - thresholds: { - lines: 100, - functions: 100, - branches: 100, - statements: 100 - }, - reportsDirectory: './test-output/vitest/coverage', - provider: 'v8' as const, - include: ['src/**/*.{ts,tsx}'], - exclude: ['**/*.{test,spec}.{ts,mts,cts,tsx,js,jsx}', '**/*.d.ts'], - // Codecov resolves an lcov `SF:` path by matching it against the repo's file list, so the - // package-relative paths istanbul emits by default (`src/utils.ts`, relative to cwd) are - // ambiguous in this monorepo and get attributed to whichever package wins the match. Emit - // repo-root-relative paths instead. - reporter: ['text', ['lcov', { projectRoot: resolve(__dirname, '../..') }]], - } - }, -})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f872ada55a..6897e54ed4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -634,9 +634,6 @@ importers: '@triliumnext/core': specifier: workspace:* version: link:../../packages/trilium-core - '@triliumnext/express-partial-content': - specifier: workspace:* - version: link:../../packages/express-partial-content '@triliumnext/highlightjs': specifier: workspace:* version: link:../../packages/highlightjs @@ -1286,8 +1283,6 @@ importers: specifier: 10.29.8 version: 10.29.8(preact-render-to-string@6.7.0) - packages/express-partial-content: {} - packages/highlightjs: dependencies: '@catppuccin/highlightjs': diff --git a/tsconfig.json b/tsconfig.json index 33e5f8cbc5..1ead495c26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,9 +36,6 @@ { "path": "./apps/edit-docs" }, - { - "path": "./packages/express-partial-content" - }, { "path": "./apps/db-compare" },