diff --git a/bin/test.ts b/bin/test.ts index 9edf958a..d1e6fa70 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,4 +1,5 @@ import { assert } from '@japa/assert' +import { snapshot } from '@japa/snapshot' import { fileSystem } from '@japa/file-system' import { expectTypeOf } from '@japa/expect-type' import { processCLIArgs, configure, run } from '@japa/runner' @@ -19,7 +20,7 @@ import { processCLIArgs, configure, run } from '@japa/runner' processCLIArgs(process.argv.slice(2)) configure({ files: ['tests/**/*.spec.ts'], - plugins: [assert(), expectTypeOf(), fileSystem()], + plugins: [assert(), expectTypeOf(), fileSystem(), snapshot()], }) /* diff --git a/modules/dumper/define_config.ts b/modules/dumper/define_config.ts new file mode 100644 index 00000000..737345f4 --- /dev/null +++ b/modules/dumper/define_config.ts @@ -0,0 +1,21 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { ConsoleDumpConfig } from '@poppinss/dumper/console/types' +import { HTMLDumpConfig } from '@poppinss/dumper/html/types' + +/** + * Define config for the dumper service exported by + * the "@adonisjs/core/services/dumper" module + */ +export function defineConfig( + dumperConfig: Partial<{ html: HTMLDumpConfig; console: ConsoleDumpConfig }> +) { + return dumperConfig +} diff --git a/modules/dumper/dumper.ts b/modules/dumper/dumper.ts new file mode 100644 index 00000000..f264acb3 --- /dev/null +++ b/modules/dumper/dumper.ts @@ -0,0 +1,139 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { dump as consoleDump } from '@poppinss/dumper/console' +import type { HTMLDumpConfig } from '@poppinss/dumper/html/types' +import type { ConsoleDumpConfig } from '@poppinss/dumper/console/types' +import { createScript, createStyleSheet, dump } from '@poppinss/dumper/html' + +import type { Application } from '../app.js' +import { E_DUMP_DIE_EXCEPTION } from './errors.js' + +const DUMP_TITLE_STYLES = ` +.adonisjs-dump-header { + font-family: JetBrains Mono, monaspace argon, Menlo, Monaco, Consolas, monospace; + background: #ff1639; + border-radius: 4px; + color: #fff; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + padding: 0.4rem 1.2rem; + font-size: 1em; + display: flex; + justify-content: space-between; +} +.adonisjs-dump-header .adonisjs-dump-header-title { + font-weight: bold; + text-transform: uppercase; +} +.adonisjs-dump-header .adonisjs-dump-header-source { + font-weight: bold; + color: inherit; + text-decoration: underline; +} +.dumper-dump pre { + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; +}` + +/** + * Dumper exposes the API to dump or die/dump values via your + * AdonisJS application. An singleton instance of the Dumper + * is shared as a service and may use it follows. + * + * ```ts + * dumper.configureHtmlOutput({ + * // parser + html formatter config + * }) + * + * dumper.configureAnsiOutput({ + * // parser + console formatter config + * }) + * + * const html = dumper.dumpToHtml(value) + * const ansi = dumper.dumpToAnsi(value) + * + * // Returns style and script tags that must be + * // injeted to the head of the HTML document + * const head = dumper.getHeadElements() + * ``` + */ +export class Dumper { + #app: Application + #htmlConfig: HTMLDumpConfig = {} + #consoleConfig: ConsoleDumpConfig = { + collapse: ['DateTime', 'Date'], + } + + constructor(app: Application) { + this.#app = app + } + + /** + * Configure the HTML formatter output + */ + configureHtmlOutput(config: HTMLDumpConfig): this { + this.#htmlConfig = config + return this + } + + /** + * Configure the ANSI formatter output + */ + configureAnsiOutput(config: ConsoleDumpConfig): this { + this.#consoleConfig = config + return this + } + + /** + * Returns the style and the script elements for the + * HTML document + */ + getHeadElements(cspNonce?: string): string { + return ( + '' + + `' + ) + } + + /** + * Dump value to HTML ouput + */ + dumpToHtml(value: unknown, cspNonce?: string) { + return dump(value, { cspNonce, ...this.#htmlConfig }) + } + + /** + * Dump value to ANSI output + */ + dumpToAnsi(value: unknown) { + return consoleDump(value, this.#consoleConfig) + } + + /** + * Dump values and die. The formatter will be picked + * based upon where your app is running. + * + * - In CLI commands, the ANSI output will be printed + * to the console. + * - During an HTTP request, the HTML output will be + * sent to the server. + */ + dd(value: unknown, traceSourceIndex: number = 1) { + const error = new E_DUMP_DIE_EXCEPTION(value, this, this.#app) + error.setTraceSourceIndex(traceSourceIndex) + throw error + } +} diff --git a/modules/dumper/errors.ts b/modules/dumper/errors.ts new file mode 100644 index 00000000..b99cacd0 --- /dev/null +++ b/modules/dumper/errors.ts @@ -0,0 +1,141 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { inspect } from 'node:util' +import { parse } from 'error-stack-parser-es' +import type { Kernel } from '@adonisjs/core/ace' +import { Exception } from '@poppinss/utils/exception' +import type { HttpContext } from '@adonisjs/core/http' +import type { ApplicationService } from '@adonisjs/core/types' + +import type { Dumper } from './dumper.js' + +const IDE = process.env.ADONIS_IDE ?? process.env.EDITOR ?? '' + +/** + * DumpDie exception is raised by the "dd" function. It will + * result in dumping the value in response to an HTTP + * request or printing the value to the console + */ +class DumpDieException extends Exception { + static status: number = 500 + static code: string = 'E_DUMP_DIE_EXCEPTION' + + #app: ApplicationService + #dumper: Dumper + #traceSourceIndex: number = 1 + + /** + * A collections of known editors to create URLs to open + * them + */ + #editors: Record = { + textmate: 'txmt://open?url=file://%f&line=%l', + macvim: 'mvim://open?url=file://%f&line=%l', + emacs: 'emacs://open?url=file://%f&line=%l', + sublime: 'subl://open?url=file://%f&line=%l', + phpstorm: 'phpstorm://open?file=%f&line=%l', + atom: 'atom://core/open/file?filename=%f&line=%l', + vscode: 'vscode://file/%f:%l', + } + + value: unknown + + constructor(value: unknown, dumper: Dumper, app: ApplicationService) { + super('Dump and Die exception') + this.#dumper = dumper + this.#app = app + this.value = value + } + + /** + * Returns the link to open the file using dd inside one + * of the known code editors + */ + #getEditorLink(): { href: string; text: string } | undefined { + const editorURL = this.#editors[IDE] || IDE + if (!editorURL) { + return + } + + const source = parse(this)[this.#traceSourceIndex] + if (!source.fileName || !source.lineNumber) { + return + } + + return { + href: editorURL.replace('%f', source.fileName).replace('%l', String(source.lineNumber)), + text: `${this.#app.relativePath(source.fileName)}:${source.lineNumber}`, + } + } + + /** + * Set the index for the trace source. This is helpful when + * you build nested helpers on top of Die/Dump + */ + setTraceSourceIndex(index: number) { + this.#traceSourceIndex = index + return this + } + + /** + * Preventing itself from getting reported by the + * AdonisJS exception reporter + */ + report() {} + + /** + * Handler called by the AdonisJS HTTP exception handler + */ + async handle(error: DumpDieException, ctx: HttpContext) { + const link = this.#getEditorLink() + /** + * Comes from the shield package + */ + const cspNonce = 'nonce' in ctx.response ? ctx.response.nonce : undefined + + ctx.response + .status(500) + .send( + '' + + '' + + '' + + '' + + '' + + `${this.#dumper.getHeadElements(cspNonce)}` + + '' + + '' + + '
' + + 'DUMP DIE' + + (link + ? `${link.text}` + : '') + + '
' + + `${this.#dumper.dumpToHtml(error.value, cspNonce)}` + + '' + + '' + ) + } + + /** + * Handler called by the AdonisJS Ace kernel + */ + async render(error: DumpDieException, kernel: Kernel) { + kernel.ui.logger.log(this.#dumper.dumpToAnsi(error.value)) + } + + /** + * Custom output for the Node.js util inspect + */ + [inspect.custom]() { + return this.#dumper.dumpToAnsi(this.value) + } +} + +export const E_DUMP_DIE_EXCEPTION = DumpDieException diff --git a/modules/dumper/main.ts b/modules/dumper/main.ts new file mode 100644 index 00000000..0fd44a67 --- /dev/null +++ b/modules/dumper/main.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export * as errors from './errors.js' +export { Dumper } from './dumper.js' +export { defineConfig } from './define_config.js' diff --git a/package.json b/package.json index 59f33108..67e8d21f 100644 --- a/package.json +++ b/package.json @@ -85,15 +85,16 @@ "@adonisjs/eslint-config": "^2.0.0-beta.6", "@adonisjs/prettier-config": "^1.4.0", "@adonisjs/tsconfig": "^1.4.0", - "@commitlint/cli": "^19.4.1", - "@commitlint/config-conventional": "^19.4.1", + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", "@japa/assert": "^3.0.0", "@japa/expect-type": "^2.0.2", "@japa/file-system": "^2.3.0", "@japa/runner": "^3.1.4", - "@release-it/conventional-changelog": "^8.0.1", - "@swc/core": "^1.7.23", - "@types/node": "^22.5.4", + "@japa/snapshot": "^2.0.5", + "@release-it/conventional-changelog": "^8.0.2", + "@swc/core": "^1.7.26", + "@types/node": "^22.5.5", "@types/pretty-hrtime": "^1.0.3", "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", @@ -105,19 +106,19 @@ "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", - "edge.js": "^6.0.2", - "eslint": "^9.9.1", - "execa": "^9.3.1", + "edge.js": "^6.1.0", + "eslint": "^9.10.0", + "execa": "^9.4.0", "get-port": "^7.1.0", "github-label-sync": "^2.3.1", - "husky": "^9.1.5", + "husky": "^9.1.6", "prettier": "^3.3.3", "release-it": "^17.6.0", - "sinon": "^18.0.0", + "sinon": "^19.0.2", "supertest": "^7.0.0", "test-console": "^2.0.0", "ts-node-maintained": "^10.9.4", - "typescript": "^5.5.4" + "typescript": "^5.6.2" }, "dependencies": { "@adonisjs/ace": "^13.2.0", @@ -135,12 +136,14 @@ "@adonisjs/repl": "^4.0.1", "@antfu/install-pkg": "^0.4.1", "@paralleldrive/cuid2": "^2.2.2", - "@poppinss/macroable": "^1.0.2", - "@poppinss/utils": "^6.7.3", - "@sindresorhus/is": "^7.0.0", + "@poppinss/dumper": "^0.4.0", + "@poppinss/macroable": "^1.0.3", + "@poppinss/utils": "^6.8.1", + "@sindresorhus/is": "^7.0.1", "@types/he": "^1.2.3", + "error-stack-parser-es": "^0.1.5", "he": "^1.2.0", - "parse-imports": "^1.2.0", + "parse-imports": "^2.1.1", "pretty-hrtime": "^1.0.3", "string-width": "^7.2.0", "youch": "^3.3.3", diff --git a/providers/app_provider.ts b/providers/app_provider.ts index 3d1c909e..59275942 100644 --- a/providers/app_provider.ts +++ b/providers/app_provider.ts @@ -10,9 +10,10 @@ import { Config } from '../modules/config.js' import { Logger } from '../modules/logger.js' import { Application } from '../modules/app.js' -import { BaseEvent, Emitter } from '../modules/events.js' +import { Dumper } from '../modules/dumper/dumper.js' import { Encryption } from '../modules/encryption.js' import { Router, Server } from '../modules/http/main.js' +import { BaseEvent, Emitter } from '../modules/events.js' import type { ApplicationService, LoggerService } from '../src/types.js' import BodyParserMiddleware from '../modules/bodyparser/bodyparser_middleware.js' @@ -138,12 +139,35 @@ export default class AppServiceProvider { }) } + /** + * Registeres singleton instance of the "Dumper" module configured + * via the "config/app.ts" file. + */ + protected registerDumper() { + this.app.container.singleton(Dumper, async () => { + const config = this.app.config.get('app.dumper', {}) + const dumper = new Dumper(this.app) + + if (config.html) { + dumper.configureHtmlOutput(config.html) + } + if (config.console) { + dumper.configureAnsiOutput(config.console) + } + + return dumper + }) + + this.app.container.alias('dumper', Dumper) + } + /** * Registers bindings */ register() { this.registerApp() this.registerAce() + this.registerDumper() this.registerLoggerManager() this.registerLogger() this.registerConfig() diff --git a/providers/edge_provider.ts b/providers/edge_provider.ts index be5c10e3..11f35ba2 100644 --- a/providers/edge_provider.ts +++ b/providers/edge_provider.ts @@ -44,6 +44,7 @@ export default class EdgeServiceProvider { async boot() { const app = this.app const router = await this.app.container.make('router') + const dumper = await this.app.container.make('dumper') function edgeConfigResolver(key: string, defaultValue?: any) { return app.config.get(key, defaultValue) @@ -73,6 +74,8 @@ export default class EdgeServiceProvider { }) edge.global('app', app) edge.global('config', edgeConfigResolver) + edge.global('dd', (value: unknown) => dumper.dd(value)) + edge.global('dump', (value: unknown) => dumper.dumpToHtml(value)) /** * Creating a isolated instance of edge renderer diff --git a/services/dumper.ts b/services/dumper.ts new file mode 100644 index 00000000..2a1ac888 --- /dev/null +++ b/services/dumper.ts @@ -0,0 +1,30 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import app from './app.js' +import type { Dumper } from '../modules/dumper/dumper.js' + +let dumper: Dumper + +/** + * dumper service is an instance of the "Dumper" class stored inside + * the "modules/dumper/dumper.ts" file + */ +await app.booted(async () => { + dumper = await app.container.make('dumper') +}) + +/** + * Dump a value and die. The dumped value will be displayed + * using the HTML printer during an HTTP request or within + * the console otherwise. + */ +export const dd = (value: unknown) => { + dumper.dd(value, 2) +} diff --git a/src/helpers/main.ts b/src/helpers/main.ts index 1ae79629..2e84fef4 100644 --- a/src/helpers/main.ts +++ b/src/helpers/main.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -export { default as parseImports } from 'parse-imports' +export { parseImports } from 'parse-imports' export { createId as cuid, isCuid } from '@paralleldrive/cuid2' export { slash, diff --git a/src/helpers/parse_binding_reference.ts b/src/helpers/parse_binding_reference.ts index e7bdbcb6..b433b29a 100644 --- a/src/helpers/parse_binding_reference.ts +++ b/src/helpers/parse_binding_reference.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import parseImports from 'parse-imports' +import { parseImports } from 'parse-imports' import { LazyImport, Constructor } from '../../types/http.js' /** diff --git a/src/types.ts b/src/types.ts index e2ea1f10..a08feb04 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ import type { Kernel } from '../modules/ace/main.js' import type { Application } from '../modules/app.js' import type { TestUtils } from './test_utils/main.js' import type { HttpServerEvents } from '../types/http.js' +import type { Dumper } from '../modules/dumper/dumper.js' import type { LoggerManager } from '../modules/logger.js' import type { HashManager } from '../modules/hash/main.js' import type { Encryption } from '../modules/encryption.js' @@ -121,6 +122,7 @@ export interface HashService */ export interface ContainerBindings { ace: Kernel + dumper: Dumper app: ApplicationService logger: LoggerService config: ApplicationService['config'] diff --git a/tests/dumper/dumper.spec.ts b/tests/dumper/dumper.spec.ts new file mode 100644 index 00000000..1f6c92e4 --- /dev/null +++ b/tests/dumper/dumper.spec.ts @@ -0,0 +1,79 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { AppFactory } from '@adonisjs/application/factories' +import { Dumper } from '../../modules/dumper/dumper.js' +import { E_DUMP_DIE_EXCEPTION } from '../../modules/dumper/errors.js' +import { HttpContextFactory } from '@adonisjs/http-server/factories' +import { AceFactory } from '../../factories/core/ace.js' + +test.group('Dumper', () => { + test('dump and die', ({ fs }) => { + const app = new AppFactory().create(fs.baseUrl) + const dumper = new Dumper(app) + + dumper.dd('hello') + }).throws('Dump and Die exception', E_DUMP_DIE_EXCEPTION) + + test('render dump as HTML', async ({ fs, assert }) => { + assert.plan(3) + const app = new AppFactory().create(fs.baseUrl) + const dumper = new Dumper(app) + + const ctx = new HttpContextFactory().create() + + try { + dumper.dd('hello') + } catch (error) { + await error.handle(error, ctx) + assert.include(ctx.response.getBody(), '