@mojojs/core
Version:
Real-time web framework
379 lines • 12.2 kB
JavaScript
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