mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-27 10:02:59 +08:00
Merge pull request #1829 from TriliumNext/monorepo/express-partial-content
Integrate express-partial-content into monorepo
This commit is contained in:
commit
848613667e
@ -18,6 +18,9 @@
|
||||
"eslint.config.mjs"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../server/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "../desktop/tsconfig.app.json"
|
||||
},
|
||||
|
@ -3,6 +3,9 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../server"
|
||||
},
|
||||
{
|
||||
"path": "../desktop"
|
||||
},
|
||||
|
@ -53,7 +53,7 @@
|
||||
"@anthropic-ai/sdk": "0.40.1",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "1.0.1",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
|
@ -45,6 +45,11 @@ describe("#getMime", () => {
|
||||
["test.zip"], "application/zip"
|
||||
],
|
||||
|
||||
[
|
||||
"MP4 videos are supported",
|
||||
["video.mp4"], "video/mp4"
|
||||
],
|
||||
|
||||
[
|
||||
"unknown MIME type not recognized by mimeTypes.lookup",
|
||||
["test.fake"], false
|
||||
|
@ -71,7 +71,8 @@ const EXTENSION_TO_MIME = new Map<string, string>([
|
||||
[".ts", "text/x-typescript"],
|
||||
[".excalidraw", "application/json"],
|
||||
[".mermaid", "text/vnd.mermaid"],
|
||||
[".mmd", "text/vnd.mermaid"]
|
||||
[".mmd", "text/vnd.mermaid"],
|
||||
[".mp4", "video/mp4"] // https://github.com/jshttp/mime-types/issues/138
|
||||
]);
|
||||
|
||||
/** @returns false if MIME is not detected */
|
||||
|
@ -36,6 +36,9 @@
|
||||
{
|
||||
"path": "../../packages/turndown-plugin-gfm/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/express-partial-content/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/commons/tsconfig.lib.json"
|
||||
}
|
||||
|
@ -6,6 +6,9 @@
|
||||
{
|
||||
"path": "../../packages/turndown-plugin-gfm"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/express-partial-content"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/commons"
|
||||
},
|
||||
|
22
packages/express-partial-content/.swcrc
Normal file
22
packages/express-partial-content/.swcrc
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"jsc": {
|
||||
"target": "es2017",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true,
|
||||
"dynamicImport": true
|
||||
},
|
||||
"transform": {
|
||||
"decoratorMetadata": true,
|
||||
"legacyDecorator": true
|
||||
},
|
||||
"keepClassNames": true,
|
||||
"externalHelpers": true,
|
||||
"loose": true
|
||||
},
|
||||
"module": {
|
||||
"type": "commonjs"
|
||||
},
|
||||
"sourceMaps": true,
|
||||
"exclude": ["jest.config.ts",".*\\.spec.tsx?$",".*\\.test.tsx?$","./src/jest-setup.ts$","./**/jest-setup.ts$",".*.js$"]
|
||||
}
|
21
packages/express-partial-content/LICENSE
Normal file
21
packages/express-partial-content/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
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.
|
149
packages/express-partial-content/README.md
Normal file
149
packages/express-partial-content/README.md
Normal file
@ -0,0 +1,149 @@
|
||||

