Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: Use stream around MediaProxy and FileServer #9453

Closed
wants to merge 40 commits into from
Closed
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
453ba25
wip?
tamaina Jan 1, 2023
038d719
clean up
tamaina Jan 1, 2023
49da902
Implement? HttpFetchService
tamaina Jan 2, 2023
80f9a81
:v:
tamaina Jan 2, 2023
b05254f
remove node-fetch
tamaina Jan 2, 2023
e55c7d5
fix
tamaina Jan 2, 2023
64f59ea
refactor
tamaina Jan 2, 2023
3b450ce
fix
tamaina Jan 2, 2023
badca16
gateway timeout
tamaina Jan 2, 2023
81bf003
UndiciFetcherクラスを追加 (仮コミット, ビルドもstartもさせていない)
tamaina Jan 3, 2023
0f06a9e
fix
tamaina Jan 3, 2023
0048f15
add logger and fix url preview
tamaina Jan 3, 2023
071a173
fix ip check
tamaina Jan 3, 2023
e15c773
enhance logger and error handling
tamaina Jan 3, 2023
372a7ec
fix
tamaina Jan 3, 2023
560c1c2
Merge branch 'develop' into fetch
tamaina Jan 3, 2023
b30d820
fix
tamaina Jan 3, 2023
ab16810
clean up
tamaina Jan 3, 2023
f14150c
Use custom fetcher for ApRequest / ApResolver
tamaina Jan 4, 2023
50b98a9
bypassProxyはproxyBypassHostsに判断を委譲するように
tamaina Jan 4, 2023
cd13046
set maxRedirections (default 3, ApRequest/ApResolver: 0)
tamaina Jan 6, 2023
a58771c
Merge branch 'fetch' into serve-stream
tamaina Jan 6, 2023
ca0025b
fix
tamaina Jan 6, 2023
12afc29
wip????
tamaina Jan 6, 2023
f69e573
wip
tamaina Jan 7, 2023
4aaa200
Merge branch 'develop' into serve-stream
tamaina Jan 7, 2023
16df492
Merge branch 'develop' into serve-stream
tamaina Jan 15, 2023
fc75156
:v:
tamaina Jan 15, 2023
fed9380
set .node-version
tamaina Jan 15, 2023
474c72d
clean up
tamaina Jan 15, 2023
e8c196d
refactor
tamaina Jan 15, 2023
510817a
clean up
tamaina Jan 15, 2023
d57f5d2
refactor
tamaina Jan 15, 2023
00866c7
Merge branch 'develop' into serve-stream
tamaina Jan 15, 2023
cdef862
refactor detectRequestType
tamaina Jan 15, 2023
13b5002
Merge branch 'develop' into serve-stream
tamaina Jan 23, 2023
88b41f8
rename detectResponseType
tamaina Jan 23, 2023
9623e8a
no got
tamaina Jan 24, 2023
6f6f257
remove got
tamaina Jan 24, 2023
cb2823d
Merge branch 'develop' into serve-stream
tamaina Jan 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v18.12.1
v18.13.0
37 changes: 34 additions & 3 deletions packages/backend/src/core/DownloadService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ import { StatusError } from '@/misc/status-error.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { buildConnector } from 'undici';
import type { Response } from 'undici';

const pipeline = util.promisify(stream.pipeline);
import { bindThis } from '@/decorators.js';

export type NonNullBodyResponse = Response & {
body: ReadableStream;
clone: () => NonNullBodyResponse;
};

