egg
Version:
A web application framework for Node.js
641 lines (640 loc) • 22.2 kB
JavaScript
import { BaseHookClass } from "./core/base_hook_class.js";
import { BaseContextClass as BaseContextClass$1 } from "./core/base_context_class.js";
import Context from "../app/extend/context.js";
import Response from "../app/extend/response.js";
import Request from "../app/extend/request.js";
import { ContextHttpClient } from "./core/context_httpclient.js";
import { HttpClient } from "./core/httpclient.js";
import { createLoggers } from "./core/logger.js";
import { create } from "./core/messenger/index.js";
import { convertObject, createTransparentProxy } from "./core/utils.js";
import assert from "node:assert";
import { EggCore, ManifestStore, Router, utils } from "@eggjs/core";
import path from "node:path";
import fs from "node:fs";
import http from "node:http";
import inspector from "node:inspector";
import { performance } from "node:perf_hooks";
import { Cookies } from "@eggjs/cookies";
import { extend } from "@eggjs/extend2";
import CircularJSON from "circular-json-for-egg";
import createClusterClient, { close } from "cluster-client";
import { EggContextLogger } from "egg-logger";
//#region src/lib/egg.ts
/**
* Based on koa's Application
* @see https://github.com/eggjs/egg-core
* @see https://github.com/eggjs/koa/blob/master/src/application.ts
* @augments EggCore
*/
var EggApplicationCore = class extends EggCore {
/**
* Get the current request context from AsyncLocalStorage.
* This provides access to the context object for the current request lifecycle.
* @returns {Context | undefined} The current request context, or undefined if not in a request scope.
*/
get currentContext() {
return this.ctxStorage.getStore();
}
ContextCookies = Cookies;
ContextLogger = EggContextLogger;
ContextHttpClient = ContextHttpClient;
HttpClient = HttpClient;
HttpClientNext = HttpClient;
/**
* Retrieve base context class
* @member {BaseContextClass} BaseContextClass
* @since 1.0.0
*/
BaseContextClass = BaseContextClass$1;
/**
* Retrieve base controller
* @member {Controller} Controller
* @since 1.0.0
*/
Controller = BaseContextClass$1;
/**
* Retrieve base service
* @member {Service} Service
* @since 1.0.0
*/
Service = BaseContextClass$1;
/**
* Retrieve base subscription
* @member {Subscription} Subscription
* @since 2.12.0
*/
Subscription = BaseContextClass$1;
/**
* Retrieve base context class
* @member {BaseHookClass} BaseHookClass
*/
BaseHookClass = BaseHookClass;
/**
* Retrieve base boot
* @member {Boot}
* @alias BaseHookClass
*/
Boot = BaseHookClass;
#httpClient;
#loggers;
#startTimeoutTimer;
#clusterClients = [];
#snapshotDeserialized = false;
#loadFinishedResolve;
#loadFinishedReject;
/**
* Promise that resolves when the `load()` method has finished.
* This is useful for callers that need to ensure config and metadata
* are fully loaded before proceeding.
*/
loadFinished;
messenger;
agent;
application;
/**
* @class
* @param {Object} options
* - {Object} [type] - type of instance, Agent and Application both extend koa, type can determine what it is.
* - {String} [baseDir] - app root dir, default is `process.cwd()`
* - {Object} [plugins] - custom plugin config, use it in unittest
* - {String} [mode] - process mode, can be cluster / single, default is `cluster`
*/
constructor(options) {
options = {
mode: "cluster",
type: "application",
baseDir: process.cwd(),
...options
};
super(options);
this.loadFinished = new Promise((resolve, reject) => {
this.#loadFinishedResolve = resolve;
this.#loadFinishedReject = reject;
});
/**
* messenger instance
* @member {Messenger}
* @since 1.0.0
*/
this.messenger = create(this);
this.messenger.once("egg-ready", () => {
this.lifecycle.triggerServerDidReady();
});
this.lifecycle.addBootHook({
snapshotWillSerialize: () => this.snapshotWillSerialize(),
snapshotDidDeserialize: () => this.snapshotDidDeserialize()
});
this.lifecycle.registerBeforeStart(async () => {
try {
await this.load();
this.#loadFinishedResolve();
} catch (err) {
this.#loadFinishedReject(err);
throw err;
}
}, "load files");
}
/**
* @deprecated please use `options` property instead
*/
get _options() {
return this.options;
}
async loadConfig() {
await this.loader.loadConfig();
}
async load() {
await this.loadConfig();
if (!this.options.snapshot) {
this.ready(() => process.nextTick(() => {
const dumpStartTime = Date.now();
this.dumpConfig();
this.dumpTiming();
this.dumpManifest();
ManifestStore.flushCompileCache();
this.coreLogger.info("[egg] dump config after ready, %sms", Date.now() - dumpStartTime);
}));
this.#setupTimeoutTimer();
}
this.console.info("[egg] App root: %s", this.baseDir);
this.console.info("[egg] All *.log files save on %j", this.config.logger.dir);
assert(this.config.logger.dir, "logger.dir is required");
this.console.info("[egg] Loaded enabled plugin %j", this.loader.orderPlugins);
this._unhandledRejectionHandler = this._unhandledRejectionHandler.bind(this);
process.on("unhandledRejection", this._unhandledRejectionHandler);
if (!this.lifecycle.registerBeforeClose(async () => {
for (const clusterClient of this.#clusterClients) await close(clusterClient);
this.#clusterClients = [];
if (this.type === "application" && this.options.mode === "single") await this.agent?.close();
ManifestStore.flushCompileCache();
for (const logger of this.loggers.values()) logger.close();
this.messenger.close();
process.removeListener("unhandledRejection", this._unhandledRejectionHandler);
})) {
this.#clearStartTimeoutTimer();
process.removeListener("unhandledRejection", this._unhandledRejectionHandler);
this.messenger.close();
if (this.#loggers) for (const logger of this.#loggers.values()) logger.close();
return;
}
await this.loader.load();
}
/**
* Usage: new ApiClient({ cluster: app.cluster })
*/
get cluster() {
return this.clusterWrapper.bind(this);
}
/**
* Wrap the Client with Leader/Follower Pattern
*
* @description almost the same as Agent.cluster API, the only different is that this method create Follower.
*
* @see https://github.com/node-modules/cluster-client
* @param {Function} clientClass - client class function
* @param {Object} [options]
* - {Boolean} [autoGenerate] - whether generate delegate rule automatically, default is true
* - {Function} [formatKey] - a method to transform the subscription info into a string,default is JSON.stringify
* - {Object} [transcode|JSON.stringify/parse]
* - {Function} encode - custom serialize method
* - {Function} decode - custom deserialize method
* - {Boolean} [isBroadcast] - whether broadcast subscription result to all followers or just one, default is true
* - {Number} [responseTimeout] - response timeout, default is 3 seconds
* - {Number} [maxWaitTime|30000] - leader startup max time, default is 30 seconds
* @return {ClientWrapper} wrapper
*/
clusterWrapper(clientClass, options) {
if (this.options.snapshot && !this.#snapshotDeserialized) throw new Error("[egg] clusterWrapper() cannot create runtime clients while building a startup snapshot; move the initialization to configDidLoad() or a later lifecycle hook");
const clientClassOptions = {
...this.config.clusterClient,
...options,
singleMode: this.options.mode === "single",
port: this.options.clusterPort,
isLeader: this.type === "agent",
logger: this.coreLogger,
isCheckHeartbeat: this.config.env === "prod" ? true : inspector.url() === void 0
};
const client = createClusterClient(clientClass, clientClassOptions);
this.#patchClusterClient(client);
return client;
}
/**
* print the information when console.log(app)
* @return {Object} inspected app.
* @since 1.0.0
* @example
* ```js
* console.log(app);
* =>
* {
* name: 'mock-app',
* env: 'test',
* subdomainOffset: 2,
* config: '<egg config>',
* controller: '<egg controller>',
* service: '<egg service>',
* middlewares: '<egg middlewares>',
* urllib: '<egg urllib>',
* loggers: '<egg loggers>'
* }
* ```
*/
inspect() {
const res = { env: this.config.env };
function delegate(res, app, keys) {
for (const key of keys) if (app[key]) res[key] = app[key];
}
function abbr(res, app, keys) {
for (const key of keys) if (app[key]) res[key] = `<egg ${key}>`;
}
delegate(res, this, [
"name",
"baseDir",
"subdomainOffset"
]);
abbr(res, this, [
"config",
"controller",
"httpclient",
"loggers",
"middlewares",
"router",
"serviceClasses"
]);
return res;
}
toJSON() {
return this.inspect();
}
/**
* http request helper base on {@link httpclient}, it will auto save httpclient log.
* Keep the same api with `httpclient.request(url, args)`.
*
* See https://github.com/node-modules/urllib#api-doc for more details.
*
* @param {String} url request url address.
* @param {Object} options
* - method {String} - Request method, defaults to GET. Could be GET, POST, DELETE or PUT. Alias 'type'.
* - data {Object} - Data to be sent. Will be stringify automatically.
* - dataType {String} - String - Type of response data. Could be `text` or `json`.
* If it's `text`, the callback data would be a String.
* If it's `json`, the data of callback would be a parsed JSON Object.
* Default callback data would be a Buffer.
* - headers {Object} - Request headers.
* - timeout {Number} - Request timeout in milliseconds. Defaults to exports.TIMEOUT.
* Include remote server connecting timeout and response timeout.
* When timeout happen, will return ConnectionTimeout or ResponseTimeout.
* - auth {String} - `username:password` used in HTTP Basic Authorization.
* - followRedirect {Boolean} - follow HTTP 3xx responses as redirects. defaults to false.
* - gzip {Boolean} - let you get the res object when request connected, default false. alias customResponse
* - nestedQuerystring {Boolean} - urllib default use querystring to stringify form data which don't
* support nested object, will use qs instead of querystring to support nested object by set this option to true.
* - more options see https://github.com/node-modules/urllib
* @return {Object}
* - status {Number} - HTTP response status
* - headers {Object} - HTTP response headers
* - res {Object} - HTTP response meta
* - data {Object} - HTTP response body
*
* @example
* ```js
* const result = await app.curl('http://example.com/foo.json', {
* method: 'GET',
* dataType: 'json',
* });
* console.log(result.status, result.headers, result.data);
* ```
*/
async curl(url, options) {
return await this.httpClient.request(url, options);
}
/**
* Create a new HttpClient instance with custom options
* @param {Object} [options] HttpClient init options
*/
createHttpClient(options) {
return new this.HttpClient(this, options);
}
/**
* HttpClient instance
*
* Returns a transparent proxy that defers actual HttpClient construction
* until a method/property is first accessed. This allows plugins to modify
* `config.httpclient.lookup` or other options during lifecycle hooks
* (e.g. `configWillLoad`, `didLoad`) even after `app.httpClient` is
* first referenced.
*
* @see https://github.com/node-modules/urllib
* @member {HttpClient}
*/
get httpClient() {
if (!this.#httpClient) this.#httpClient = createTransparentProxy({ createReal: () => this.createHttpClient() });
return this.#httpClient;
}
/**
* @deprecated please use httpClient instead
* @alias httpClient
* @member {HttpClient}
*/
get httpclient() {
return this.httpClient;
}
/**
* All loggers contain logger, coreLogger and customLogger
* @member {Object}
* @since 1.0.0
*/
get loggers() {
if (!this.#loggers) this.#loggers = createLoggers(this);
return this.#loggers;
}
/**
* Get logger by name, it's equal to app.loggers['name'],
* but you can extend it with your own logical.
* @param {String} name - logger name
* @return {Logger} logger
*/
getLogger(name) {
return this.loggers[name] || null;
}
/**
* application logger, log file is `$HOME/logs/{appname}/{appname}-web`
* @member {Logger}
* @since 1.0.0
*/
get logger() {
return this.getLogger("logger");
}
/**
* core logger for framework and plugins, log file is `$HOME/logs/{appname}/egg-web`
* @member {Logger}
* @since 1.0.0
*/
get coreLogger() {
return this.getLogger("coreLogger");
}
_unhandledRejectionHandler(err) {
this.coreLogger.error("[egg:unhandledRejection] %s", err && err.message || err);
if (!(err instanceof Error)) {
const newError = new Error(String(err));
if (err) {
if (err.name) newError.name = err.name;
if (err.message) newError.message = err.message;
if (err.stack) newError.stack = err.stack;
}
err = newError;
}
if (err.name === "Error") err.name = "unhandledRejectionError";
this.coreLogger.error(err);
}
/**
* Clean up non-serializable resources before V8 heap serialization.
* Closes messenger (IPC listeners), logger streams and flush timers
* (file descriptors), and removes the process-level unhandledRejection
* listener.
*
* The `EggLoggers` instance is intentionally kept (not discarded): plugins
* such as `@eggjs/schedule` capture individual logger references during the
* load phase, before serialization. Replacing them with a fresh `EggLoggers`
* on restore would leave those captured references pointing at closed
* streams (`... log stream had been closed`). Instead, `snapshotDidDeserialize`
* reopens these same logger objects in place.
*/
snapshotWillSerialize() {
this.messenger.close();
if (this.#loggers) for (const logger of this.#loggers.values()) logger.close();
process.removeListener("unhandledRejection", this._unhandledRejectionHandler);
}
/**
* Restore non-serializable resources after V8 heap deserialization.
* Recreates messenger, re-registers the egg-ready listener, reopens the
* logger streams closed during serialize, and re-attaches the process-level
* unhandledRejection listener.
*/
snapshotDidDeserialize() {
this.#snapshotDeserialized = true;
this.messenger = create(this);
this.messenger.once("egg-ready", () => {
this.lifecycle.triggerServerDidReady();
});
this.#reopenLoggers();
process.on("unhandledRejection", this._unhandledRejectionHandler);
}
/**
* Reopen logger resources that `snapshotWillSerialize` released.
*
* `transport.reload()` reopens each `FileTransport` stream on the existing
* logger objects (so references captured before the snapshot keep working).
* `FileBufferTransport`, however, clears its flush interval in `close()` and
* does not restart it in `reload()`, so buffered logs would never flush after
* restore. Restart that interval explicitly to keep the willSerialize /
* didDeserialize resource pairing complete.
*/
#reopenLoggers() {
if (!this.#loggers) return;
for (const logger of this.#loggers.values()) for (const transport of logger.values()) {
transport.reload();
const bufferTransport = transport;
if (typeof bufferTransport._createInterval === "function" && !bufferTransport._timer) bufferTransport._timer = bufferTransport._createInterval();
}
}
/**
* dump out the config and meta object
* @private
*/
dumpConfigToObject() {
let ignoreList;
try {
ignoreList = Array.from(this.config.dump.ignore);
} catch {
ignoreList = [];
}
const config = extend(true, {}, {
config: this.config,
plugins: this.loader.allPlugins,
appInfo: this.loader.appInfo
});
convertObject(config, ignoreList);
return {
config,
meta: this.loader.configMeta
};
}
/**
* save app.config to `run/${type}_config.json`
* @private
*/
dumpConfig() {
const rundir = this.config.rundir;
try {
if (!fs.existsSync(rundir)) fs.mkdirSync(rundir);
const { config, meta } = this.dumpConfigToObject();
const dumpFile = path.join(rundir, `${this.type}_config.json`);
fs.writeFileSync(dumpFile, CircularJSON.stringify(config, null, 2));
const dumpMetaFile = path.join(rundir, `${this.type}_config_meta.json`);
fs.writeFileSync(dumpMetaFile, CircularJSON.stringify(meta, null, 2));
} catch (err) {
this.coreLogger.warn(`[egg] dumpConfig error: ${err.message}`);
}
}
dumpTiming() {
try {
const items = this.timing.toJSON();
const rundir = this.config.rundir;
const dumpFile = path.join(rundir, `${this.type}_timing_${process.pid}.json`);
fs.writeFileSync(dumpFile, CircularJSON.stringify(items, null, 2));
this.coreLogger.info(this.timing.toString());
this.timing.disable();
for (const item of items) if (item.index > 0 && item.duration && item.duration >= this.config.dump.timing.slowBootActionMinDuration) this.coreLogger.warn("[egg][dumpTiming][slow-boot-action] #%d %dms, name: %s", item.index, item.duration, item.name);
} catch (err) {
this.coreLogger.warn(`[egg] dumpTiming error: ${err.message}`);
}
}
/**
* Generate and save startup manifest for faster subsequent startups.
* Only generates when no valid manifest was loaded (avoids overwriting during manifest-accelerated starts).
*/
dumpManifest() {
try {
if (this.loader.serverEnv === "local" && process.env.EGG_MANIFEST !== "true") return;
if (this.loader.manifest.data.generatedAt) return;
const manifest = this.loader.generateManifest();
ManifestStore.enableCompileCache(this.baseDir);
ManifestStore.write(this.baseDir, manifest).catch((err) => {
this.coreLogger.warn("[egg] dumpManifest write error: %s", err.message);
});
} catch (err) {
this.coreLogger.warn("[egg] dumpManifest error: %s", err.message);
}
}
customEggPaths() {
const bundleStore = ManifestStore.getBundleStore();
if (bundleStore && path.resolve(bundleStore.baseDir) === path.resolve(this.baseDir)) {
const bundledFrameworkDir = path.join(bundleStore.baseDir, "node_modules", "egg", "dist");
const importMetaRewritten = !!import.meta.dirname && path.resolve(import.meta.dirname) === path.resolve(bundleStore.baseDir);
if (fs.existsSync(bundledFrameworkDir) || importMetaRewritten) return [bundledFrameworkDir, ...super.customEggPaths()];
}
return [path.dirname(import.meta.dirname), ...super.customEggPaths()];
}
#clearStartTimeoutTimer() {
if (this.#startTimeoutTimer) {
clearTimeout(this.#startTimeoutTimer);
this.#startTimeoutTimer = void 0;
}
}
#setupTimeoutTimer() {
this.#startTimeoutTimer = setTimeout(() => {
this.coreLogger.error(this.timing.toString());
this.coreLogger.error(`${this.type} still doesn't ready after ${this.config.workerStartTimeout} ms.`);
const items = this.timing.toJSON();
for (const item of items) {
if (item.end) continue;
this.coreLogger.error(`unfinished timing item: ${CircularJSON.stringify(item)}`);
}
this.coreLogger.error("[egg][setupTimeoutTimer] check run/%s_timing_%s.json for more details.", this.type, process.pid);
this.emit("startTimeout");
this.dumpConfig();
this.dumpTiming();
}, this.config.workerStartTimeout);
this.ready(() => this.#clearStartTimeoutTimer());
}
get config() {
return super.config;
}
/**
* app.env delegate app.config.env
* @deprecated
*/
get env() {
this.deprecate("please use app.config.env instead");
return this.config.env;
}
set env(_) {}
/**
* app.proxy delegate app.config.proxy
* @deprecated
*/
get proxy() {
return this.config.proxy;
}
set proxy(_) {}
#patchClusterClient(client) {
const rawCreate = client.create;
client.create = (...args) => {
const realClient = rawCreate.apply(client, args);
this.#clusterClients.push(realClient);
return realClient;
};
}
/**
* Create an anonymous context, the context isn't request level, so the request is mocked.
* then you can use context level API like `ctx.service`
* @member {String} EggApplication#createAnonymousContext
* @param {Request} [req] - if you want to mock request like querystring, you can pass an object to this function.
* @return {Context} context
*/
createAnonymousContext(req) {
const request = {
headers: {
host: "127.0.0.1",
"x-forwarded-for": "127.0.0.1"
},
query: {},
querystring: "",
host: "127.0.0.1",
hostname: "127.0.0.1",
protocol: "http",
secure: "false",
method: "GET",
url: "/",
path: "/",
socket: {
remoteAddress: "127.0.0.1",
remotePort: 7001
}
};
if (req) for (const key in req) if (key === "headers" || key === "query" || key === "socket") Object.assign(request[key], req[key]);
else request[key] = req[key];
const response = new http.ServerResponse(request);
return this.createContext(request, response);
}
/**
* Run async function in the anonymous context scope
* @see Context#runInAnonymousContextScope
* @param {Function} scope - the first args is an anonymous ctx, scope should be async function
* @param {Request} [req] - if you want to mock request like querystring, you can pass an object to this function.
*/
async runInAnonymousContextScope(scope, req) {
const ctx = this.createAnonymousContext(req);
if (!scope.name) Reflect.set(scope, "_name", utils.getCalleeFromStack(true));
return await this.ctxStorage.run(ctx, async () => {
return await scope(ctx);
});
}
/**
* Create egg context
* @function EggApplication#createContext
* @param {Req} req - node native Request object
* @param {Res} res - node native Response object
* @return {Context} context object
*/
createContext(req, res) {
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
context.app = request.app = response.app = this;
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.onerror = context.onerror.bind(context);
context.originalUrl = request.originalUrl = req.url;
context.starttime = Date.now();
context.performanceStarttime = performance.now();
return context;
}
};
//#endregion
export { Context, EggApplicationCore, Request, Response, Router };