|
||||
|
||||
# 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<Content>`: 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.
|
23
packages/express-partial-content/eslint.config.mjs
Normal file
23
packages/express-partial-content/eslint.config.mjs
Normal file
@ -0,0 +1,23 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
"files": [
|
||||
"**/*.json"
|
||||
],
|
||||
"rules": {
|
||||
"@nx/dependency-checks": [
|
||||
"error",
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"{projectRoot}/eslint.config.{js,cjs,mjs}"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"languageOptions": {
|
||||
"parser": (await import('jsonc-eslint-parser'))
|
||||
}
|
||||
}
|
||||
];
|
47
packages/express-partial-content/package.json
Normal file
47
packages/express-partial-content/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"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": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"partial-content",
|
||||
"206",
|
||||
"stream",
|
||||
"typescript"
|
||||
],
|
||||
"nx": {
|
||||
"name": "express-partial-content",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:swc",
|
||||
"outputs": [
|
||||
"{options.outputPath}"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "packages/express-partial-content/dist",
|
||||
"tsConfig": "packages/express-partial-content/tsconfig.lib.json",
|
||||
"packageJson": "packages/express-partial-content/package.json",
|
||||
"main": "packages/express-partial-content/src/index.ts",
|
||||
"stripLeadingPaths": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
22
packages/express-partial-content/src/Content.ts
Normal file
22
packages/express-partial-content/src/Content.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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;
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export class ContentDoesNotExistError extends Error {
|
||||
}
|
6
packages/express-partial-content/src/ContentProvider.ts
Normal file
6
packages/express-partial-content/src/ContentProvider.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { Request } from "express";
|
||||
import type { Content } from "./Content.js";
|
||||
/**
|
||||
* @type {function (Request): Promise<Content>}
|
||||
*/
|
||||
export type ContentProvider = (req: Request) => Promise<Content>;
|
3
packages/express-partial-content/src/Logger.ts
Normal file
3
packages/express-partial-content/src/Logger.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface Logger {
|
||||
debug(message: string, extra?: any): void;
|
||||
}
|
4
packages/express-partial-content/src/Range.ts
Normal file
4
packages/express-partial-content/src/Range.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type Range = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
5
packages/express-partial-content/src/RangeParserError.ts
Normal file
5
packages/express-partial-content/src/RangeParserError.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class RangeParserError extends Error {
|
||||
constructor(start: any, end: any) {
|
||||
super(`Invalid start and end values: ${start}-${end}.`);
|
||||
}
|
||||
}
|
@ -0,0 +1,181 @@
|
||||
import * as utils from "./utils.js";
|
||||
import * as ParseRangeHeaderExports from "./parseRangeHeader.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";
|
||||
|
||||
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(() => {
|
||||
req = {} 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);
|
||||
try {
|
||||
await handler(req, res);
|
||||
expect(statusSpy).toHaveBeenCalledExactlyOnceWith(404);
|
||||
expect(sendSpy).toHaveBeenCalledExactlyOnceWith(error.message);
|
||||
} catch {
|
||||
expect(false);
|
||||
}
|
||||
});
|
||||
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);
|
||||
try {
|
||||
await handler(req, res);
|
||||
expect(sendStatusSpy).toHaveBeenCalledExactlyOnceWith(500);
|
||||
} catch {
|
||||
expect(false);
|
||||
}
|
||||
});
|
||||
it("returns 416 if parseRangeHeader throws RangeParserError error", async () => {
|
||||
const contentProvider = vi.fn().mockResolvedValue({}) as ContentProvider;
|
||||
const handler = createPartialContentHandler(contentProvider, logger);
|
||||
req.headers = { range: "bytes=30-10" };
|
||||
try {
|
||||
await handler(req, res);
|
||||
expect(statusSpy).toHaveBeenCalledExactlyOnceWith(416);
|
||||
} catch {
|
||||
expect(false);
|
||||
}
|
||||
});
|
||||
it("returns 500 if parseRangeHeader throws other errors", async () => {
|
||||
const parseRangeHeaderStub = vi
|
||||
.spyOn(ParseRangeHeaderExports, "parseRangeHeader")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("Something went wrong!")
|
||||
});
|
||||
const contentProvider = vi.fn().mockResolvedValue({}) as ContentProvider;
|
||||
const handler = createPartialContentHandler(contentProvider, logger);
|
||||
try {
|
||||
await handler(req, res);
|
||||
expect(sendStatusSpy).toHaveBeenCalledExactlyOnceWith(500);
|
||||
} catch {
|
||||
expect(false);
|
||||
}
|
||||
});
|
||||
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");
|
||||
try {
|
||||
await handler(req, res);
|
||||
expect(setContentTypeHeaderSpy).toHaveBeenCalledExactlyOnceWith(content.mimeType, res);
|
||||
expect(setContentDispositionHeaderSpy).toHaveBeenCalledExactlyOnceWith(content.fileName, res);
|
||||
expect(setAcceptRangesHeaderSpy).toHaveBeenCalledExactlyOnceWith(res);
|
||||
expect(setContentLengthHeaderSpy).toHaveBeenCalledExactlyOnceWith(content.totalSize, res);
|
||||
expect(getStreamSpy).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(pipeSpy).toHaveBeenCalledExactlyOnceWith(res);
|
||||
expect(setContentRangeHeaderSpy).not.toHaveBeenCalled();
|
||||
} catch {
|
||||
expect(false);
|
||||
}
|
||||
});
|
||||
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");
|
||||
try {
|
||||
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);
|
||||
} catch {
|
||||
expect(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
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 { ContentDoesNotExistError } from "./ContentDoesNotExistError.js";
|
||||
import {
|
||||
getRangeHeader,
|
||||
setContentRangeHeader,
|
||||
setContentTypeHeader,
|
||||
setContentDispositionHeader,
|
||||
setAcceptRangesHeader,
|
||||
setContentLengthHeader,
|
||||
setCacheControlHeaderNoCache
|
||||
} 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 } = 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);
|
||||
// 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);
|
||||
};
|
||||
}
|
6
packages/express-partial-content/src/index.ts
Normal file
6
packages/express-partial-content/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./Content.js";
|
||||
export * from "./ContentDoesNotExistError.js";
|
||||
export * from "./ContentProvider.js";
|
||||
export * from "./createPartialContentHandler.js";
|
||||
export * from "./Logger.js";
|
||||
export * from "./Range.js";
|
@ -0,0 +1,54 @@
|
||||
import { parseRangeHeader } from "./parseRangeHeader.js";
|
||||
import type { Logger } from "./Logger.js";
|
||||
import { RangeParserError } from "./RangeParserError.js";
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
65
packages/express-partial-content/src/parseRangeHeader.ts
Normal file
65
packages/express-partial-content/src/parseRangeHeader.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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));
|
||||
}
|
111
packages/express-partial-content/src/utils.spec.ts
Normal file
111
packages/express-partial-content/src/utils.spec.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { expect, type Mock } from "vitest";
|
||||
|
||||
import {
|
||||
getHeader,
|
||||
setHeader,
|
||||
getRangeHeader,
|
||||
setContentTypeHeader,
|
||||
setContentLengthHeader,
|
||||
setAcceptRangesHeader,
|
||||
setContentDispositionHeader,
|
||||
setContentRangeHeader,
|
||||
setCacheControlHeaderNoCache
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
});
|
13
packages/express-partial-content/src/utils.ts
Normal file
13
packages/express-partial-content/src/utils.ts
Normal file
@ -0,0 +1,13 @@
|
||||
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");
|
13
packages/express-partial-content/tsconfig.json
Normal file
13
packages/express-partial-content/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
31
packages/express-partial-content/tsconfig.lib.json
Normal file
31
packages/express-partial-content/tsconfig.lib.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"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"
|
||||
]
|
||||
}
|
29
packages/express-partial-content/tsconfig.spec.json
Normal file
29
packages/express-partial-content/tsconfig.spec.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"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"
|
||||
]
|
||||
}
|
23
packages/express-partial-content/vite.config.ts
Normal file
23
packages/express-partial-content/vite.config.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/express-partial-content',
|
||||
plugins: [],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
}
|
||||
},
|
||||
}));
|
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@ -444,8 +444,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/commons
|
||||
'@triliumnext/express-partial-content':
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(express@4.21.2)
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/express-partial-content
|
||||
'@triliumnext/turndown-plugin-gfm':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/turndown-plugin-gfm
|
||||
@ -777,6 +777,12 @@ importers:
|
||||
specifier: ~0.5.11
|
||||
version: 0.5.17
|
||||
|
||||
packages/express-partial-content:
|
||||
dependencies:
|
||||
tslib:
|
||||
specifier: ^2.3.0
|
||||
version: 2.8.1
|
||||
|
||||
packages/turndown-plugin-gfm:
|
||||
dependencies:
|
||||
'@swc/helpers':
|
||||
@ -3263,11 +3269,6 @@ packages:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@triliumnext/express-partial-content@1.0.1':
|
||||
resolution: {integrity: sha512-WQipyCd3AHnKRhkMGUjgvi75L8kRjCyvCituvtdkNt5GLUQeHRyMwEokYJ8uoBgPoJNPLPCjXV1Ig0WE37N7KA==}
|
||||
peerDependencies:
|
||||
express: ^4.16.4
|
||||
|
||||
'@trysound/sax@0.2.0':
|
||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@ -14048,10 +14049,6 @@ snapshots:
|
||||
|
||||
'@tootallnate/once@2.0.0': {}
|
||||
|
||||
'@triliumnext/express-partial-content@1.0.1(express@4.21.2)':
|
||||
dependencies:
|
||||
express: 4.21.2
|
||||
|
||||
'@trysound/sax@0.2.0': {}
|
||||
|
||||
'@tweenjs/tween.js@25.0.0': {}
|
||||
|
@ -29,6 +29,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./apps/edit-docs"
|
||||
},
|
||||
{
|
||||
"path": "./packages/express-partial-content"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user