@Injectable()
export class DownloadService {
private logger: Logger;
Expand Down Expand Up @@ -52,8 +58,8 @@ export class DownloadService {
}

@bindThis
public async downloadUrl(url: string, path: string): Promise<void> {
this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
public async fetchUrl(url: string): Promise<NonNullBodyResponse> {
this.logger.info(`Downloading ${chalk.cyan(url)} ...`);

const timeout = 30 * 1000;
const operationTimeout = 60 * 1000;
Expand All @@ -65,8 +71,33 @@ export class DownloadService {
throw new StatusError('No body', 400, 'No body');
}

await pipeline(stream.Readable.fromWeb(response.body), fs.createWriteStream(path));
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);

return response as NonNullBodyResponse;
}

@bindThis
public async pipeRequestToFile(_response: Response, path: string): Promise<void> {
const response = _response.clone();
if (response.body == null) {
throw new StatusError('No body', 400, 'No body');
}

try {
this.logger.info(`Saving File to ${chalk.cyanBright(path)} from downloading ...`);
await pipeline(stream.Readable.fromWeb(response.body), fs.createWriteStream(path));
} catch (e) {
if (e instanceof Got.HTTPError) {
throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
} else {
throw e;
}
}
}

@bindThis
public async downloadUrl(url: string, path: string): Promise<void> {
await this.pipeRequestToFile(await this.fetchUrl(url), path);
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
}

Expand Down
55 changes: 49 additions & 6 deletions packages/backend/src/core/FileInfoService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as stream from 'node:stream';
import * as util from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import { FSWatcher } from 'chokidar';
import { fileTypeFromFile } from 'file-type';
import { fileTypeFromFile, fileTypeFromStream } from 'file-type';
import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
Expand All @@ -15,6 +15,8 @@ import { encode } from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
import { AiService } from '@/core/AiService.js';
import { bindThis } from '@/decorators.js';
import { Response } from 'undici';
import { StatusError } from '@/misc/status-error.js';

const pipeline = util.promisify(stream.pipeline);

Expand All @@ -39,7 +41,7 @@ const TYPE_OCTET_STREAM = {
ext: null,
};

const TYPE_SVG = {
export const TYPE_SVG = {
mime: 'image/svg+xml',
ext: 'svg',
};
Expand Down Expand Up @@ -306,9 +308,9 @@ export class FileInfoService {
*/
@bindThis
public async detectType(path: string): Promise<{
mime: string;
ext: string | null;
}> {
mime: string;
ext: string | null;
}> {
// Check 0 byte
const fileSize = await this.getFileSize(path);
if (fileSize === 0) {
Expand Down Expand Up @@ -338,6 +340,47 @@ export class FileInfoService {
return TYPE_OCTET_STREAM;
}

/**
* Detect MIME Type and extension by stream and path for performance
*/
@bindThis
public async detectRequestType(_response: Response, path?: string, fileSavingPromise: Promise<any> = Promise.resolve()): Promise<{
tamaina marked this conversation as resolved.
Show resolved Hide resolved
mime: string;
ext: string | null;
}> {
const response = _response.clone();

if (!response.body) {
throw new StatusError('No Body', 400, 'No Body');
}

const type = await fileTypeFromStream(stream.Readable.fromWeb(response.body));

if (type) {
// XMLはSVGかもしれない
if (path && type.mime === 'application/xml') {
await fileSavingPromise;
if (await this.checkSvg(path)) {
return TYPE_SVG;
}
}

return {
mime: type.mime,
ext: type.ext,
};
}

// 種類が不明でもSVGかもしれない
if (path) {
await fileSavingPromise;
if (await this.checkSvg(path)) return TYPE_SVG;
}

// 種類が不明なら application/octet-stream にする
return TYPE_OCTET_STREAM;
}

/**
* Check the file is SVG or not
*/
Expand All @@ -346,7 +389,7 @@ export class FileInfoService {
try {
const size = await this.getFileSize(path);
if (size > 1 * 1024 * 1024) return false;
return isSvg(fs.readFileSync(path));
return isSvg(await fs.promises.readFile(path));
} catch {
return false;
}
Expand Down
45 changes: 37 additions & 8 deletions packages/backend/src/core/ImageProcessingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@ import { Inject, Injectable } from '@nestjs/common';
import sharp from 'sharp';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { ReadableStream } from 'node:stream/web';
import { Readable } from 'node:stream';

export type IImage = {
data: Buffer;
ext: string | null;
type: string;
};

export type IImageStream = {
data: NodeJS.ReadableStream;
ext: string | null;
type: string;
};

export type IImageStreamable = IImage | IImageStream;

export const webpDefault: sharp.WebpOptions = {
quality: 85,
alphaQuality: 95,
Expand Down Expand Up @@ -62,21 +72,40 @@ export class ImageProcessingService {
* Convert to WebP
* with resize, remove metadata, resolve orientation, stop animation
*/
@bindThis
public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): sharp.Sharp {
return sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.rotate()
.webp(options);
}

@bindThis
public convertSharpToWebpStreamObj(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream {
return {
data: this.convertSharpToWebpStream(sharp, width, height, options),
ext: 'webp',
type: 'image/webp',
}
}

@bindThis
public convertToWebpFromWebReadable(readable: ReadableStream | null, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream {
if (readable == null) throw new Error('Input is null');
return this.convertSharpToWebpStreamObj(Readable.fromWeb(readable).pipe(sharp()), width, height, options);
}

@bindThis
public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
return this.convertSharpToWebp(await sharp(path), width, height, options);
}

@bindThis
public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.rotate()
.webp(options)
.toBuffer();
const data = await this.convertSharpToWebpStream(sharp, width, height, options).toBuffer();

return {
data,
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default class Logger {
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;

console.log(important ? chalk.bold(log) : log);
if (level === 'error' && data) console.log(data);

if (store) {
if (this.syslogClient) {
Expand Down
33 changes: 24 additions & 9 deletions packages/backend/src/server/FileServerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ import { createTemp } from '@/misc/create-temp.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { StatusError } from '@/misc/status-error.js';
import type Logger from '@/logger.js';
import { DownloadService } from '@/core/DownloadService.js';
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import { DownloadService, NonNullBodyResponse } from '@/core/DownloadService.js';
import { ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js';
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
import { InternalStorageService } from '@/core/InternalStorageService.js';
import { contentDisposition } from '@/misc/content-disposition.js';
import { FileInfoService } from '@/core/FileInfoService.js';
import { FileInfoService, TYPE_SVG } from '@/core/FileInfoService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { Readable } from 'node:stream';

const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
Expand Down Expand Up @@ -106,29 +107,43 @@ export class FileServerService {
if (!file.storedInternal) {
if (file.isLink && file.uri) { // 期限切れリモートファイル
const [path, cleanup] = await createTemp();

try {
await this.downloadService.downloadUrl(file.uri, path);
const response = await this.downloadService.fetchUrl(file.uri);
const fileSaving = this.downloadService.pipeRequestToFile(response, path);

const { mime, ext } = await this.fileInfoService.detectType(path);
const { mime, ext } = await this.fileInfoService.detectRequestType(response, path, fileSaving);

const convertFile = async () => {
if (isThumbnail) {
if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(mime)) {
return await this.imageProcessingService.convertToWebp(path, 498, 280);
return this.imageProcessingService.convertToWebpFromWebReadable(
response.body,
498,
280
);
} else if (mime.startsWith('video/')) {
await fileSaving;
return await this.videoProcessingService.generateVideoThumbnail(path);
}
}

if (isWebpublic) {
if (['image/svg+xml'].includes(mime)) {
return await this.imageProcessingService.convertToPng(path, 2048, 2048);
return {
data: this.imageProcessingService.convertToWebpFromWebReadable(
response.body,
2048,
2048,
{ ...webpDefault, lossless: true }
),
ext: 'webp',
type: 'image/webp',
};
}
}

return {
data: fs.readFileSync(path),
data: Readable.fromWeb(response.body),
ext,
type: mime,
};
Expand Down