UNPKG

@mojojs/core

Version:

Real-time web framework

444 lines 15.2 kB
import { Readable } from 'node:stream'; import { CLI } from './cli.js'; import { Context } from './context.js'; import { Hooks } from './hooks.js'; import { Logger } from './logger.js'; import { Mime } from './mime.js'; import defaultConditionsPlugin from './plugins/default-conditions.js'; import defaultHelpersPlugin from './plugins/default-helpers.js'; import tmplEnginePlugin from './plugins/tmpl-engine.js'; import { Renderer } from './renderer.js'; import { Router } from './router.js'; import { ServerRequest } from './server/request.js'; import { ServerResponse } from './server/response.js'; import { Session } from './session.js'; import { Static } from './static.js'; import { MockUserAgent } from './user-agent/mock.js'; import { TestUserAgent } from './user-agent/test.js'; import { UserAgent } from './user-agent.js'; import { Validator } from './validator.js'; import Path from '@mojojs/path'; const ContextWrapper = class extends Context { }; /** * Application class. */ export class App { constructor(options = {}) { /** * Command line interface. * @example * // Add another path to load commands from * app.cli.commandPaths.push(app.home.child('cli').toString()); */ this.cli = new CLI(this); /** * Default stash values. * @example * // Remove value * delete app.defaults.foo; * * // Assign multiple values at once * Object.assign(app.defaults, {foo: 'test', bar: 23}); */ this.defaults = {}; /** * Application hooks. * @example * // Run a custom hook * await app.hooks.runHook('my:hook', foo, bar); */ this.hooks = new Hooks(); /** * Application home directory. * @example * // Portably generate path relative to home directory * const path = app.home.child('data', 'important.txt'); */ this.home = new Path(); /** * MIME types. * @example * // Get MIME type for extension * const type = app.mime.extType('txt'); */ this.mime = new Mime(); /** * Storage for user defined models. * @example * // Store database connection * app.models.pg = new Pg('postgres://127.0.0.1:5432/db'); */ this.models = {}; /** * Application renderer. * @example * // Disable compression * app.renderer.autoCompress = false; * * // Add another "views" directory * app.renderer.viewPaths.push(app.home.child('views').toString()); */ this.renderer = new Renderer(); /** * Application router. * @example * // Add routes * const r = app.router; * r.get('/foo/bar').to('test#foo', {title: 'Hello Mojo!'}); * r.post('/baz').to('test#baz'); * * // Add another path to load controllers from * app.router.controllerPaths.push(app.home.child('more-controllers').toString()); */ this.router = new Router(); /** * Encrypted cookie based session manager. * @example * // Change name of cookie used for all sessions * app.sessions.cookieName = 'mysession'; * * // Disable SameSite feature * app.sessions.sameSite = 'none'; */ this.session = new Session(this); /** * Static file server. * @example * // Add another "public" directory * app.static.publicPaths.push('/home/sri/public'); * * // Add another "public" directory with higher precedence * app.static.publicPaths.unshift('/home/sri/themes/blue/public'); */ this.static = new Static(); /** * HTTP/WebSocket user-agent. * @example * # Perform HTTP request * const res = await app.ua.get('http://example.com'); */ this.ua = new UserAgent(); /** * JSON schema validator. * @example * // Add a named schema for later use * app.validator.addSchema({type: 'object', properties: {test: {type: 'number'}}}, 'testForm'); */ this.validator = new Validator(); this._contextClass = class extends ContextWrapper { }; this._nestedHelpers = {}; this.config = options.config ?? {}; this.detectImport = options.detectImport ?? true; this.exceptionFormat = options.exceptionFormat ?? 'html'; this.secrets = options.secrets ?? ['Insecure']; this.mode = options.mode ?? process.env.NODE_ENV ?? 'development'; const isDev = this.mode === 'development'; this.log = new Logger({ historySize: isDev ? 10 : 0, level: isDev ? 'trace' : 'info' }); this.plugin(defaultHelpersPlugin); this.plugin(defaultConditionsPlugin); this.plugin(tmplEnginePlugin); } /** * Add an application hook to extend the framework. * @example * // Run code whenever a server has been started * app.addAppHook('server:start', async app => { * ... * }); */ addAppHook(name, fn) { this.hooks.addHook(name, fn); return this; } /** * Add a context hook to extend the framework. * @example * // Run code after a new request has been received * app.addContextHook('dispatch:before', async ctx => { * ... * }); */ addContextHook(name, fn) { this.hooks.addHook(name, fn); return this; } /** * Add a helper. * @example * // Render response with header * app.addHelper('renderWithHeader', async (ctx, ...args) => { * ctx.res.set('X-Mojo', 'I <3 mojo.js!'); * await ctx.render(...args); * }); * * // Render response with header using nested helper * app.addHelper('renderWith.header', async (ctx, ...args) => { * ctx.res.set('X-Mojo', 'I <3 mojo.js!'); * await ctx.render(...args); * }); */ addHelper(name, fn) { // Simple helper const nestedNames = name.split('.'); if (nestedNames.length === 1) { return this.decorateContext(name, function (...args) { return fn(this, ...args); }); } // Nested helper if (nestedNames.length === 2) { const [getterName, methodName] = nestedNames; if (this._nestedHelpers[getterName] === undefined) { this._nestedHelpers[getterName] = {}; this.decorateContext(getterName, { get: function () { var _a; return ((_a = this._nestedHelpersCache)[getterName] ?? (_a[getterName] = Object.create(this.app._nestedHelpers[getterName], { _ctx: { value: this } }))); } }); } this._nestedHelpers[getterName][methodName] = function (...args) { return fn(this._ctx, ...args); }; return this; } // Invalid helper name throw new Error(`The name "${name}" exceeds maximum depth (2) for nested helpers`); } /** * Generate route matching any of the listed HTTP request methods or all. * @example * // Route with pattern and destination * app.any('/user').to('User#whatever'); * * // Route with HTTP methods, pattern, restrictive placeholders and destination * app.any(['DELETE', 'PUT'], '/:foo', {foo: /\w+/}).to('Foo#bar'); * * // Route with pattern, name and destination * app.any('/:foo').name('foo_route').to('Foo#bar'); * * // Route with pattern, condition and destination * app.any('/').requires({agent: /Firefox/}).to('Foo#bar'); * * // Route with pattern and a closure as destination * app.any('/:foo', async ctx => ctx.render({text: 'Hello World!'})); */ any(...args) { return this.router.any(...args); } /** * Decorate context class with a method or getter/setter. * @example * // Decorate context with getter * app.decorateContext('helloWorld', {get: () => 'Hello World!'}); */ decorateContext(name, fn) { const proto = Context.prototype; if (Object.getOwnPropertyDescriptor(proto, name) != null) { throw new Error(`The name "${name}" is already used in the prototype chain`); } if (typeof fn !== 'function') { Object.defineProperty(this._contextClass.prototype, name, fn); } else { this._contextClass.prototype[name] = fn; } return this; } /** * Generate route matching only `DELETE` requests. * @example * // Route with destination * app.delete('/user').to('User#remove'); */ delete(...args) { return this.router.delete(...args); } /** * Generate route matching only `GET` requests. * @example * // Route with destination * app.get('/user').to('User#show'); */ get(...args) { return this.router.get(...args); } /** * Handle a new incoming request, used by servers. */ async handleRequest(ctx) { if ((await this.hooks.runHook('dispatch:before', ctx)) === true) return; if (ctx.isWebSocket !== true && (await this.static.dispatch(ctx)) === true) return; if ((await this.hooks.runHook('router:before', ctx)) === true) return; if ((await this.router.dispatch(ctx)) === true) return; if (ctx.isWebSocket !== true) await ctx.notFound(); } /** * Create a context for application. */ newContext(req, res, backend) { const ctx = new this._contextClass(this, req, res, backend); Object.assign(ctx.stash, this.defaults); return ctx; } /** * Create a mock context for application. Very useful for testing helpers. * @example * // Use a mock context to call a helper * const ctx = app.newMockContext(); * const html = ctx.assetTag('/app.js'); */ newMockContext(options = {}) { const ctx = new this._contextClass(this, new ServerRequest({ body: new Readable(), headers: options.headers ?? [], isSecure: false, isWebSocket: false, method: options.method ?? 'GET', remoteAddress: '127.0.0.1', reverseProxy: false, url: options.url ?? '/' }), new ServerResponse(() => ctx.log.trace('Mock response has been sent')), { name: 'mock' }); Object.assign(ctx.stash, this.defaults); return ctx; } /** * Create a new mock user-agent for application. */ async newMockUserAgent(options, serverOptions) { return await MockUserAgent.newMockUserAgent(this, options, serverOptions); } /** * Create a new test user-agent for application. * @example * // Test plain text endpoint * const ua = await app.newTestUserAgent(); * (await ua.getOk('/')).statusIs(200).bodyIs('Hello World!'); */ async newTestUserAgent(options, serverOptions) { return await TestUserAgent.newTestUserAgent(this, options, serverOptions); } /** * Your main hook into the application, it is a shortcut for the `app:start` hook and runs during application * startup. You can use it to perform tasks like preparing database connections. * @example * // Perform async operations on application startup * app.onStart(async app => { * if (app.models.db === undefined) app.models.db = new SomeDatabase(); * await app.models.db.connect(); * }); */ onStart(fn) { return this.addAppHook('app:start', fn); } /** * The opposite of `onStart`, it is a shortcut for the `app:stop` hook and runs during application shutdown. You can * use it to perform tasks like closing database connections gracefully. * @example * app.onStop(async app => { * await app.models.db.disconnect(); * }); */ onStop(fn) { return this.addAppHook('app:stop', fn); } /** * Generate route matching only `OPTIONS` requests. * @example * // Route with destination * app.options('/user').to('User#overview'); */ options(...args) { return this.router.options(...args); } /** * Generate route matching only `PATCH` requests. * @example * // Route with destination * app.patch('/user').to('User#update'); */ patch(...args) { return this.router.patch(...args); } /** * Register plugin. * @example * // Mount application under "/prefix" * app.plugin(mountPlugin, {app: myOtherApp, path: '/prefix'}); * * // Load configuration from file * app.plugin(jsonConfigPlugin, {file: 'myapp.conf'}); */ plugin(plugin, options = {}) { return plugin(this, options); } /** * Generate route matching only `POST` requests. * @example * // Route with destination * app.post('/user').to('User#create'); */ post(...args) { return this.router.post(...args); } /** * Generate route matching only `PUT` requests. * @example * // Route with destination * app.put('/user').to('User#replace'); */ put(...args) { return this.router.put(...args); } /** * Start the command line interface. * @example * // Get arguments from "process.argv" * app.start(); * * // Always start server (rarely used) * app.start('server', '-l', 'http://*:8080'); */ async start(command, ...args) { if (this.detectImport === true && process.argv[1] !== Path.callerFile().toString()) return; return this.cli.start(command, ...args).catch(error => this.log.error(error.message)); } /** * Generate route for a nested route with its own intermediate destination. * @example * // Intermediate destination and prefix shared between two routes * const auth = app.under('/user').to('User#auth'); * auth.get('/show').to('User#show'); * auth.post('/create').to('User#create'); */ under(...args) { return this.router.under(...args); } /** * Warmup the cache, usually called automatically. */ async warmup() { await Promise.all([this.static, this.renderer, this.router].map(component => component.warmup())); await this.hooks.runHook('app:warmup', this); } /** * Generate route matching only WebSocket handshake requests. * @example * // Route with destination * app.websocket('/echo').to('Example#echo'); */ websocket(...args) { return this.router.websocket(...args); } } //# sourceMappingURL=app.js.map