UNPKG

@mojojs/core

Version:

Real-time web framework

379 lines 12.2 kB
import EventEmitter from 'node:events'; import querystring from 'node:querystring'; import { Params } from './body/params.js'; import { SafeString } from './util.js'; const ABSOLUTE = /^[a-zA-Z][a-zA-Z0-9]*:\/\//; /** * Context class. */ class Context extends EventEmitter { constructor(app, req, res, backend) { super({ captureRejections: true }); /** * Partial content. Always returns at least an empty string, even for sections that have not been defined with * `ctx.contentFor()`. * @example * // Get content for "head" section * const head = ctx.content.head; */ this.content = new Proxy({}, { get: function (target, name) { return new SafeString(target[name] ?? ''); } }); /** * WebSocket JSON mode. */ this.jsonMode = false; /** * Router dispatch plan. */ this.plan = null; /** * Non-persistent data storage and exchange for the current request. */ this.stash = {}; this._flash = undefined; this._nestedHelpersCache = {}; this._params = undefined; this._session = undefined; this._ws = null; this.app = app; this.exceptionFormat = app.exceptionFormat; this.req = req; this.res = res; this.res.bindContext(this); this.backend = backend; this.log = app.log.child({ requestId: this.req.requestId }); } [EventEmitter.captureRejectionSymbol](error) { this.exception(error); } /** * Select best possible representation for resource. */ accepts(allowed) { const formats = this.app.mime.detect(this.req.get('Accept') ?? ''); const { stash } = this; if (typeof stash.ext === 'string') formats.unshift(stash.ext); if (allowed === undefined) return formats.length > 0 ? formats : null; const results = formats.filter(format => allowed.includes(format)); return results.length > 0 ? results : null; } /** * Application config shortcut. * @example * // Longer version * const config = ctx.app.config; */ get config() { return this.app.config; } /** * Append partial content to `ctx.content` buffers. * @example * // Add content for "head" section * await ctx.contentFor('head', '<link rel="icon" href="/static/favicon.ico">'); */ async contentFor(name, content) { this.content[name] += await (content instanceof Function ? content() : content); } /** * Data storage persistent only for the next request. * @example * // Show message after redirect * const flash = await ctx.flash(); * flash.message = 'User created successfully!'; * await ctx.redirectTo('show_user', {values: {id: 23}}); */ async flash() { if (this._flash === undefined) { const session = await this.session(); this._flash = new Proxy(session, { get: function (target, name) { if (target.flash === undefined) return undefined; return target.flash[name]; }, set: function (target, name, value) { const nextFlash = target.nextFlash ?? {}; nextFlash[name] = value; target.nextFlash = nextFlash; return true; } }); } return this._flash; } /** * Handle WebSocket upgrade, used by servers. */ handleUpgrade(ws) { this._ws = new WeakRef(ws); this.emit('connection', ws); ws.on('error', error => this.exception(error)); } /** * Home directory shortcut. * @example * // Generate path * const path = ctx.home.child('views', 'foo', 'bar.html.tmpl'); */ get home() { return this.app.home; } /** * Check if WebSocket connection has been accepted. */ get isAccepted() { return this.listenerCount('connection') > 0; } /** * Check if WebSocket connection has been established. */ get isEstablished() { return this._ws !== null; } /** * Check if session is active. */ get isSessionActive() { return this._session !== undefined; } /** * Check if HTTP request is a WebSocket handshake. */ get isWebSocket() { return this.req.isWebSocket; } /** * Accept WebSocket connection and activate JSON mode. * @example * // Echo WebSocket messages * ctx.json(async ws => { * for await (const data of ws) { * // Add a Futurama quote to JSON objects * if (typeof data === 'object') { * data.quote = 'Shut up and take my money!'; * await ws.send(data); * } * } * }); */ json(fn) { this.jsonMode = true; return this.on('connection', fn); } /** * Model shortcut. * @example * // Access arbitrary model objects * const db = await ctx.models.pg.db(); */ get models() { return this.app.models; } /** * GET and POST parameters. * @example * // Get a specific parameter * const params = await ctx.params(); * const foo = params.get('foo'); * * // Ignore all empty parameters * const params = await ctx.params({notEmpty: true}); */ async params(options = {}) { if (this._params === undefined) { const req = this.req; const params = (this._params = new Params(req.query)); for (const [name, value] of await req.form(options)) { params.append(name, value); } } return options.notEmpty === true ? this._params.removeEmpty() : this._params; } /** * Accept WebSocket connection. * @example * // Echo incoming WebSocket messages * ctx.plain(async ws => { * for await (const message of ws) { * await ws.send(`echo: ${message}`); * } * }); */ plain(fn) { return this.on('connection', fn); } /** * Send `302` redirect response. * @example * // Moved Permanently * await ctx.redirect_to('some_route', {status: 301}); */ async redirectTo(target, options = {}) { await this.res .status(options.status ?? 302) .set('Location', this.urlFor(target, { absolute: true, ...options }) ?? '') .send(); } /** * Render dynamic content. * @example * // Render text * await ctx.render({text: 'Hello World!'}); * * // Render JSON * await ctx.render({json: {hello: 'world'}}); * * // Render view "users/list.*.*" and pass it a stash value * await ctx.render({view: 'users/list'}, {foo: 'bar'}); */ async render(options = {}, stash) { const { app } = this; if (stash !== undefined) Object.assign(this.stash, stash); await app.hooks.runHook('render:before', this, options); const result = await app.renderer.render(this, options); if (result === null) { if (options.maybe !== true) throw new Error('Nothing could be rendered'); return false; } return await app.renderer.respond(this, result, { status: options.status }); } /** * Try to render dynamic content to string. */ async renderToString(options, stash) { if (typeof options === 'string') options = { view: options }; Object.assign(this.stash, stash); const result = await this.app.renderer.render(this, options); return result === null ? null : result.output.toString(); } /** * Automatically select best possible representation for resource. */ async respondTo(spec) { const formats = this.accepts() ?? []; let handler; for (const format of formats) { if (spec[format] === undefined) continue; handler = spec[format]; break; } if (handler === undefined && spec.any !== undefined) handler = spec.any; if (handler !== undefined) { if (typeof handler === 'function') { await handler(this); } else { await this.render(handler); } } await this.res.status(204).send(); } /** * Send static file. */ async sendFile(file) { return await this.app.static.serveFile(this, file); } /** * Get JSON schema validation function. */ schema(schema) { return this.app.validator.schema(schema); } /** * Persistent data storage for the next few requests. */ async session() { if (this._session === undefined) this._session = (await this.app.session.load(this)) ?? {}; return this._session; } /** * HTTP/WebSocket user-agent shortcut. */ get ua() { return this.app.ua; } /** * Generate URL for route or path. * @example * // Current URL with query parameter * const url = ctx.urlFor('current', {query: {foo: 'bar'}}); * * // URL for route with placeholder values * const url = ctx.urlFor('users', {values: {id: 23}}); * * // Absolute URL for path with fragment * const url = ctx.urlFor('/some/path', {absolute: true, fragment: 'whatever'}); */ urlFor(target, options = {}) { if (target === undefined || target === 'current') { if (this.plan === null) throw new Error('No current route to generate URL for'); const result = this.plan.render(options.values); return this._urlForPath(result.path, result.websocket, options); } if (target.startsWith('/')) return this._urlForPath(target, false, options); if (ABSOLUTE.test(target)) return target; const route = this.app.router.lookup(target); if (route === null) throw new Error('No route to generate URL for'); return this._urlForPath(route.render(options.values), route.hasWebSocket(), options); } /** * Generate URL for static asset. */ urlForAsset(path, options = {}) { return ABSOLUTE.test(path) === true ? path : this._urlForPath(this.app.static.assetPath(path), false, options); } /** * Generate URL for static file. */ urlForFile(path, options = {}) { return ABSOLUTE.test(path) === true ? path : this._urlForPath(this.app.static.filePath(path), false, options); } /** * Generate URL for route or path and preserve the current query parameters. * @example * // Remove a specific query parameter * const url = ctx.urlWith('current', {query: {foo: null}}); */ urlWith(target, options = {}) { options.query = Object.fromEntries(Object.entries({ ...this.req.query.toObject(), ...(options.query ?? {}) }).filter(([, v]) => v !== null)); return this.urlFor(target, options); } /** * Established WebSocket connection. */ get ws() { return this._ws?.deref() ?? null; } _urlForPath(path, isWebSocket, options) { path = this.req.basePath + (path === '' ? '/' : path); let queryFragment = ''; if (options.query !== undefined && Object.keys(options.query).length > 0) { queryFragment = '?' + querystring.stringify(options.query); } if (options.fragment !== undefined) queryFragment += '#' + querystring.escape(options.fragment); if (options.absolute !== true && isWebSocket === false) return path + queryFragment; const url = this.req.baseURL + path + queryFragment; return isWebSocket ? url.replace(/^http/, 'ws') : url; } } export { Context }; //# sourceMappingURL=context.js.map