UNPKG

egg

Version:

A web application framework for Node.js

641 lines (640 loc) • 22.2 kB
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 };