Compare commits

...

10 Commits

Author SHA1 Message Date
William Valentin 7d7dc4eb39 chore: ignore repo log files 2025-10-17 09:55:25 -07:00
William Valentin 894c71309b chore(k8s): align deployment scale and secrets 2025-10-17 09:55:17 -07:00
William Valentin 5eec64fffb feat(api): migrate S3 client with structured logs 2025-10-17 09:54:58 -07:00
William Valentin 994da9a4e1 feat(api): adopt pino request logging 2025-10-17 09:54:46 -07:00
William Valentin 37411bc890 build(api): refresh bun config 2025-10-17 09:54:14 -07:00
William Valentin aec89c6f5a chore: rename ffmpeg package entry 2025-10-17 09:53:58 -07:00
William Valentin a3e4faa7e9 Add common Node.js ignore patterns 2025-08-30 19:25:41 -07:00
William Valentin 1991b9b6b6 Add .vscode/extensions.json and s3-nodejs-api/.zed/settings.json 2025-08-30 19:17:49 -07:00
William Valentin 7f93017686 Refactor resource names from s3-media-streamer to media-streamer for consistency 2025-08-16 00:54:55 -07:00
William Valentin 533b48f4a4 Rename audio-streamer resources to media-streamer for consistency 2025-08-15 23:48:44 -07:00
24 changed files with 5723 additions and 1122 deletions
+1
View File
@@ -0,0 +1 @@
*.log
+13
View File
@@ -0,0 +1,13 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": [
]
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "minio-audio-streamer", "name": "s3-ffmpeg-stream",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": {} "packages": {}
} }
+15
View File
@@ -1,2 +1,17 @@
node_modules/ node_modules/
dist/ dist/
*.log
.env
.DS_Store
coverage/
.idea/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.sass-cache/
.cache/
.next/
out/
build/
View File
+9 -12
View File
@@ -1,20 +1,17 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ARG NODE_VERSION=20-alpine ARG BUN_VERSION=1.0.0
# FROM node:${NODE_VERSION} AS base FROM oven/bun:${BUN_VERSION} AS base
FROM oven/bun:latest AS base
WORKDIR /app WORKDIR /app
FROM base AS deps FROM base AS deps
COPY package.json ./ COPY package.json bun.lock ./
# Install only production dependencies for the runtime image RUN bun install --production --no-cache
RUN bun install --omit=dev
FROM base AS build FROM base AS build
COPY package.json ./ COPY package.json bun.lock ./
# Install all dependencies for building (includes devDeps like typescript) RUN bun install --no-cache
RUN bun install
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY src ./src COPY src ./src
RUN bun run build RUN bun run build
@@ -23,8 +20,8 @@ FROM base AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
USER bun USER bun
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --link --from=deps /app/node_modules ./node_modules
COPY --link --from=build /app/dist ./dist
COPY package.json ./ COPY package.json ./
COPY --from=build /app/dist ./dist
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "dist/app.js"] CMD ["bun", "run", "dist/app.js"]
+238 -922
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
name: audio-streamer-config name: media-streamer-config
data: data:
S3_BUCKET: "zik" S3_BUCKET: "zik"
AWS_REGION: "us-east-1" AWS_REGION: "us-east-1"
+15 -15
View File
@@ -1,22 +1,22 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: s3-audio-streamer name: media-streamer
labels: labels:
app: s3-audio-streamer app: media-streamer
spec: spec:
replicas: 2 replicas: 1
selector: selector:
matchLabels: matchLabels:
app: s3-audio-streamer app: media-streamer
template: template:
metadata: metadata:
labels: labels:
app: s3-audio-streamer app: media-streamer
spec: spec:
containers: containers:
- name: s3-audio-streamer - name: media-streamer
image: gitea-http.taildb3494.ts.net/will/s3-audio-streamer image: gitea-http.taildb3494.ts.net/will/media-streamer
ports: ports:
- containerPort: 3000 - containerPort: 3000
env: env:
@@ -24,41 +24,41 @@ spec:
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: aws-secret name: aws-secret
key: access-key-id key: AWS_ACCESS_KEY_ID
- name: AWS_SECRET_ACCESS_KEY - name: AWS_SECRET_ACCESS_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: aws-secret name: aws-secret
key: secret-access-key key: AWS_SECRET_ACCESS_KEY
- name: AWS_REGION - name: AWS_REGION
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:
name: audio-streamer-config name: media-streamer-config
key: AWS_REGION key: AWS_REGION
- name: S3_BUCKET - name: S3_BUCKET
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:
name: audio-streamer-config name: media-streamer-config
key: S3_BUCKET key: S3_BUCKET
- name: S3_ENDPOINT - name: S3_ENDPOINT
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:
name: audio-streamer-config name: media-streamer-config
key: S3_ENDPOINT key: S3_ENDPOINT
- name: S3_FORCE_PATH_STYLE - name: S3_FORCE_PATH_STYLE
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:
name: audio-streamer-config name: media-streamer-config
key: S3_FORCE_PATH_STYLE key: S3_FORCE_PATH_STYLE
- name: S3_PREFIX - name: S3_PREFIX
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:
name: audio-streamer-config name: media-streamer-config
key: S3_PREFIX key: S3_PREFIX
- name: PORT - name: PORT
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:
name: audio-streamer-config name: media-streamer-config
key: PORT key: PORT
readinessProbe: readinessProbe:
httpGet: httpGet:
+3 -4
View File
@@ -1,13 +1,12 @@
apiVersion: autoscaling/v2beta2 apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler kind: HorizontalPodAutoscaler
metadata: metadata:
name: s3-audio-streamer-hpa name: media-streamer-hpa
namespace: default
spec: spec:
scaleTargetRef: scaleTargetRef:
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
name: s3-audio-streamer name: media-streamer
minReplicas: 1 minReplicas: 1
maxReplicas: 10 maxReplicas: 10
metrics: metrics:
@@ -16,4 +15,4 @@ spec:
name: cpu name: cpu
target: target:
type: Utilization type: Utilization
averageUtilization: 50 averageUtilization: 50
+3 -3
View File
@@ -1,19 +1,19 @@
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: audio-streamer-ingress name: media-streamer-ingress
annotations: annotations:
nginx.ingress.kubernetes.io/rewrite-target: / nginx.ingress.kubernetes.io/rewrite-target: /
spec: spec:
rules: rules:
- host: s3-audio-streamer.192.168.153.243.nip.io - host: media-streamer.192.168.153.243.nip.io
http: http:
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: s3-audio-streamer name: media-streamer
port: port:
number: 3000 number: 3000
ingressClassName: nginx ingressClassName: nginx
+2 -3
View File
@@ -2,8 +2,7 @@ apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
name: aws-secret name: aws-secret
namespace: default
type: Opaque type: Opaque
data: data:
access-key-id: bXZ3ZldTc1Q3b2RkdTE0amxuMHk= AWS_ACCESS_KEY_ID: ZW1ZeVVERjZlVkJvTnpaU1RYQTRhemhvV2s4Sw==
secret-access-key: Y1dscWVsQTFabGxTU0ZsdU4xTktWRnBxU2s1MFRrbDFNVkpSZW01UlkyaHdkREpCYVRkTmRBbz0= AWS_SECRET_ACCESS_KEY: b1FZQ3Axb2MyRHd5U2RxSkZwVVg3bjgxZU5qV0MyaDVScEpNQU5GNQ==
+3 -3
View File
@@ -1,9 +1,9 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: s3-audio-streamer name: media-streamer
labels: labels:
app: s3-audio-streamer app: media-streamer
spec: spec:
type: ClusterIP type: ClusterIP
ports: ports:
@@ -11,4 +11,4 @@ spec:
targetPort: 3000 targetPort: 3000
protocol: TCP protocol: TCP
selector: selector:
app: s3-audio-streamer app: media-streamer
+5139
View File
File diff suppressed because it is too large Load Diff
+14 -15
View File
@@ -4,27 +4,26 @@
"description": "A simple application to stream audio files stored in S3.", "description": "A simple application to stream audio files stored in S3.",
"main": "dist/app.js", "main": "dist/app.js",
"scripts": { "scripts": {
"start": "bun dist/app.js", "start": "bun run dist/app.js",
"start:dev": "ts-node src/app.ts", "start:dev": "bun run --watch src/app.ts",
"build": "tsc -p tsconfig.json", "build": "bun run build:tsc",
"test": "jest" "build:tsc": "tsc -p tsconfig.json",
"test": "bun test"
}, },
"dependencies": { "dependencies": {
"aws-sdk": "^2.1234.0", "@aws-sdk/client-s3": "^3.879.0",
"express": "^4.17.1", "express": "^4.18.2",
"morgan": "^1.10.0" "pino": "^9.9.0",
"pino-http": "^10.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/aws-sdk": "^2.7.0", "@types/express": "^4.17.21",
"@types/express": "^4.17.0", "@types/jest": "^29.5.12",
"@types/jest": "^26.0.0",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"jest": "^26.6.0", "ts-node": "^10.9.2",
"ts-jest": "^26.5.0", "typescript": "^5.3.3"
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=18.0.0"
} }
} }
+10 -7
View File
@@ -1,12 +1,15 @@
import express from 'express'; import express from "express";
import { setRoutes } from './routes/index'; import { setRoutes } from "./routes/index";
import { errorHandler } from './middlewares/errorHandler'; import { errorHandler } from "./middlewares/errorHandler";
import requestLogger from './middlewares/requestLogger'; import pino from "pino";
import pinoHttp from "pino-http";
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.use(requestLogger); app.set("logger", logger);
app.use(pinoHttp({ logger }));
app.use(express.json()); app.use(express.json());
setRoutes(app); setRoutes(app);
@@ -14,5 +17,5 @@ setRoutes(app);
app.use(errorHandler); app.use(errorHandler);
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`); logger.info(`Server is running on port ${PORT}`);
}); });
@@ -1,23 +1,34 @@
import { Request, Response } from 'express'; import { Request, Response } from "express";
import StreamService from '../services/streamService'; import StreamService from "../services/streamService";
import { Logger } from "pino";
export default class StreamController { export default class StreamController {
constructor(private streamService: StreamService) { } constructor(
private streamService: StreamService,
private logger: Logger,
) {}
// GET /api/streams/:key // GET /api/streams/:key
async streamAudio(req: Request, res: Response) { async streamAudio(req: Request, res: Response) {
const { fileName: key } = req.params as { fileName: string }; const { fileName: key } = req.params as { fileName: string };
const range = req.headers.range as string | undefined; const range = req.headers.range as string | undefined;
if (!key) return res.status(400).send('Missing file key'); if (!key) return res.status(400).send("Missing file key");
try { try {
const result = await this.streamService.streamFromS3({ key, range }); this.logger.info(
res.writeHead(result.status, result.headers); `Attempting to stream file: ${key}, range: ${range || "none"}`,
result.body.pipe(res); );
} catch (err: any) { const result = await this.streamService.streamFromS3({ key, range });
const message = err?.message || 'Error streaming audio'; this.logger.info(
const status = err?.statusCode || 500; `Streaming successful for ${key}, status: ${result.status}, content-length: ${result.headers["Content-Length"]}`,
res.status(status).send(message); );
} res.writeHead(result.status, result.headers);
result.body.pipe(res);
} catch (err: any) {
this.logger.error({ err, key }, `Error streaming audio file ${key}`);
const message = err?.message || "Error streaming audio";
const status = err?.statusCode || 500;
res.status(status).send(message);
} }
} }
}
@@ -1,8 +0,0 @@
import { Request, Response, NextFunction } from 'express';
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
console.log(`${req.method} ${req.url}`);
next();
};
export default requestLogger;
+21 -11
View File
@@ -1,18 +1,28 @@
import { Application, Router, Request, Response } from 'express'; import { Application, Router, Request, Response } from "express";
import StreamController from '../controllers/streamController'; import StreamController from "../controllers/streamController";
import StreamService from '../services/streamService'; import StreamService from "../services/streamService";
const router = Router(); const router = Router();
const streamService = new StreamService();
const streamController = new StreamController(streamService);
export function setRoutes(app: Application) { export function setRoutes(app: Application) {
// Health endpoints // Health endpoints
app.get('/', (_req: Request, res: Response) => res.status(200).send('OK')); app.get("/", (_req: Request, res: Response) => res.status(200).send("OK"));
app.get('/healthz', (_req: Request, res: Response) => res.status(200).json({ status: 'ok' })); app.get("/healthz", (_req: Request, res: Response) =>
res.status(200).json({ status: "ok" }),
);
app.use('/api/streams', router); const streamService = new StreamService(
router.get('/:fileName', streamController.streamAudio.bind(streamController)); app.get("logger"),
process.env.S3_BUCKET,
process.env.S3_PREFIX,
);
const streamController = new StreamController(
streamService,
app.get("logger"),
);
router.get("/:fileName", streamController.streamAudio.bind(streamController));
app.use("/api/streams", router);
} }
export default router; export default router;
-11
View File
@@ -1,11 +0,0 @@
import { Router } from 'express';
import StreamController from '../controllers/streamController';
import StreamService from '../services/streamService';
const router = Router();
const streamService = new StreamService();
const streamController = new StreamController(streamService);
router.get('/:fileName', streamController.streamAudio.bind(streamController));
export default router;
+100 -36
View File
@@ -1,49 +1,113 @@
import { S3 } from 'aws-sdk'; import {
import { Readable } from 'stream'; S3Client as AwsS3Client,
HeadObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { Readable } from "stream";
import { Logger } from "pino";
export interface GetObjectStreamResult { export interface GetObjectStreamResult {
stream: Readable; stream: Readable;
contentLength: number; // length of the returned body contentLength: number; // length of the returned body
totalLength: number; // full object size contentType?: string;
contentType?: string; contentRange?: string;
contentRange?: string;
} }
export default class S3Client { export default class S3Client {
private s3: S3; private s3: AwsS3Client;
private logger: Logger;
constructor() { constructor(logger: Logger) {
this.s3 = new S3({ this.logger = logger;
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID, const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
endpoint: process.env.S3_ENDPOINT || undefined, if (!accessKeyId || !secretAccessKey) {
}); throw new Error(
"AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.",
);
} }
async headObject(bucket: string, key: string) { const config = {
return this.s3.headObject({ Bucket: bucket, Key: key }).promise(); region: process.env.AWS_REGION,
credentials: {
accessKeyId,
secretAccessKey,
},
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
endpoint: process.env.S3_ENDPOINT || undefined,
};
this.logger.info(
{
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
},
"S3Client: Initializing with config",
);
this.s3 = new AwsS3Client(config);
}
async headObject(bucket: string, key: string) {
this.logger.info({ bucket, key }, `S3Client: headObject`);
try {
const command = new HeadObjectCommand({ Bucket: bucket, Key: key });
const result = await this.s3.send(command);
this.logger.info(
{ size: result.ContentLength, type: result.ContentType },
`S3Client: headObject success`,
);
return result;
} catch (error) {
this.logger.error({ error, bucket, key }, `S3Client: headObject error`);
throw error;
}
}
async getObjectStream(
bucket: string,
key: string,
totalLength: number,
contentType: string | undefined,
range?: { start: number; end: number },
): Promise<GetObjectStreamResult> {
this.logger.info(
{ bucket, key, range: range ? `${range.start}-${range.end}` : "none" },
`S3Client: getObjectStream`,
);
const params = {
Bucket: bucket,
Key: key,
Range: undefined as string | undefined,
};
let contentRange: string | undefined;
if (range && totalLength > 0) {
params.Range = `bytes=${range.start}-${range.end}`;
contentRange = `bytes ${range.start}-${range.end}/${totalLength}`;
} }
async getObjectStream(bucket: string, key: string, range?: { start: number; end: number }): Promise<GetObjectStreamResult> { try {
const head = await this.headObject(bucket, key); const command = new GetObjectCommand(params);
const totalLength = head.ContentLength || 0; const result = await this.s3.send(command);
const contentType = head.ContentType; const stream = result.Body as Readable;
const params: S3.GetObjectRequest = { Bucket: bucket, Key: key }; const contentLength = result.ContentLength || 0;
let contentRange: string | undefined;
if (range && totalLength > 0) {
params.Range = `bytes=${range.start}-${range.end}`;
contentRange = `bytes ${range.start}-${range.end}/${totalLength}`;
}
const req = this.s3.getObject(params); this.logger.info(
const stream = req.createReadStream(); { contentLength, totalLength },
`S3Client: getObjectStream success`,
// When a range is requested, S3 returns partial content length (end-start+1) );
const contentLength = range && totalLength > 0 ? range.end - range.start + 1 : totalLength; return { stream, contentLength, contentType, contentRange };
} catch (error) {
return { stream, contentLength, totalLength, contentType, contentRange }; this.logger.error(
{ error, bucket, key },
`S3Client: getObjectStream error`,
);
throw error;
} }
} }
}
+93 -49
View File
@@ -1,57 +1,101 @@
import { Readable } from 'stream'; import { Readable } from "stream";
import S3Client, { GetObjectStreamResult } from './s3Client'; import S3Client, { GetObjectStreamResult } from "./s3Client";
import { rangeParser } from '../utils/rangeParser'; import { rangeParser } from "../utils/rangeParser";
import { Logger } from "pino";
export interface StreamResult { export interface StreamResult {
status: number; status: number;
headers: Record<string, string>; headers: Record<string, string>;
body: Readable; body: Readable;
} }
export default class StreamService { export default class StreamService {
private s3: S3Client; private s3: S3Client;
private bucket: string; private bucket: string;
private prefix: string; private prefix: string;
private logger: Logger;
constructor(bucket = process.env.S3_BUCKET || '', prefix = process.env.S3_PREFIX || '') { constructor(
this.s3 = new S3Client(); logger: Logger,
this.bucket = bucket; bucket = process.env.S3_BUCKET || "",
this.prefix = (prefix || '').replace(/^\/+|\/+$/g, ''); // trim leading/trailing '/' prefix = process.env.S3_PREFIX || "",
) {
this.s3 = new S3Client(logger);
this.bucket = bucket;
this.prefix = (prefix || "").replace(/^\/+|\/+$/g, ""); // trim leading/trailing '/'
this.logger = logger;
}
async streamFromS3({
key,
range,
}: {
key: string;
range?: string;
}): Promise<StreamResult> {
if (!this.bucket) throw new Error("S3_BUCKET not configured");
const actualKey = this.buildKey(key);
this.logger.info(
`StreamService: Attempting to stream from bucket: ${this.bucket}, key: ${actualKey}`,
);
try {
// Determine byte range
let parsedRange: { start: number; end: number } | undefined;
// We need object size to parse range properly, so head first
this.logger.info(`StreamService: Getting head object for ${actualKey}`);
const head = await this.s3.headObject(this.bucket, actualKey);
const total = head.ContentLength || 0;
const contentType = head.ContentType;
this.logger.info(
`StreamService: Object size: ${total}, content-type: ${contentType}`,
);
if (range) {
const r = rangeParser(range, total);
if (r) parsedRange = r;
this.logger.info(
`StreamService: Parsed range: ${r ? `${r.start}-${r.end}` : "invalid"}`,
);
}
this.logger.info(`StreamService: Getting object stream for ${actualKey}`);
const { stream, contentLength, contentRange }: GetObjectStreamResult =
await this.s3.getObjectStream(
this.bucket,
actualKey,
total,
contentType,
parsedRange,
);
const isPartial = Boolean(parsedRange);
const status = isPartial ? 206 : 200;
const headers: Record<string, string> = {
"Content-Type": contentType || "audio/mp4",
"Accept-Ranges": "bytes",
"Content-Length": String(contentLength),
};
if (isPartial && contentRange) headers["Content-Range"] = contentRange;
this.logger.info(
{ headers, status },
`StreamService: Successfully prepared stream`,
);
return { status, headers, body: stream };
} catch (error) {
this.logger.error(
{ error, actualKey },
`StreamService: Error in streamFromS3`,
);
throw error;
} }
}
async streamFromS3({ key, range }: { key: string; range?: string }): Promise<StreamResult> { private buildKey(key: string): string {
if (!this.bucket) throw new Error('S3_BUCKET not configured'); const cleanKey = (key || "").replace(/^\/+/, "");
if (!this.prefix) return cleanKey;
const actualKey = this.buildKey(key); return `${this.prefix}/${cleanKey}`;
}
// Determine byte range }
let parsedRange: { start: number; end: number } | undefined;
// We need object size to parse range properly, so head first
const head = await this.s3.headObject(this.bucket, actualKey);
const total = head.ContentLength || 0;
if (range) {
const r = rangeParser(range, total);
if (r) parsedRange = r;
}
const { stream, contentLength, totalLength, contentType, contentRange }: GetObjectStreamResult =
await this.s3.getObjectStream(this.bucket, actualKey, parsedRange);
const isPartial = Boolean(parsedRange);
const status = isPartial ? 206 : 200;
const headers: Record<string, string> = {
'Content-Type': contentType || 'audio/mp4',
'Accept-Ranges': 'bytes',
'Content-Length': String(contentLength),
};
if (isPartial && contentRange) headers['Content-Range'] = contentRange;
return { status, headers, body: stream };
}
private buildKey(key: string): string {
const cleanKey = (key || '').replace(/^\/+/, '');
if (!this.prefix) return cleanKey;
return `${this.prefix}/${cleanKey}`;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Logger } from 'pino';
declare global {
namespace Express {
interface Request {
log: Logger;
}
}
}
+3 -2
View File
@@ -7,8 +7,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src",
"typeRoots": ["./src/types", "./node_modules/@types"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "**/*.spec.ts"] "exclude": ["node_modules", "**/*.spec.ts"]
} }