Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/common/src/ErrorPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export type ErrorPageOptions = {
message?: string
stack?: string
code?: number
title?: string
}

/** The `view()` global registered by `@arkstack/view`. */
type ViewGlobal = {
view?: (name: string, data?: Record<string, unknown>) => { renderSync(): string }
}

/**
* Renders the HTML error page, using the `~arkstack/common.error` view when
* `@arkstack/view` is installed and a built-in document otherwise.
*/
export class ErrorPage {
static readonly titles: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
}

static render({ message = 'An unexpected error occurred.', stack, title, code = 500 }: ErrorPageOptions = {}) {
const data = { code, title: this.titles[code] || title || 'Error', stack, message }
const view = (globalThis as unknown as ViewGlobal).view

if (typeof view === 'function') {
return view('~arkstack/common.error', data).renderSync()
}

return this.document(data)
}

private static document({ code, title, stack, message }: Required<Omit<ErrorPageOptions, 'stack'>> & { stack?: string }) {
return [
'<!DOCTYPE html>',
'<html lang="en">',
'<head>',
'<meta charset="UTF-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
`<title>${code} | ${this.escape(title)}</title>`,
'<style>',
':root{color-scheme:light dark}',
'body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem 1rem;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#0b0c0e;color:#f5f0e8}',
'@media (prefers-color-scheme: light){body{background:#f5f3ef;color:#1a1714}}',
'.page{max-width:620px;width:100%}',
'.code{font-size:3.5rem;font-weight:800;color:#e05252;margin:0 0 .5rem}',
'.title{font-size:1.2rem;font-weight:700;margin:0 0 .6rem}',
'.message{font-size:.8rem;opacity:.7;line-height:1.7;margin:0 0 2rem}',
'pre{font-size:.72rem;line-height:1.85;overflow-x:auto;padding:1.1rem 1.25rem;border:1px solid rgba(220,60,60,.22);border-radius:10px}',
'</style>',
'</head>',
'<body>',
'<div class="page">',
`<div class="code">${code}</div>`,
`<div class="title">${this.escape(title)}</div>`,
`<p class="message">${this.escape(message)}</p>`,
...(stack ? [`<pre>${this.escape(stack)}</pre>`] : []),
'</div>',
'</body>',
'</html>',
].join('\n')
}

private static escape(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
}
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './Exceptions/Exception'
export * from './Exceptions/AppException'
export * from './Exceptions/RequestException'
export * from './ErrorHandler'
export * from './ErrorPage'
export * from './Publisher'
export * from './Logger'
export * from './Hook'
Expand Down
41 changes: 3 additions & 38 deletions packages/common/src/network.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { abort, abortIf, assertFound, initializeGlobalContext } from './utils'
import { config, env } from './system'

import { ErrorPage, type ErrorPageOptions } from './ErrorPage'
import { Arkstack } from '@arkstack/contract'
import { Hook } from '@arkstack/foundry'
import { bindGracefulShutdown } from './lifecycle'
Expand Down Expand Up @@ -45,41 +46,5 @@ export const bootWithDetectedPort = async <TApp, TRoutes = unknown, THandler = u
await Hook.emit('boot', 'after', port, app)
}

/** The `view()` global registered by `@arkstack/view`. */
type ViewGlobal = {
view: (name: string, data?: Record<string, unknown>) => { renderSync(): string }
}

export const renderError = ({
message = 'An unexpected error occurred.',
stack,
title,
code = 500,
}: {
message?: string
stack?: string
code?: number
title?: string
}) => {
const titleMap: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
}

title = titleMap[code] || title || 'Error'

return (globalThis as unknown as ViewGlobal)
.view('~arkstack/common.error', {
code,
title,
stack,
message,
})
.renderSync()
}
/** Renders the HTML error page, with or without `@arkstack/view` installed. */
export const renderError = (options: ErrorPageOptions = {}) => ErrorPage.render(options)
29 changes: 29 additions & 0 deletions packages/common/tests/error-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, test } from 'vitest'

import { renderError } from '../src/network'

