Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 37 additions & 25 deletions packages/koa/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,21 @@ import { isGeneratorFunction } from 'is-type-of';
import onFinished from 'on-finished';
import statuses from 'statuses';
import compose from 'koa-compose';

import { HttpError } from 'http-errors';
import { Context } from './context.ts';
import { Request } from './request.ts';
import { Response } from './response.ts';
import type { CustomError, AnyProto } from './types.ts';

// Re-export for external use
export { Context, Request, Response };
import { KoaContext } from './context.ts';
import { KoaRequest } from './request.ts';
import { KoaResponse } from './response.ts';
import type { CustomError, AnyProto } from './types.ts';
import { isStream } from './utils.ts';

const debug = debuglog('egg/koa/application');

// oxlint-disable-next-line typescript/no-explicit-any
export type ProtoImplClass<T = object> = new (...args: any[]) => T;
export type Next = () => Promise<void>;
type _MiddlewareFunc<T> = (ctx: T, next: Next) => Promise<void> | void;
export type MiddlewareFunc<T extends Context = Context> = _MiddlewareFunc<T> & {
export type MiddlewareFunc<T extends KoaContext = KoaContext> = _MiddlewareFunc<T> & {
_name?: string;
};

Expand All @@ -47,14 +45,14 @@ export class Application extends Emitter {
proxyIpHeader: string;
maxIpsCount: number;
protected _keys?: string[];
middleware: MiddlewareFunc<Context>[];
ctxStorage: AsyncLocalStorage<Context>;
middleware: MiddlewareFunc<KoaContext>[];
ctxStorage: AsyncLocalStorage<KoaContext>;
silent: boolean;
ContextClass: ProtoImplClass<Context>;
ContextClass: ProtoImplClass<KoaContext>;
context: AnyProto;
RequestClass: ProtoImplClass<Request>;
RequestClass: ProtoImplClass<KoaRequest>;
request: AnyProto;
ResponseClass: ProtoImplClass<Response>;
ResponseClass: ProtoImplClass<KoaResponse>;
response: AnyProto;

/**
Expand Down Expand Up @@ -90,11 +88,11 @@ export class Application extends Emitter {
this.middleware = [];
this.ctxStorage = getAsyncLocalStorage();
this.silent = false;
this.ContextClass = class ApplicationContext extends Context {} as ProtoImplClass<Context>;
this.ContextClass = class ApplicationContext extends KoaContext {} as ProtoImplClass<KoaContext>;
this.context = this.ContextClass.prototype;
this.RequestClass = class ApplicationRequest extends Request {} as ProtoImplClass<Request>;
this.RequestClass = class ApplicationRequest extends KoaRequest {} as ProtoImplClass<KoaRequest>;
this.request = this.RequestClass.prototype;
this.ResponseClass = class ApplicationResponse extends Response {} as ProtoImplClass<Response>;
this.ResponseClass = class ApplicationResponse extends KoaResponse {} as ProtoImplClass<KoaResponse>;
this.response = this.ResponseClass.prototype;
// Set up custom inspect
this[util.inspect.custom] = this.inspect.bind(this);
Expand Down Expand Up @@ -156,7 +154,7 @@ export class Application extends Emitter {
/**
* Use the given middleware `fn`.
*/
use<T extends Context = Context>(fn: MiddlewareFunc<T>): this {
use<T extends KoaContext = KoaContext>(fn: MiddlewareFunc<T>): this {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
const name = fn._name || fn.name || '-';
if (isGeneratorFunction(fn)) {
Expand All @@ -167,7 +165,7 @@ export class Application extends Emitter {
);
}
debug('use %o #%d', name, this.middleware.length);
this.middleware.push(fn as MiddlewareFunc<Context>);
this.middleware.push(fn as MiddlewareFunc<KoaContext>);
return this;
}

Expand Down Expand Up @@ -195,15 +193,15 @@ export class Application extends Emitter {
/**
* return current context from async local storage
*/
get currentContext(): Context | undefined {
get currentContext(): KoaContext | undefined {
return this.ctxStorage.getStore();
}

/**
* Handle request in callback.
* @private
*/
protected async handleRequest(ctx: Context, fnMiddleware: (ctx: Context) => Promise<void>): Promise<void> {
protected async handleRequest(ctx: KoaContext, fnMiddleware: (ctx: KoaContext) => Promise<void>): Promise<void> {
this.emit('request', ctx);
const res = ctx.res;
res.statusCode = 404;
Expand All @@ -227,7 +225,7 @@ export class Application extends Emitter {
* Initialize a new context.
* @private
*/
createContext(req: IncomingMessage, res: ServerResponse): Context {
createContext(req: IncomingMessage, res: ServerResponse): KoaContext {
const context = new this.ContextClass(this, req, res);
return context;
}
Expand All @@ -247,14 +245,13 @@ export class Application extends Emitter {
if (this.silent) return;

const msg = err.stack || err.toString();
// oxlint-disable-next-line no-console
console.error(`\n${msg.replaceAll(/^/gm, ' ')}\n`);
}

/**
* Response helper.
*/
protected _respond(ctx: Context): void {
protected _respond(ctx: KoaContext): void {
// allow bypassing koa
if (ctx.respond === false) return;

Expand Down Expand Up @@ -311,8 +308,23 @@ export class Application extends Emitter {
res.end(body);
return;
}
if (body instanceof Stream) {
body.pipe(res);

// try stream
let stream: Stream.Readable | null = null;
if (body instanceof Blob) {
stream = Stream.Readable.from(body.stream());
} else if (body instanceof ReadableStream) {
stream = Stream.Readable.from(body);
} else if (body instanceof Response) {
stream = Stream.Readable.from(body?.body ?? '');
} else if (isStream(body)) {
stream = body;
}

if (stream) {
Stream.pipeline(stream, res, (err) => {
if (err && ctx.app.listenerCount('error')) ctx.onerror(err);
});
return;
}

Expand Down
26 changes: 13 additions & 13 deletions packages/koa/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import Cookies from 'cookies';
import type { Accepts } from 'accepts';

import type { Application } from './application.ts';
import type { Request, RequestSocket } from './request.ts';
import type { Response } from './response.ts';
import type { KoaRequest, RequestSocket } from './request.ts';
import type { KoaResponse } from './response.ts';
import type { CustomError, AnyProto } from './types.ts';

export class Context {
export class KoaContext {
[key: symbol | string]: unknown;
app: Application;
req: IncomingMessage;
res: ServerResponse;
request: Request & AnyProto;
response: Response & AnyProto;
request: KoaRequest & AnyProto;
response: KoaResponse & AnyProto;
originalUrl: string;
respond?: boolean;
// oxlint-disable-next-line typescript/no-explicit-any
Expand Down Expand Up @@ -403,35 +403,35 @@ export class Context {
* Response delegation.
*/

attachment(...args: Parameters<Response['attachment']>): void {
attachment(...args: Parameters<KoaResponse['attachment']>): void {
return this.response.attachment(...args);
}

redirect(...args: Parameters<Response['redirect']>): void {
redirect(...args: Parameters<KoaResponse['redirect']>): void {
return this.response.redirect(...args);
}

remove(...args: Parameters<Response['remove']>): void {
remove(...args: Parameters<KoaResponse['remove']>): void {
return this.response.remove(...args);
}

vary(...args: Parameters<Response['vary']>): void {
vary(...args: Parameters<KoaResponse['vary']>): void {
return this.response.vary(...args);
}

has(...args: Parameters<Response['has']>): boolean {
has(...args: Parameters<KoaResponse['has']>): boolean {
return this.response.has(...args);
}

set(...args: Parameters<Response['set']>): void {
set(...args: Parameters<KoaResponse['set']>): void {
return this.response.set(...args);
}

append(...args: Parameters<Response['append']>): void {
append(...args: Parameters<KoaResponse['append']>): void {
return this.response.append(...args);
}

flushHeaders(...args: Parameters<Response['flushHeaders']>): void {
flushHeaders(...args: Parameters<KoaResponse['flushHeaders']>): void {
return this.response.flushHeaders(...args);
}

Expand Down
24 changes: 21 additions & 3 deletions packages/koa/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,25 @@ import { Application } from './application.ts';
export default Application;

export * from './application.ts';
export * from './context.ts';
export * from './request.ts';
export * from './response.ts';
export {
KoaContext,
/**
* @deprecated Use `KoaContext` instead, keep compatibility with koa
*/
KoaContext as Context,
} from './context.ts';
export {
KoaRequest,
/**
* @deprecated Use `KoaRequest` instead, keep compatibility with koa
*/
KoaRequest as Request,
} from './request.ts';
export {
KoaResponse,
/**
* @deprecated Use `KoaResponse` instead, keep compatibility with koa
*/
KoaResponse as Response,
} from './response.ts';
export type { CustomError, AnyProto } from './types.ts';
6 changes: 3 additions & 3 deletions packages/koa/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@
import fresh from 'fresh';

import type { Application } from './application.ts';
import type { Context } from './context.ts';

Check failure on line 14 in packages/koa/src/request.ts

View workflow job for this annotation

GitHub Actions / Test (macos-latest, 24, 1/5)

Module '"./context.ts"' has no exported member 'Context'.

Check failure on line 14 in packages/koa/src/request.ts

View workflow job for this annotation

GitHub Actions / Test (macos-latest, 22, 1/5)

Module '"./context.ts"' has no exported member 'Context'.
import type { Response } from './response.ts';
import type { KoaResponse } from './response.ts';

export interface RequestSocket extends Socket {
encrypted: boolean;
}

export class Request {
export class KoaRequest {
[key: symbol]: unknown;
app: Application;
req: IncomingMessage;
res: ServerResponse;
ctx: Context;
response: Response;
response: KoaResponse;
originalUrl: string;

constructor(app: Application, ctx: Context, req: IncomingMessage, res: ServerResponse) {
Expand Down
52 changes: 42 additions & 10 deletions packages/koa/src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,19 @@ import vary from 'vary';
import encodeUrl from 'encodeurl';

import type { Application } from './application.ts';
import type { Context } from './context.ts';
import type { Request } from './request.ts';
import type { KoaContext } from './context.ts';
import type { KoaRequest } from './request.ts';
import { isStream } from './utils.ts';

export class Response {
export class KoaResponse {
[key: symbol]: unknown;
app: Application;
req: IncomingMessage;
res: ServerResponse;
ctx: Context;
request: Request;
ctx: KoaContext;
request: KoaRequest;

constructor(app: Application, ctx: Context, req: IncomingMessage, res: ServerResponse) {
constructor(app: Application, ctx: KoaContext, req: IncomingMessage, res: ServerResponse) {
this.app = app;
this.req = req;
this.res = res;
Expand Down Expand Up @@ -126,6 +127,13 @@ export class Response {
this.remove('Content-Type');
this.remove('Content-Length');
this.remove('Transfer-Encoding');

const shouldDestroyOriginal = original && isStream(original);
if (shouldDestroyOriginal) {
// Ignore errors during cleanup to prevent unhandled exceptions when destroying the stream
original.once('error', () => {});
destroy(original);
}
Comment on lines +131 to +136
Copy link

Copilot AI Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The variable shouldDestroyOriginal is only used once immediately after declaration. Consider simplifying by using the condition directly in the if statement: if (original && isStream(original)).

Copilot uses AI. Check for mistakes.
return;
}

Expand All @@ -150,11 +158,9 @@ export class Response {
}

// stream
if (val instanceof Stream) {
if (isStream(val)) {
onFinish(this.res, destroy.bind(null, val));
// oxlint-disable-next-line eqeqeq
if (original != val) {
val.once('error', (err) => this.ctx.onerror(err));
// overwriting
if (original !== null && original !== undefined) {
this.remove('Content-Length');
Expand All @@ -167,6 +173,32 @@ export class Response {
return;
}

// ReadableStream
if (val instanceof ReadableStream) {
if (setType) this.type = 'bin';
return;
}

// blob
if (val instanceof Blob) {
if (setType) this.type = 'bin';
this.length = val.size;
return;
}

// Response
if (val instanceof Response) {
this.status = val.status;
if (setType) this.type = 'bin';
const headers = val.headers;
console.log('headers', headers);
Copy link

Copilot AI Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug console.log statement should be removed before merging to production. This appears to be leftover debugging code that will pollute production logs.

Suggested change
console.log('headers', headers);

Copilot uses AI. Check for mistakes.
for (const key of headers.keys()) {
this.set(key, headers.get(key)!);
}

return;
}

// json
this.remove('Content-Length');
this.type = 'json';
Expand All @@ -193,7 +225,7 @@ export class Response {
}

const body = this.body;
if (!body || body instanceof Stream) {
if (!body || isStream(body)) {
return undefined;
}
if (typeof body === 'string') {
Expand Down
17 changes: 17 additions & 0 deletions packages/koa/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Stream from 'node:stream';

// https://github.com/koajs/koa/blob/master/lib/is-stream.js
export function isStream(stream: any): stream is Stream.Readable {
return (
stream instanceof Stream ||
(stream !== null &&
typeof stream === 'object' &&
!!stream.readable &&
typeof stream.pipe === 'function' &&
typeof stream.read === 'function' &&
typeof stream.readable === 'boolean' &&
typeof stream.readableObjectMode === 'boolean' &&
typeof stream.destroy === 'function' &&
typeof stream.destroyed === 'boolean')
);
}
3 changes: 3 additions & 0 deletions packages/koa/test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ exports[`should export Koa class 1`] = `
exports[`should export Koa class 2`] = `
[
"default",
"KoaContext",
"Context",
"KoaRequest",
"Request",
"KoaResponse",
"Response",
"Application",
]
Expand Down
Loading
Loading