describe('renderError', () => {
afterEach(() => {
delete (globalThis as Record<string, unknown>).view
})

test('renders a built-in page when @arkstack/view is not installed', () => {
const html = renderError({ code: 404, message: '<missing>', stack: 'at <anonymous>' })

expect(html).toContain('<title>404 | Not Found</title>')
expect(html).toContain('&lt;missing&gt;')
expect(html).toContain('<pre>at &lt;anonymous&gt;</pre>')
})

test('omits the stack block when no stack is given', () => {
expect(renderError({ code: 500 })).not.toContain('<pre>')
})

test('renders through the view global when it is registered', () => {
;(globalThis as Record<string, unknown>).view = (name: string, data: Record<string, unknown>) => ({
renderSync: () => `${name}:${data.code}:${data.title}`,
})

expect(renderError({ code: 403 })).toBe('~arkstack/common.error:403:Forbidden')
})
})
36 changes: 32 additions & 4 deletions packages/notifications/src/drivers/MailNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import Mail, { Attachment } from 'nodemailer/lib/mailer'
export class MailNotification extends NotificationContract {
driver!: Transporter
private options: MailDriverOptions = {}
private ViewName: string = '~arkstack/notifications.mail'
private static readonly defaultView = '~arkstack/notifications.mail'
private ViewName: string = MailNotification.defaultView
private transport: NonNullable<MailDriverOptions['transport']> | Transport = 'smtp'
private sesOptions?: Record<string, any>
private recipients?: MailRecipient
Expand Down Expand Up @@ -225,6 +226,35 @@ export class MailNotification extends NotificationContract {
return this
}

/**
* Render the html body from the html template, the configured view, or the
* built-in layout when `@arkstack/view` is not installed.
*/
private async renderHtml(data: NotificationData) {
if (this.htmlTemplate) {
return interpolate(this.htmlTemplate, data)
}

if (typeof globalThis.view === 'function') {
return await globalThis.view(this.ViewName, data)
}

if (this.ViewName !== MailNotification.defaultView) {
throw new Error(`Mail view [${this.ViewName}] requires @arkstack/view to be installed.`)
}

return [
'<!doctype html>',
'<html>',
'<body style="font-family:Arial,sans-serif;line-height:1.5;color:#111827">',
`<h2>${data.subject ?? ''}</h2>`,
`<div>${data.message ?? ''}</div>`,
`<p style="color:#6b7280;font-size:12px">&copy; ${new Date().getFullYear()} ${data.app_name ?? ''}</p>`,
'</body>',
'</html>',
].join('\n')
}

/**
* Prepare a notification to be sent.
*
Expand Down Expand Up @@ -284,9 +314,7 @@ export class MailNotification extends NotificationContract {
...templateData,
message: textMessage,
}),
html: this.htmlTemplate
? interpolate(this.htmlTemplate, templateData)
: await globalThis.view(this.ViewName, templateData),
html: await this.renderHtml(templateData),
}

if (this.transport === 'ses') {
Expand Down
27 changes: 27 additions & 0 deletions packages/notifications/tests/notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ describe('Notification', () => {
expect(content).toContain('<body><strong>Hello Ada</strong></body>')
})

it('delivers mail with the built-in layout when @arkstack/view is not installed', async () => {
const view = globalThis.view

delete (globalThis as Record<string, unknown>).view

try {
await Notification.mail({ from: 'noreply@arkstack.test', host: 'smtp.arkstack.test', port: 2525 })
.recipient('ada@example.com')
.subject('Welcome, {name}')
.data({ app_name: 'Arkstack', name: 'Ada' })
.send('<strong>Hello {name}</strong>')

const content = String(mocks.sendMail.mock.calls[0][0].html)

expect(content).toContain('<h2>Welcome, Ada</h2>')
expect(content).toContain('<div><strong>Hello Ada</strong></div>')
await expect(
Notification.mail({ from: 'noreply@arkstack.test', host: 'smtp.arkstack.test', port: 2525 })
.recipient('ada@example.com')
.view('emails.welcome')
.send('Hello'),
).rejects.toThrow('requires @arkstack/view')
} finally {
globalThis.view = view
}
})

it('delivers mail to named address recipients', async () => {
const recipients = [{ 'ada@example.com': 'Ada Lovelace' }, { 'grace@example.com': 'Grace Hopper' }]
const nodemailerRecipients = [
Expand Down
Loading