From bb443d5d28b70a377b6960a9df2562550b597283 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:03 -0400 Subject: [PATCH 01/22] feat: render Mermaid diagrams in markdown Mermaid loads only on pages that contain a diagram, and colors come from VitePress variables so light and dark mode share one stylesheet. --- .vitepress/config.ts | 13 + .vitepress/theme/index.ts | 5 + .vitepress/theme/main.css | 74 +++ .vitepress/theme/mermaid-zoom.ts | 184 +++++++ README.md | 2 + components/Mermaid.vue | 80 ++++ package.json | 1 + pnpm-lock.yaml | 793 +++++++++++++++++++++++++++++++ 8 files changed, 1152 insertions(+) create mode 100644 .vitepress/theme/mermaid-zoom.ts create mode 100644 components/Mermaid.vue diff --git a/.vitepress/config.ts b/.vitepress/config.ts index ba128731..5eae068e 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -88,6 +88,17 @@ export default defineConfig({ markdown: { config(md) { md.use(groupIconMdPlugin); + + // ```mermaid fences render client-side through components/Mermaid.vue. + const fence = md.renderer.rules.fence!; + md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + if (token.info.trim() !== 'mermaid') { + return fence(tokens, idx, options, env, self); + } + + return ``; + }; }, lineNumbers: true, theme: { @@ -204,6 +215,8 @@ export default defineConfig({ title: 'hapi.dev', titleTemplate: 'hapi.dev - :title', vite: { + // Pre-bundled at startup; otherwise the first page with a diagram triggers a re-optimization and full reload. + optimizeDeps: { include: ['mermaid'] }, plugins: [groupIconVitePlugin()], }, }); diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts index 7b3d9503..4b32756b 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -6,6 +6,7 @@ import './variables.css'; import './main.css'; import 'virtual:group-icons.css'; +import { setupMermaidZoom } from './mermaid-zoom.js'; import { getRedirectPath } from './redirect.js'; import type { Theme } from 'vitepress'; @@ -13,6 +14,7 @@ import type { Theme } from 'vitepress'; const ApiOutline = defineAsyncComponent(() => import('../../components/ApiOutline.vue')); const CarbonAds = defineAsyncComponent(() => import('../../components/CarbonAds.vue')); const HomeContent = defineAsyncComponent(() => import('../../components/HomeContent.vue')); +const Mermaid = defineAsyncComponent(() => import('../../components/Mermaid.vue')); const ModuleIndex = defineAsyncComponent(() => import('../../components/ModuleIndex.vue')); const PluginsDirectory = defineAsyncComponent(() => import('../../components/PluginsDirectory.vue')); const StatusContent = defineAsyncComponent(() => import('../../components/StatusContent.vue')); @@ -27,12 +29,15 @@ export default { enhanceApp({ app, router }) { app.component('ApiOutline', ApiOutline); app.component('HomeContent', HomeContent); + app.component('Mermaid', Mermaid); app.component('ModuleIndex', ModuleIndex); app.component('PluginsDirectory', PluginsDirectory); app.component('StatusContent', StatusContent); app.component('TutorialLang', TutorialLang); if (typeof window !== 'undefined') { + setupMermaidZoom(); + router.onBeforeRouteChange = (to) => { const target = getRedirectPath(to); if (target) { diff --git a/.vitepress/theme/main.css b/.vitepress/theme/main.css index d0502269..6a517125 100644 --- a/.vitepress/theme/main.css +++ b/.vitepress/theme/main.css @@ -421,3 +421,77 @@ select.form-control { border: 1px solid var(--vp-c-divider); margin: 0 0 0 20px; } + +/* ---------- Mermaid ---------- */ +.vp-doc .mermaid { + display: flex; + justify-content: center; + margin: 16px 0; + padding: 24px 16px; + border-radius: 8px; + background-color: var(--vp-c-bg-soft); + cursor: zoom-in; +} + +/* Mermaid sizes label boxes at its own line-height, outside .vp-doc. */ +.vp-doc .mermaid foreignObject p { + line-height: inherit; +} + +.hapi-mermaid-lightbox { + position: fixed; + inset: 0; + z-index: 2000; + overflow: hidden; + touch-action: none; + user-select: none; + background: color-mix(in srgb, var(--vp-c-bg) 90%, transparent); + backdrop-filter: blur(4px); + cursor: grab; +} + +.hapi-mermaid-lightbox:active { + cursor: grabbing; +} + +.hapi-mermaid-lightbox-stage { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} + +.hapi-mermaid-lightbox-stage svg { + display: block; +} + +.hapi-mermaid-lightbox-close { + position: fixed; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--vp-c-divider); + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-1); + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.hapi-mermaid-lightbox-close:hover { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-brand-1); +} + +.hapi-mermaid-lightbox-hint { + position: fixed; + bottom: 16px; + left: 0; + right: 0; + text-align: center; + font-size: 12px; + color: var(--vp-c-text-3); + pointer-events: none; +} diff --git a/.vitepress/theme/mermaid-zoom.ts b/.vitepress/theme/mermaid-zoom.ts new file mode 100644 index 00000000..bcc61f7c --- /dev/null +++ b/.vitepress/theme/mermaid-zoom.ts @@ -0,0 +1,184 @@ +const SCALE_MIN = 0.1; +const SCALE_MAX = 40; + +interface PointerPosition { + x: number; + y: number; +} + +function naturalSize(svg: SVGSVGElement): { width: number; height: number } { + const viewBox = svg.viewBox.baseVal; + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height }; + } + const rect = svg.getBoundingClientRect(); + return { width: Math.max(rect.width, 1), height: Math.max(rect.height, 1) }; +} + +function openLightbox(svg: SVGSVGElement, opener: HTMLElement) { + const { width, height } = naturalSize(svg); + + const overlay = document.createElement('div'); + overlay.className = 'hapi-mermaid-lightbox'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Diagram viewer'); + + const stage = document.createElement('div'); + stage.className = 'hapi-mermaid-lightbox-stage'; + + // The diagram's styles and marker refs are scoped by its svg id; a renamed copy keeps them + // without colliding with the original on the page. + const markup = svg.id ? svg.outerHTML.split(svg.id).join(`${svg.id}-lightbox`) : svg.outerHTML; + stage.innerHTML = markup; + + const clone = stage.querySelector('svg'); + if (!clone) { + return; + } + clone.style.width = `${width}px`; + clone.style.height = `${height}px`; + clone.style.maxWidth = 'none'; + + const close = document.createElement('button'); + close.className = 'hapi-mermaid-lightbox-close'; + close.setAttribute('aria-label', 'Close diagram viewer'); + close.textContent = '×'; + + const hint = document.createElement('div'); + hint.className = 'hapi-mermaid-lightbox-hint'; + hint.textContent = 'scroll or pinch to zoom · drag to pan · double-click to reset · esc to close'; + + overlay.append(stage, close, hint); + + let scale = 1; + let tx = 0; + let ty = 0; + + const apply = () => { + stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; + }; + + const fit = () => { + scale = Math.min((window.innerWidth * 0.92) / width, (window.innerHeight * 0.92) / height); + tx = (window.innerWidth - width * scale) / 2; + ty = (window.innerHeight - height * scale) / 2; + apply(); + }; + + const zoomAt = (clientX: number, clientY: number, factor: number) => { + const next = Math.min(SCALE_MAX, Math.max(SCALE_MIN, scale * factor)); + const applied = next / scale; + tx = clientX - (clientX - tx) * applied; + ty = clientY - (clientY - ty) * applied; + scale = next; + apply(); + }; + + const pointers = new Map(); + + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + zoomAt(event.clientX, event.clientY, Math.exp(-event.deltaY * 0.002)); + }; + + const onPointerDown = (event: PointerEvent) => { + overlay.setPointerCapture(event.pointerId); + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + }; + + const onPointerMove = (event: PointerEvent) => { + const previous = pointers.get(event.pointerId); + if (!previous) { + return; + } + const current = { x: event.clientX, y: event.clientY }; + + if (pointers.size === 1) { + tx += current.x - previous.x; + ty += current.y - previous.y; + apply(); + } else if (pointers.size === 2) { + const other = [...pointers.entries()].find(([id]) => id !== event.pointerId); + if (other) { + const [, anchor] = other; + const previousDistance = Math.hypot(previous.x - anchor.x, previous.y - anchor.y); + const currentDistance = Math.hypot(current.x - anchor.x, current.y - anchor.y); + if (previousDistance > 0) { + const midX = (current.x + anchor.x) / 2; + const midY = (current.y + anchor.y) / 2; + zoomAt(midX, midY, currentDistance / previousDistance); + } + } + } + pointers.set(event.pointerId, current); + }; + + const onPointerEnd = (event: PointerEvent) => { + pointers.delete(event.pointerId); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + teardown(); + } + + // The close button is the only control, so Tab stays on it instead of reaching the page behind. + if (event.key === 'Tab') { + event.preventDefault(); + close.focus(); + } + }; + + const teardown = () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('resize', fit); + overlay.remove(); + document.body.style.removeProperty('overflow'); + opener.focus(); + }; + + overlay.addEventListener('wheel', onWheel, { passive: false }); + overlay.addEventListener('pointerdown', onPointerDown); + overlay.addEventListener('pointermove', onPointerMove); + overlay.addEventListener('pointerup', onPointerEnd); + overlay.addEventListener('pointercancel', onPointerEnd); + overlay.addEventListener('dblclick', fit); + close.addEventListener('click', teardown); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('resize', fit); + + document.body.style.overflow = 'hidden'; + document.body.appendChild(overlay); + close.focus(); + fit(); +} + +function openFrom(target: EventTarget | null) { + if (!(target instanceof Element)) { + return false; + } + const container = target.closest('.mermaid'); + if (!(container instanceof HTMLElement) || container.closest('.hapi-mermaid-lightbox')) { + return false; + } + const svg = container.querySelector('svg'); + if (!svg) { + return false; + } + openLightbox(svg, container); + return true; +} + +/** Opens a rendered Mermaid diagram in a full-screen viewer with zoom and pan, by click, Enter, or Space. */ +export function setupMermaidZoom() { + document.addEventListener('click', (event) => { + openFrom(event.target); + }); + + document.addEventListener('keydown', (event) => { + if ((event.key === 'Enter' || event.key === ' ') && openFrom(event.target)) { + event.preventDefault(); + } + }); +} diff --git a/README.md b/README.md index a9529278..5017e420 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Tutorials live in `docs/tutorials/{locale}/` with one markdown file per tutorial To add a new translation, create a directory under `docs/tutorials/` with the locale code (e.g. `fr_FR`) and add markdown files matching the English tutorial filenames. +A ` ```mermaid ` code block renders as a diagram (`components/Mermaid.vue`), loaded only on pages that have one; clicking a diagram opens it in a zoomable viewer. Mark key flowchart nodes with `class A,B highlight` instead of inline styles, so they follow the site theme in light and dark mode, and say in the text which nodes are highlighted and why, since color alone carries no meaning for every reader. + ## Plugins hapi.dev maintains a list of community-created plugins at [hapi.dev/plugins](http://hapi.dev/plugins). The plugin data lives in `docs/public/lib/plugins.json`. To add a plugin, send a [pull request](https://github.com/hapijs/hapi.dev/blob/master/docs/public/lib/plugins.json) with your addition. Please keep plugins in alphabetical order within their category. diff --git a/components/Mermaid.vue b/components/Mermaid.vue new file mode 100644 index 00000000..a013f5c9 --- /dev/null +++ b/components/Mermaid.vue @@ -0,0 +1,80 @@ + + + diff --git a/package.json b/package.json index 5a74e5a9..c3126e1b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@vueuse/core": "^14.3.0", "es-toolkit": "^1.49.0", "feed": "^6.0.0", + "mermaid": "^11.17.2", "semver": "^7.8.5", "vitepress-plugin-group-icons": "^1.7.5", "vue": "^3.5.40" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5af27b4..de9fe3f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: feed: specifier: ^6.0.0 version: 6.0.0 + mermaid: + specifier: ^11.17.2 + version: 11.17.2 semver: specifier: ^7.8.5 version: 7.8.5 @@ -80,6 +83,12 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@docsearch/css@4.6.3': resolution: {integrity: sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==} @@ -279,6 +288,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -730,6 +742,102 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -751,6 +859,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -760,6 +871,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-vue@6.0.8': resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -879,9 +993,185 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.4: + resolution: {integrity: sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -893,6 +1183,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -911,6 +1204,9 @@ packages: fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -932,6 +1228,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -944,12 +1243,36 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + json-with-bigint@3.5.8: resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -1024,15 +1347,26 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -1091,6 +1425,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -1101,6 +1438,12 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.22: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} @@ -1117,11 +1460,23 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -1142,9 +1497,15 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} @@ -1163,6 +1524,10 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1197,6 +1562,10 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + uuid@14.0.3: + resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==} + hasBin: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -1301,6 +1670,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@docsearch/css@4.6.3': {} '@docsearch/js@4.6.3': {} @@ -1429,6 +1802,10 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -1724,6 +2101,125 @@ snapshots: tslib: 2.8.1 optional: true + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -1747,12 +2243,20 @@ snapshots: '@types/semver@7.7.1': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-vue@6.0.8(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.22.4))(vue@3.5.40(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -1861,8 +2365,210 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@7.2.0: {} + + commander@8.3.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.4): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.4 + + cytoscape-fcose@2.2.0(cytoscape@3.34.4): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.4 + + cytoscape@3.34.4: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + dayjs@1.11.23: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -1871,6 +2577,10 @@ snapshots: dependencies: dequal: 2.0.3 + dompurify@3.4.17: + optionalDependencies: + '@types/trusted-types': 2.0.7 + entities@7.0.1: {} es-toolkit@1.49.0: {} @@ -1908,6 +2618,10 @@ snapshots: fast-content-type-parse@3.0.0: {} + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -1923,6 +2637,8 @@ snapshots: fsevents@2.3.3: optional: true + hachure-fill@0.5.2: {} + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -1945,10 +2661,28 @@ snapshots: html-void-elements@3.0.0: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + import-meta-resolve@4.2.0: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + json-with-bigint@3.5.8: {} + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -1998,12 +2732,16 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lodash-es@4.18.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 mark.js@8.11.1: {} + marked@16.4.2: {} + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 @@ -2016,6 +2754,31 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mermaid@11.17.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.4 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.4) + cytoscape-fcose: 2.2.0(cytoscape@3.34.4) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.17 + es-toolkit: 1.49.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.3 + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -2093,12 +2856,21 @@ snapshots: package-manager-detector@1.6.0: {} + path-data-parser@0.1.0: {} + perfect-debounce@2.1.0: {} picocolors@1.1.1: {} picomatch@4.0.5: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.22: dependencies: nanoid: 3.3.16 @@ -2117,6 +2889,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + robust-predicates@3.0.3: {} + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -2138,6 +2912,17 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + sax@1.6.0: {} semver@7.8.5: {} @@ -2157,11 +2942,15 @@ snapshots: space-separated-tokens@2.0.2: {} + strictdom@1.0.1: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + stylis@4.4.0: {} + tabbable@6.5.0: {} tinyexec@1.2.4: {} @@ -2175,6 +2964,8 @@ snapshots: trim-lines@3.0.1: {} + ts-dedent@2.3.0: {} + tslib@2.8.1: optional: true @@ -2213,6 +3004,8 @@ snapshots: universal-user-agent@7.0.3: {} + uuid@14.0.3: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 From bfb181b403e3ffb6d1e8c5ae4d49d8c463c9ec43 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:11 -0400 Subject: [PATCH 02/22] fix: keep URL hash on first-load redirect Opening /api#section directly landed at the top of the page instead of the section. --- .vitepress/theme/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts index 4b32756b..149441cd 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -46,7 +46,7 @@ export default { } }; - const initialTarget = getRedirectPath(window.location.pathname); + const initialTarget = getRedirectPath(window.location.pathname + window.location.hash); if (initialTarget) { router.go(initialTarget); } From a21ce8d068bc47f2a256c2791428aea38afa1d47 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:20 -0400 Subject: [PATCH 03/22] feat: add Spanish tutorial locale --- .vitepress/config.ts | 1 + .vitepress/theme/redirect.ts | 2 +- README.md | 4 +-- components/TutorialLang.vue | 5 +-- docs/tutorials/es_ES/community.md | 57 +++++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 docs/tutorials/es_ES/community.md diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 5eae068e..7bbef0ba 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -197,6 +197,7 @@ export default defineConfig({ }, ], '/tutorials/en_US/': getTutorialSidebar('en_US'), + '/tutorials/es_ES/': getTutorialSidebar('es_ES'), '/tutorials/ko_KR/': getTutorialSidebar('ko_KR'), '/tutorials/pt_BR/': getTutorialSidebar('pt_BR'), '/tutorials/tr_TR/': getTutorialSidebar('tr_TR'), diff --git a/.vitepress/theme/redirect.ts b/.vitepress/theme/redirect.ts index 718bb260..cce2cbf2 100644 --- a/.vitepress/theme/redirect.ts +++ b/.vitepress/theme/redirect.ts @@ -52,7 +52,7 @@ export const getRedirectPath = (toPath: string) => { if (tutorialMatch) { const oldName = tutorialMatch.at(1)!; // If it's a locale code, skip (it's a directory) - if (['en_US', 'ko_KR', 'pt_BR', 'tr_TR', 'zh_CN'].includes(oldName)) { + if (['en_US', 'es_ES', 'ko_KR', 'pt_BR', 'tr_TR', 'zh_CN'].includes(oldName)) { return null; } const newName = tutorialRenames[oldName] ?? oldName; diff --git a/README.md b/README.md index 5017e420..0de58a89 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ components/ # Vue 3 components docs/ # VitePress markdown pages and static assets ├── api/ # hapi core API docs (dynamic routes) ├── module/ # Module pages (install, API, changelog) - ├── tutorials/ # Tutorials in 5 languages (en_US, ko_KR, pt_BR, tr_TR, zh_CN) + ├── tutorials/ # Tutorials in 6 languages (en_US, es_ES, ko_KR, pt_BR, tr_TR, zh_CN) ├── policies/ # Policy pages ├── resources/ # Resources (changelog, status, books, etc.) └── public/ # Static assets (images, plugins.json) @@ -81,7 +81,7 @@ generated/ # Auto-generated data (from pnpm fetch-data) ## Tutorials -Tutorials live in `docs/tutorials/{locale}/` with one markdown file per tutorial. Available locales: `en_US`, `ko_KR`, `pt_BR`, `tr_TR`, `zh_CN`. +Tutorials live in `docs/tutorials/{locale}/` with one markdown file per tutorial. Available locales: `en_US`, `es_ES`, `ko_KR`, `pt_BR`, `tr_TR`, `zh_CN`. To add a new translation, create a directory under `docs/tutorials/` with the locale code (e.g. `fr_FR`) and add markdown files matching the English tutorial filenames. diff --git a/components/TutorialLang.vue b/components/TutorialLang.vue index 6c4114a8..143104c1 100644 --- a/components/TutorialLang.vue +++ b/components/TutorialLang.vue @@ -24,6 +24,7 @@ const savedLang = useStorage('hapi-tutorial-lang', ''); const languages = [ { label: 'English', locale: 'en_US' }, + { label: 'Español', locale: 'es_ES' }, { label: '한국어', locale: 'ko_KR' }, { label: 'Português', locale: 'pt_BR' }, { label: 'Türkçe', locale: 'tr_TR' }, @@ -32,13 +33,13 @@ const languages = [ const currentSlug = computed(() => { const { path } = route; - const match = path.match(/\/tutorials\/(?:en_US|ko_KR|pt_BR|tr_TR|zh_CN)\/(.+?)(?:\.html)?$/); + const match = path.match(/\/tutorials\/(?:en_US|es_ES|ko_KR|pt_BR|tr_TR|zh_CN)\/(.+?)(?:\.html)?$/); return match ? match[1] : 'getting-started'; }); const currentLocale = computed(() => { const { path } = route; - const match = path.match(/\/tutorials\/(en_US|ko_KR|pt_BR|tr_TR|zh_CN)\//); + const match = path.match(/\/tutorials\/(en_US|es_ES|ko_KR|pt_BR|tr_TR|zh_CN)\//); return match ? match[1] : 'en_US'; }); diff --git a/docs/tutorials/es_ES/community.md b/docs/tutorials/es_ES/community.md new file mode 100644 index 00000000..9150238e --- /dev/null +++ b/docs/tutorials/es_ES/community.md @@ -0,0 +1,57 @@ +--- +title: Comunidad +--- + + + +# Tutoriales de la comunidad + +- [Authentication and Authorization with hapi](https://medium.com/@poeticninja/authentication-and-authorization-with-hapi-5529b5ecc8ec) + + Artículo sobre cómo proteger tu aplicación hapi. + +- [Building a Chat Application with hapi.js, Socket.io and Redis](https://github.com/dwyl/hapi-socketio-redis-chat-example) + + Aplicación de chat en tiempo real construida con hapi.js, Socket.io y Redis Pub/Sub, con tests end-to-end. + +- [Building a modern backend with TypeScript, Hapi, PostgreSQL and Prisma](https://www.prisma.io/blog/series/modern-backend-bdes2ps5kibb) + + Una serie de directos y artículos sobre cómo construir un backend con TypeScript, Hapi, PostgreSQL y Prisma. + La serie cubre modelado de datos, CRUD, agregaciones, REST, validación con Joi, testing, autenticación, autorización, integración con otras APIs, e integración y despliegue continuos con GitHub Actions y Heroku. + La serie explora y muestra distintos patrones, problemas y arquitecturas para un backend moderno resolviendo un problema concreto: un sistema de calificaciones para cursos online. Es un buen ejemplo porque incluye relaciones de tipos diversos y es lo bastante complejo como para representar un caso de uso real. + +- [Handling plugin dependencies](https://hapipal.com/best-practices/handling-plugin-dependencies) + + Partiendo de los límites entre plugins de hapi, este artículo propone una forma concreta de controlar las dependencias entre plugins. + +- [hapi tutorial series (30+ tutorials)](https://futurestud.io/tutorials/hapi-get-your-server-up-and-running) + + Esta serie de tutoriales en profundidad sobre hapi cubre la configuración de rutas, la autenticación, los plugins, el manejo de peticiones y respuestas, y mucho más. + +- [hapi.js tutorials covering various topics](https://content.nanobox.io/tag/hapi.js/) + + Una lista creciente de tutoriales sobre desarrollo, despliegue, optimización y gestión en producción (escalado, monitorización, etc.) de aplicaciones hapi.js. + +- [How to create a REST API with hapi](http://blog.webkid.io/how-to-create-a-rest-api-with-hapi/) + + Tutorial sobre cómo crear una API REST con los plugins Dogwater (ORM Waterline) y Bedwetter. + +- [The joys of server / plugin separation](https://hapipal.com/best-practices/server-plugin-separation) + + Separar el plugin de tu aplicación de su despliegue hace que tu app hapi sea más portable y más fácil de testear. + +- [rate-limiter-flexible-plugin-example](https://github.com/animir/node-rate-limiter-flexible/wiki/Hapi-plugin) + + Ejemplo de plugin de limitación de peticiones y protección contra DDoS con el paquete rate-limiter-flexible. + +- [Run hapi.js on Google Cloud Platform](https://cloud.google.com/nodejs/resources/frameworks/hapi) + + Tutorial y aplicación de ejemplo clonable que muestra cómo crear y desplegar una aplicación hapi.js en Google App Engine. + +- [Catbox-Redis and Cookie Auth Example](https://github.com/johnmanko/catbox-redis-example) + + Muestra cómo usar @hapi/catbox-redis y @hapi/cookie. + +- [WittCode's Hapi Tutorial Series](https://youtube.com/playlist?list=PLkqiWyX-_LotaQ9AuppIAXl0xyV-P5Ms-) + + 10 vídeos que empiezan por crear un servidor Hapi, añadir rutas y plugins, y llegan hasta el uso de Sequelize/MySQL y la autenticación por cookie. From 657631d3c50f57d06af6e108484fa05e34852749 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:27 -0400 Subject: [PATCH 04/22] docs: translate community tutorials page Replaces the "no tutorials yet" placeholders with the English list. --- docs/tutorials/ko_KR/community.md | 54 ++++++++++++++++++++++++++++--- docs/tutorials/pt_BR/community.md | 54 ++++++++++++++++++++++++++++--- docs/tutorials/tr_TR/community.md | 54 ++++++++++++++++++++++++++++--- docs/tutorials/zh_CN/community.md | 54 ++++++++++++++++++++++++++++--- 4 files changed, 200 insertions(+), 16 deletions(-) diff --git a/docs/tutorials/ko_KR/community.md b/docs/tutorials/ko_KR/community.md index b8eec564..db55fdc4 100644 --- a/docs/tutorials/ko_KR/community.md +++ b/docs/tutorials/ko_KR/community.md @@ -1,11 +1,57 @@ --- -title: Community +title: 커뮤니티 --- -# Community Tutorials +# 커뮤니티 튜토리얼 -## No tutorials yet +- [Authentication and Authorization with hapi](https://medium.com/@poeticninja/authentication-and-authorization-with-hapi-5529b5ecc8ec) -There are no tutorials for this language. If you would like to add one, please submit a pull request. + hapi 애플리케이션을 보호하는 방법을 다룬 글입니다. + +- [Building a Chat Application with hapi.js, Socket.io and Redis](https://github.com/dwyl/hapi-socketio-redis-chat-example) + + hapi.js, Socket.io, Redis Pub/Sub로 만든 실시간 채팅 애플리케이션으로, end-to-end 테스트가 포함되어 있습니다. + +- [Building a modern backend with TypeScript, Hapi, PostgreSQL and Prisma](https://www.prisma.io/blog/series/modern-backend-bdes2ps5kibb) + + TypeScript, Hapi, PostgreSQL, Prisma로 백엔드를 만드는 라이브 스트림과 글 시리즈입니다. + 데이터 모델링, CRUD, 집계, REST, Joi를 사용한 검증, 테스트, 인증, 인가, 다른 API와의 연동, GitHub Actions와 Heroku를 사용한 지속적 통합과 배포를 다룹니다. + 온라인 강의의 성적 관리 시스템이라는 구체적인 문제를 풀면서 현대적인 백엔드의 다양한 패턴, 문제, 아키텍처를 살펴보고 보여 줍니다. 관계 유형이 다양하고 실제 사용 사례를 대표할 만큼 복잡하기 때문에 좋은 예제입니다. + +- [Handling plugin dependencies](https://hapipal.com/best-practices/handling-plugin-dependencies) + + hapi 플러그인 경계를 살려, 플러그인 사이의 의존성을 다루는 구체적인 방법을 소개합니다. + +- [hapi tutorial series (30+ tutorials)](https://futurestud.io/tutorials/hapi-get-your-server-up-and-running) + + 라우트 설정, 인증, 플러그인, 요청과 응답 처리 등을 깊이 있게 다루는 hapi 튜토리얼 시리즈입니다. + +- [hapi.js tutorials covering various topics](https://content.nanobox.io/tag/hapi.js/) + + hapi.js 애플리케이션 개발, 배포, 최적화, 운영 관리(확장, 모니터링 등)를 다루는 튜토리얼 목록으로, 계속 늘어나고 있습니다. + +- [How to create a REST API with hapi](http://blog.webkid.io/how-to-create-a-rest-api-with-hapi/) + + Dogwater(Waterline ORM)와 Bedwetter 플러그인으로 RESTful API를 만드는 튜토리얼입니다. + +- [The joys of server / plugin separation](https://hapipal.com/best-practices/server-plugin-separation) + + 애플리케이션 플러그인을 배포 방식과 분리하면 hapi 앱을 옮기기 쉬워지고 테스트하기도 간단해집니다. + +- [rate-limiter-flexible-plugin-example](https://github.com/animir/node-rate-limiter-flexible/wiki/Hapi-plugin) + + rate-limiter-flexible 패키지로 요청 속도 제한과 DDoS 방어를 구현하는 플러그인 예제입니다. + +- [Run hapi.js on Google Cloud Platform](https://cloud.google.com/nodejs/resources/frameworks/hapi) + + hapi.js 애플리케이션을 만들어 Google App Engine에 배포하는 방법을 보여 주는 튜토리얼과 복제할 수 있는 샘플 앱입니다. + +- [Catbox-Redis and Cookie Auth Example](https://github.com/johnmanko/catbox-redis-example) + + @hapi/catbox-redis와 @hapi/cookie를 사용하는 방법을 보여 줍니다. + +- [WittCode's Hapi Tutorial Series](https://youtube.com/playlist?list=PLkqiWyX-_LotaQ9AuppIAXl0xyV-P5Ms-) + + Hapi 서버 만들기부터 라우트, 플러그인 추가, Sequelize/MySQL과 쿠키 인증 사용까지 다루는 영상 10편입니다. diff --git a/docs/tutorials/pt_BR/community.md b/docs/tutorials/pt_BR/community.md index b8eec564..3943e6a9 100644 --- a/docs/tutorials/pt_BR/community.md +++ b/docs/tutorials/pt_BR/community.md @@ -1,11 +1,57 @@ --- -title: Community +title: Comunidade --- -# Community Tutorials +# Tutoriais da comunidade -## No tutorials yet +- [Authentication and Authorization with hapi](https://medium.com/@poeticninja/authentication-and-authorization-with-hapi-5529b5ecc8ec) -There are no tutorials for this language. If you would like to add one, please submit a pull request. + Artigo sobre como proteger sua aplicação hapi. + +- [Building a Chat Application with hapi.js, Socket.io and Redis](https://github.com/dwyl/hapi-socketio-redis-chat-example) + + Aplicação de chat em tempo real construída com hapi.js, Socket.io e Redis Pub/Sub, com testes end-to-end. + +- [Building a modern backend with TypeScript, Hapi, PostgreSQL and Prisma](https://www.prisma.io/blog/series/modern-backend-bdes2ps5kibb) + + Uma série de transmissões ao vivo e artigos sobre como construir um backend com TypeScript, Hapi, PostgreSQL e Prisma. + A série cobre modelagem de dados, CRUD, agregações, REST, validação com Joi, testes, autenticação, autorização, integração com outras APIs e integração e entrega contínuas com GitHub Actions e Heroku. + A série explora e demonstra diferentes padrões, problemas e arquiteturas para um backend moderno resolvendo um problema concreto: um sistema de notas para cursos online. É um bom exemplo porque inclui relações de tipos variados e é complexo o bastante para representar um caso de uso real. + +- [Handling plugin dependencies](https://hapipal.com/best-practices/handling-plugin-dependencies) + + Partindo dos limites entre plugins do hapi, este artigo propõe uma forma concreta de controlar as dependências entre plugins. + +- [hapi tutorial series (30+ tutorials)](https://futurestud.io/tutorials/hapi-get-your-server-up-and-running) + + Esta série de tutoriais aprofundados sobre hapi cobre configuração de rotas, autenticação, plugins, tratamento de requisições e respostas, e muito mais. + +- [hapi.js tutorials covering various topics](https://content.nanobox.io/tag/hapi.js/) + + Uma lista crescente de tutoriais sobre desenvolvimento, deploy, otimização e gestão em produção (escalabilidade, monitoramento etc.) de aplicações hapi.js. + +- [How to create a REST API with hapi](http://blog.webkid.io/how-to-create-a-rest-api-with-hapi/) + + Tutorial sobre como criar uma API RESTful com os plugins Dogwater (ORM Waterline) e Bedwetter. + +- [The joys of server / plugin separation](https://hapipal.com/best-practices/server-plugin-separation) + + Separar o plugin da sua aplicação do seu deploy deixa seu app hapi mais portável e mais fácil de testar. + +- [rate-limiter-flexible-plugin-example](https://github.com/animir/node-rate-limiter-flexible/wiki/Hapi-plugin) + + Exemplo de plugin de limitação de requisições e proteção contra DDoS com o pacote rate-limiter-flexible. + +- [Run hapi.js on Google Cloud Platform](https://cloud.google.com/nodejs/resources/frameworks/hapi) + + Tutorial e aplicação de exemplo clonável que mostra como criar uma aplicação hapi.js e fazer o deploy no Google App Engine. + +- [Catbox-Redis and Cookie Auth Example](https://github.com/johnmanko/catbox-redis-example) + + Mostra como usar @hapi/catbox-redis e @hapi/cookie. + +- [WittCode's Hapi Tutorial Series](https://youtube.com/playlist?list=PLkqiWyX-_LotaQ9AuppIAXl0xyV-P5Ms-) + + 10 vídeos que começam pela criação de um servidor Hapi, passam pela adição de rotas e plugins e chegam ao uso de Sequelize/MySQL e da autenticação por cookie. diff --git a/docs/tutorials/tr_TR/community.md b/docs/tutorials/tr_TR/community.md index b8eec564..b4336d59 100644 --- a/docs/tutorials/tr_TR/community.md +++ b/docs/tutorials/tr_TR/community.md @@ -1,11 +1,57 @@ --- -title: Community +title: Topluluk --- -# Community Tutorials +# Topluluk Eğitimleri -## No tutorials yet +- [Authentication and Authorization with hapi](https://medium.com/@poeticninja/authentication-and-authorization-with-hapi-5529b5ecc8ec) -There are no tutorials for this language. If you would like to add one, please submit a pull request. + hapi uygulamanı nasıl güvence altına alacağını anlatan bir makale. + +- [Building a Chat Application with hapi.js, Socket.io and Redis](https://github.com/dwyl/hapi-socketio-redis-chat-example) + + hapi.js, Socket.io ve Redis Pub/Sub ile geliştirilmiş, end-to-end testleri olan gerçek zamanlı bir sohbet uygulaması. + +- [Building a modern backend with TypeScript, Hapi, PostgreSQL and Prisma](https://www.prisma.io/blog/series/modern-backend-bdes2ps5kibb) + + TypeScript, Hapi, PostgreSQL ve Prisma ile backend geliştirmeyi anlatan bir canlı yayın ve makale serisi. + Seri; veri modelleme, CRUD, toplama işlemleri, REST, Joi ile doğrulama, test, kimlik doğrulama, yetkilendirme, başka API'lerle entegrasyon ve GitHub Actions ile Heroku üzerinden sürekli entegrasyon ve dağıtım konularını kapsıyor. + Seri, somut bir problemi çözerek modern bir backend için farklı kalıpları, problemleri ve mimarileri inceliyor ve gösteriyor: çevrimiçi kurslar için bir not sistemi. Bu iyi bir örnek, çünkü farklı ilişki türleri içeriyor ve gerçek bir kullanım senaryosunu temsil edecek kadar karmaşık. + +- [Handling plugin dependencies](https://hapipal.com/best-practices/handling-plugin-dependencies) + + hapi plugin sınırlarından yola çıkarak, plugin'ler arasındaki bağımlılıkları kontrol altına almak için somut bir yaklaşım sunuyor. + +- [hapi tutorial series (30+ tutorials)](https://futurestud.io/tutorials/hapi-get-your-server-up-and-running) + + hapi üzerine bu kapsamlı eğitim serisi route kurulumu, kimlik doğrulama, plugin'ler, istek ve yanıt işleme gibi pek çok konuyu ele alıyor. + +- [hapi.js tutorials covering various topics](https://content.nanobox.io/tag/hapi.js/) + + hapi.js uygulamalarının geliştirilmesi, dağıtımı, optimizasyonu ve üretim ortamında yönetimini (ölçekleme, izleme vb.) anlatan, sürekli büyüyen bir eğitim listesi. + +- [How to create a REST API with hapi](http://blog.webkid.io/how-to-create-a-rest-api-with-hapi/) + + Dogwater (Waterline ORM) ve Bedwetter plugin'leriyle RESTful API oluşturmayı anlatan bir eğitim. + +- [The joys of server / plugin separation](https://hapipal.com/best-practices/server-plugin-separation) + + Uygulama plugin'ini dağıtımından ayırmak, hapi uygulamanı daha taşınabilir ve test etmesi daha kolay hale getirir. + +- [rate-limiter-flexible-plugin-example](https://github.com/animir/node-rate-limiter-flexible/wiki/Hapi-plugin) + + rate-limiter-flexible paketiyle istek sınırlama ve DDoS koruması yapan bir plugin örneği. + +- [Run hapi.js on Google Cloud Platform](https://cloud.google.com/nodejs/resources/frameworks/hapi) + + Bir hapi.js uygulamasını oluşturup Google App Engine'e dağıtmayı gösteren bir eğitim ve klonlayabileceğin örnek uygulama. + +- [Catbox-Redis and Cookie Auth Example](https://github.com/johnmanko/catbox-redis-example) + + @hapi/catbox-redis ve @hapi/cookie kullanımını gösteriyor. + +- [WittCode's Hapi Tutorial Series](https://youtube.com/playlist?list=PLkqiWyX-_LotaQ9AuppIAXl0xyV-P5Ms-) + + Hapi sunucusu oluşturmakla başlayıp route ve plugin eklemeye, oradan Sequelize/MySQL ve cookie ile kimlik doğrulama kullanımına kadar ilerleyen 10 video. diff --git a/docs/tutorials/zh_CN/community.md b/docs/tutorials/zh_CN/community.md index b8eec564..c2ee02ef 100644 --- a/docs/tutorials/zh_CN/community.md +++ b/docs/tutorials/zh_CN/community.md @@ -1,11 +1,57 @@ --- -title: Community +title: 社区 --- -# Community Tutorials +# 社区教程 -## No tutorials yet +- [Authentication and Authorization with hapi](https://medium.com/@poeticninja/authentication-and-authorization-with-hapi-5529b5ecc8ec) -There are no tutorials for this language. If you would like to add one, please submit a pull request. + 介绍如何保护 hapi 应用的文章。 + +- [Building a Chat Application with hapi.js, Socket.io and Redis](https://github.com/dwyl/hapi-socketio-redis-chat-example) + + 使用 hapi.js、Socket.io 和 Redis Pub/Sub 构建的实时聊天应用,附带端到端测试。 + +- [Building a modern backend with TypeScript, Hapi, PostgreSQL and Prisma](https://www.prisma.io/blog/series/modern-backend-bdes2ps5kibb) + + 一系列直播和文章,讲解如何使用 TypeScript、Hapi、PostgreSQL 和 Prisma 构建后端。 + 内容涵盖数据建模、CRUD、聚合、REST、使用 Joi 校验、测试、认证、授权、与其他 API 集成,以及使用 GitHub Actions 和 Heroku 进行持续集成与部署。 + 该系列通过解决一个具体问题(在线课程的评分系统),探索并演示现代后端的各种模式、问题和架构。这是一个很好的例子,因为它包含多种关系类型,复杂程度也足以代表真实的使用场景。 + +- [Handling plugin dependencies](https://hapipal.com/best-practices/handling-plugin-dependencies) + + 基于 hapi 插件之间的边界,介绍一种管理插件间依赖的具体方法。 + +- [hapi tutorial series (30+ tutorials)](https://futurestud.io/tutorials/hapi-get-your-server-up-and-running) + + 这套深入的 hapi 教程系列涵盖路由配置、认证、插件、请求与响应处理等众多内容。 + +- [hapi.js tutorials covering various topics](https://content.nanobox.io/tag/hapi.js/) + + 一份持续增加的教程列表,涵盖 hapi.js 应用的开发、部署、优化和生产环境管理(扩展、监控等)。 + +- [How to create a REST API with hapi](http://blog.webkid.io/how-to-create-a-rest-api-with-hapi/) + + 介绍如何使用 Dogwater(Waterline ORM)和 Bedwetter 插件创建 RESTful API 的教程。 + +- [The joys of server / plugin separation](https://hapipal.com/best-practices/server-plugin-separation) + + 把应用插件与它的部署方式分离开,可以让 hapi 应用更易于移植,也更容易测试。 + +- [rate-limiter-flexible-plugin-example](https://github.com/animir/node-rate-limiter-flexible/wiki/Hapi-plugin) + + 使用 rate-limiter-flexible 包实现请求限流和 DDoS 防护的插件示例。 + +- [Run hapi.js on Google Cloud Platform](https://cloud.google.com/nodejs/resources/frameworks/hapi) + + 教程和可克隆的示例应用,演示如何创建 hapi.js 应用并将其部署到 Google App Engine。 + +- [Catbox-Redis and Cookie Auth Example](https://github.com/johnmanko/catbox-redis-example) + + 演示如何使用 @hapi/catbox-redis 和 @hapi/cookie。 + +- [WittCode's Hapi Tutorial Series](https://youtube.com/playlist?list=PLkqiWyX-_LotaQ9AuppIAXl0xyV-P5Ms-) + + 共 10 个视频,从创建 Hapi 服务器、添加路由和插件,一直讲到使用 Sequelize/MySQL 和 cookie 认证。 From f5ea79de22bb57d9ba7fb352c91558df18da7512 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:34 -0400 Subject: [PATCH 05/22] docs: rework getting started tutorial TypeScript examples (optional for readers), and translations updated. --- docs/tutorials/en_US/getting-started.md | 109 +++++++--- docs/tutorials/es_ES/getting-started.md | 162 +++++++++++++++ docs/tutorials/ko_KR/getting-started.md | 264 +++++++++-------------- docs/tutorials/pt_BR/getting-started.md | 190 ++++++++++------- docs/tutorials/tr_TR/getting-started.md | 266 +++++++++--------------- docs/tutorials/zh_CN/getting-started.md | 264 +++++++++-------------- 6 files changed, 640 insertions(+), 615 deletions(-) create mode 100644 docs/tutorials/es_ES/getting-started.md diff --git a/docs/tutorials/en_US/getting-started.md b/docs/tutorials/en_US/getting-started.md index c6077ba3..d4ef1da0 100644 --- a/docs/tutorials/en_US/getting-started.md +++ b/docs/tutorials/en_US/getting-started.md @@ -6,30 +6,30 @@ title: Getting Started # Getting Started -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -This tutorial will show how to set up a basic hapi server that displays "Hello World!" in your browser. +This tutorial sets up a basic hapi server that displays "Hello World!" in your browser. ## Installing hapi -Create a new directory `myproject`, and from there: - -- Run: `cd myproject`, this goes into the created project folder. - -- Run: `npm init` and follow the prompts. This will generate a package.json file for you. +```sh +mkdir myproject +cd myproject +npm init -y # creates package.json +npm pkg set type=module # makes Node.js treat your files as ES modules +npm install @hapi/hapi +``` -- Run: `npm install @hapi/hapi`, this will install the latest version of hapi as a dependency in your package.json. +The examples in these tutorials are written in TypeScript, but TypeScript is optional. hapi works the same from plain JavaScript: remove the type annotations, `import type` lines, `interface` and `declare module` blocks, and type arguments such as `server.route<...>` from an example, and save it as a `.js` file. See [Using TypeScript](#typescript) to set up type checking. ## Creating a Server A very basic hapi server looks like the following: -```js -'use strict'; - -const Hapi = require('@hapi/hapi'); +```ts +import Hapi from '@hapi/hapi'; const init = async () => { const server = Hapi.server({ @@ -49,20 +49,24 @@ process.on('unhandledRejection', (err) => { init(); ``` -First, you require hapi. Then you initialize a new `Hapi.server()` with connection details containing a port number to listen on and the host information. After that you start the server and log that it's running. +`Hapi.server()` creates the server, and `server.start()` starts listening. The `unhandledRejection` handler stops the process if `init()` fails, for example when the port is already in use. -When creating a server, you can provide a hostname, IP address, a Unix socket file, or Windows named pipe to bind the server to. For more details, see the API reference. +`host` decides which network interface the server listens on: -The `host` property set to `localhost` is likely the safest choice. In a docker container, however, the localhost may not be accessible outside of the container and using `host: '0.0.0.0'` may be needed. +| `host` | Reachable from | +| ------------- | ----------------------------------------------------- | +| `'localhost'` | this machine only; the safest default for development | +| `'0.0.0.0'` | every interface; needed inside a Docker container | +| omitted | every interface (hapi's default) | -## Adding Routes +You can also listen on a Unix socket or a Windows named pipe; see [`server.options.port`](/api#server-options-port). -After you get the server up and running, its time to add a route that will display "Hello World!" in your browser. +## Adding Routes -```js -'use strict'; +Add a route that returns "Hello World!": -const Hapi = require('@hapi/hapi'); +```ts +import Hapi from '@hapi/hapi'; const init = async () => { const server = Hapi.server({ @@ -90,14 +94,69 @@ process.on('unhandledRejection', (err) => { init(); ``` -Save the above as `index.js` and start the server with the command `node index.js`. Now you'll find that if you visit `http://localhost:3000` in your browser, you'll see the text 'Hello, World!'. +Save the above as `index.ts` and start the server: -The `method` property can be any valid HTTP method, array of HTTP methods, or an asterisk to allow any method. +```sh +node index.ts +``` + +Node.js 22.18 and newer run `.ts` files directly, with no build step. This example has no type annotations, so saving it as `index.js` and running `node index.js` works too. Visit `http://localhost:3000` and you'll see `Hello World!`. + +A route has three required properties: -The `path` property defines the path including parameters. It can contain optional parameters, numbered parameters, and even wildcards. For more details, see the routing tutorial. +| Property | What it does | +| --------- | -------------------------------------------------------------------------------------------------------------------- | +| `method` | The HTTP method: `'GET'`, `'POST'`, an array such as `['GET', 'POST']`, or `'*'` for any method. | +| `path` | The URL path. It can contain parameters such as `/users/{id}`; see the [routing tutorial](/tutorials/en_US/routing). | +| `handler` | The function that produces the response. It returns a value, returns a promise, or throws an error. | -The `handler` function performs the main business logic of the route and sets the response. The `handler` must return a value, a promise, or throw an error. +## Using TypeScript + +This section is optional. hapi ships its own TypeScript types, so there is no `@types/hapi__hapi` package to install. + +Node.js runs the examples by stripping the types, which only removes them; it does not check them. To check them, install the TypeScript compiler and the Node.js types: + +```sh +npm install --save-dev typescript @types/node +``` + +Then add a `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + } +} +``` + +Run `npx tsc` whenever you want to check your types. `erasableSyntaxOnly` and `verbatimModuleSyntax` reject the TypeScript features that Node.js cannot strip, such as `enum` and type imports written without the `type` keyword. + +In the route above, `request` and `h` need no type annotations: TypeScript infers them as hapi's `Request` and `ResponseToolkit` from the `server.route()` signature. Annotate them only when you write a handler outside of a route config, using the types exported by `@hapi/hapi`: + +```ts +import type { Request, ResponseToolkit } from '@hapi/hapi'; + +const hello = (request: Request, h: ResponseToolkit) => { + return 'Hello World!'; +}; +``` ## Everything Else -hapi has many, many other capabilities and only a select few are documented in tutorials here. This tutorial was intentionally minimal, we highly recommend you to check out the plugins tutorial. It will give some more knowledge to better organize your hapi project. We also have other tutorials of interest, please use the list to your left to check them out. Everything else is documented in the [API reference](/api) and, as always, feel free to ask questions on [github](https://github.com/hapijs/hapi/issues) or just visit us on [slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg). +This tutorial was intentionally minimal. Good next steps: + +- [Plugins](/tutorials/en_US/plugins): split your application into reusable pieces. +- [Routing](/tutorials/en_US/routing): path parameters, methods, and route options. +- [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks): run code at each step of a request, and at server startup and shutdown. +- [Decorators](/tutorials/en_US/decorators): add your own methods to the server, the request, and the response toolkit. +- [Response Marshalling](/tutorials/en_US/response-marshalling): how a handler's return value becomes an HTTP response. +- [Testing](/tutorials/en_US/testing): test routes without opening a port. + +Everything else is documented in the [API reference](/api). Ask questions on [GitHub](https://github.com/hapijs/hapi/issues) or in the [hapi Slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg). diff --git a/docs/tutorials/es_ES/getting-started.md b/docs/tutorials/es_ES/getting-started.md new file mode 100644 index 00000000..78de7c7b --- /dev/null +++ b/docs/tutorials/es_ES/getting-started.md @@ -0,0 +1,162 @@ +--- +title: Primeros pasos +--- + + + +# Primeros pasos + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Este tutorial monta un servidor hapi básico que muestra "Hello World!" en el navegador. + +## Instalar hapi + +```sh +mkdir myproject +cd myproject +npm init -y # creates package.json +npm pkg set type=module # makes Node.js treat your files as ES modules +npm install @hapi/hapi +``` + +Los ejemplos de estos tutoriales están escritos en TypeScript, pero TypeScript es opcional. hapi funciona igual desde JavaScript puro: quita de un ejemplo las anotaciones de tipo, las líneas `import type`, los bloques `interface` y `declare module` y los argumentos de tipo como `server.route<...>`, y guárdalo como fichero `.js`. Consulta [Usar TypeScript](#typescript) para configurar la comprobación de tipos. + +## Crear un servidor + +Un servidor hapi muy básico tiene este aspecto: + +```ts +import Hapi from '@hapi/hapi'; + +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + await server.start(); + console.log('Server running on %s', server.info.uri); +}; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); + +init(); +``` + +`Hapi.server()` crea el servidor y `server.start()` lo pone a escuchar. El listener de `unhandledRejection` detiene el proceso si `init()` falla, por ejemplo cuando el puerto ya está en uso. + +`host` decide en qué interfaz de red escucha el servidor: + +| `host` | Accesible desde | +| ------------- | -------------------------------------------------------------- | +| `'localhost'` | solo esta máquina; la opción más segura para desarrollo | +| `'0.0.0.0'` | todas las interfaces; necesario dentro de un contenedor Docker | +| sin indicar | todas las interfaces (el valor por defecto de hapi) | + +También puedes escuchar en un socket Unix o en una tubería con nombre (named pipe) de Windows; consulta [`server.options.port`](/api#server-options-port). + +## Añadir rutas + +Añade una ruta que devuelva "Hello World!": + +```ts +import Hapi from '@hapi/hapi'; + +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return 'Hello World!'; + }, + }); + + await server.start(); + console.log('Server running on %s', server.info.uri); +}; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); + +init(); +``` + +Guarda lo anterior como `index.ts` y arranca el servidor: + +```sh +node index.ts +``` + +Node.js 22.18 y posteriores ejecutan ficheros `.ts` directamente, sin paso de compilación. Este ejemplo no tiene anotaciones de tipo, así que también funciona guardarlo como `index.js` y ejecutar `node index.js`. Abre `http://localhost:3000` y verás `Hello World!`. + +Una ruta tiene tres propiedades obligatorias: + +| Propiedad | Qué hace | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `method` | El método HTTP: `'GET'`, `'POST'`, un array como `['GET', 'POST']` o `'*'` para cualquier método. | +| `path` | La ruta de la URL. Puede contener parámetros como `/users/{id}`; consulta el [tutorial de enrutamiento](/tutorials/es_ES/routing). | +| `handler` | La función que genera la respuesta. Devuelve un valor, devuelve una promesa o lanza un error. | + +## Usar TypeScript + +Esta sección es opcional. hapi incluye sus propios tipos de TypeScript, así que no hay ningún paquete `@types/hapi__hapi` que instalar. + +Node.js ejecuta los ejemplos eliminando los tipos, cosa que solo los quita; no los comprueba. Para comprobarlos, instala el compilador de TypeScript y los tipos de Node.js: + +```sh +npm install --save-dev typescript @types/node +``` + +Después añade un `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + } +} +``` + +Ejecuta `npx tsc` cuando quieras comprobar los tipos. `erasableSyntaxOnly` y `verbatimModuleSyntax` rechazan las características de TypeScript que Node.js no puede eliminar, como `enum` y las importaciones de tipos escritas sin la palabra clave `type`. + +En la ruta anterior, `request` y `h` no necesitan anotaciones de tipo: TypeScript los infiere como `Request` y `ResponseToolkit` de hapi a partir de la firma de `server.route()`. Anótalos solo cuando escribas un handler fuera de la configuración de una ruta, usando los tipos que exporta `@hapi/hapi`: + +```ts +import type { Request, ResponseToolkit } from '@hapi/hapi'; + +const hello = (request: Request, h: ResponseToolkit) => { + return 'Hello World!'; +}; +``` + +## Todo lo demás + +Este tutorial es mínimo a propósito. Buenos siguientes pasos: + +- [Plugins](/tutorials/es_ES/plugins): divide tu aplicación en piezas reutilizables. +- [Enrutamiento](/tutorials/es_ES/routing): parámetros de ruta, métodos y opciones de ruta. +- [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks): ejecuta código en cada paso de una petición y al arrancar y detener el servidor. +- [Decoradores](/tutorials/es_ES/decorators): añade tus propios métodos al servidor, a la petición y al response toolkit. +- [Serialización de respuestas](/tutorials/es_ES/response-marshalling): cómo el valor que devuelve un handler se convierte en una respuesta HTTP. +- [Testing](/tutorials/es_ES/testing): prueba rutas sin abrir un puerto. + +Todo lo demás está documentado en la [referencia de la API](/api). Haz tus preguntas en [GitHub](https://github.com/hapijs/hapi/issues) o en el [Slack de hapi](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg). diff --git a/docs/tutorials/ko_KR/getting-started.md b/docs/tutorials/ko_KR/getting-started.md index bdec48ae..049a9a7a 100644 --- a/docs/tutorials/ko_KR/getting-started.md +++ b/docs/tutorials/ko_KR/getting-started.md @@ -1,87 +1,44 @@ --- -title: Getting Started +title: 시작하기 --- -## Getting started +# 시작하기 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -### hapi 설치 +## 개요 -`myproject`라는 디렉터리를 만들어 주세요. 그리고 거기서: +이 튜토리얼에서는 브라우저에 "Hello World!"를 표시하는 기본 hapi 서버를 만듭니다. -- 실행: `cd myproject` 생성한 프로젝트 폴더로 이동합니다. -- 실행: `npm init` 입력 후, 지시 메시지를 따르세요. package.json 파일을 생성할 것입니다. -- 실행: `npm install --save @hapi/hapi@17.x.x` 이 명령은 hapi를 설치하고 프로젝트 의존성을 package.json에 기록합니다. +## hapi 설치 -이게 전부입니다! hapi를 사용하는 서버를 만드는 데 필요한 모든 것을 갖췄습니다. - -### 서버 만들기 - -가장 간단한 서버는 다음과 같습니다.: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); - -const init = async () => { - await server.start(); - console.log(`Server running at: ${server.info.uri}`); -}; - -process.on('unhandledRejection', (err) => { - console.log(err); - process.exit(1); -}); - -init(); +```sh +mkdir myproject +cd myproject +npm init -y # creates package.json +npm pkg set type=module # makes Node.js treat your files as ES modules +npm install @hapi/hapi ``` -먼저 hapi가 필요합니다. 그런 다음 호스트(host)와 사용할 포트 번호를 포함한 설정 객체로 새로운 hapi 서버 객체를 만듭니다. 그 후에 서버를 시작하고 실행 중이라는 로그를 남깁니다. - -서버를 생성할 때 호스트 이름, IP 주소 또는 Unix 소켓 파일, 서버에서 사용할 Windows named pipe도 제공할 수 있습니다. 자세한 정보는 [the API reference](/api/#-server-options)를 봐주세요. - -### 경로(routes) 추가하기 +이 튜토리얼의 예제는 TypeScript로 작성되어 있지만, TypeScript는 선택 사항입니다. hapi는 일반 JavaScript에서도 똑같이 동작합니다. 예제에서 타입 표기, `import type` 줄, `interface`와 `declare module` 블록, `server.route<...>` 같은 타입 인자를 지우고 `.js` 파일로 저장하면 됩니다. 타입 검사를 설정하려면 [TypeScript 사용하기](#typescript)를 참고하세요. -하나 또는 두 개의 경로를 추가해야 실제로 동작하는 서버를 가지고 있습니다. 어떻게 생겼는지 보겠습니다.: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); +## 서버 만들기 -server.route({ - method: 'GET', - path: '/', - handler: (request, h) => { - return 'Hello, world!'; - }, -}); +가장 기본적인 hapi 서버는 다음과 같습니다. -server.route({ - method: 'GET', - path: '/{name}', - handler: (request, h) => { - return 'Hello, ' + encodeURIComponent(request.params.name) + '!'; - }, -}); +```ts +import Hapi from '@hapi/hapi'; const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + await server.start(); - console.log(`Server running at: ${server.info.uri}`); + console.log('Server running on %s', server.info.uri); }; process.on('unhandledRejection', (err) => { @@ -92,149 +49,114 @@ process.on('unhandledRejection', (err) => { init(); ``` -`server.js`로 저장하고 `node server.js` 명령으로 서버를 시작하세요. 이제 브라우저에서 [http://localhost:3000](http://localhost:3000)을 방문하면, `Hello, world!`가 표시되고 [http://localhost:3000/stimpy](http://localhost:3000/stimpy)을 방문하면 `Hello, stimpy!`가 표시됩니다. +`Hapi.server()`가 서버를 만들고, `server.start()`가 요청 수신을 시작합니다. `unhandledRejection` 핸들러는 `init()`이 실패하면 프로세스를 종료합니다. 예를 들어 포트가 이미 사용 중일 때 그렇습니다. -콘텐츠 주입 공격(content injection attacks)을 막기 위해 이름 인자를 URI 인코딩 합니다. 출력 인코딩 없이 사용자가 제공한 데이터를 보여주는 것은 절대로 좋은 생각이 아닙니다! +`host`는 서버가 어느 네트워크 인터페이스에서 수신할지 정합니다. -`method` 인자는 유효한 HTTP 메서드(method), HTTP 메서드의 배열 또는 모든 메서드를 허용하는 별표(\*)가 될 수 있습니다. `path` 인자는 인자를 포함하는 경로를 정의합니다. 선택적(optional) 인자, 번호 인자, 별표(\*)를 포함할 수 있습니다. 자세한 내용은 [the routing tutorial](/tutorials/routing)을 봐주세요. +| `host` | 접근 가능한 곳 | +| ------------- | ------------------------------------------------ | +| `'localhost'` | 이 컴퓨터에서만. 개발용으로 가장 안전한 기본값 | +| `'0.0.0.0'` | 모든 인터페이스. Docker 컨테이너 안에서는 필요함 | +| 생략 | 모든 인터페이스 (hapi의 기본값) | -### 정적 페이지와 콘텐츠 만들기 +Unix 소켓이나 Windows named pipe에서 수신할 수도 있습니다. [`server.options.port`](/api#server-options-port)를 참고하세요. -Hello World 응용프로그램으로 간단한 hapi 앱을 실행할 수 있음을 확인했습니다. 다음은 정적 페이지를 제공하는 **inert**라는 플러그인을 사용할 것입니다. 시작하기 전에 **CTRL + C**로 서버를 중지하세요. +## 라우트 추가하기 -[inert](https://github.com/hapijs/inert)를 설치하려면 명령행에서 이 명령을 실행하세요.: `npm install --save @hapi/inert` [inert](https://github.com/hapijs/inert)를 내려받고 설치된 패키지를 문서로 만든 `package.json`에 추가합니다. +"Hello World!"를 반환하는 라우트를 추가합니다. -`server.js` 파일에 `init` 함수를 변경합니다.: +```ts +import Hapi from '@hapi/hapi'; -```javascript const init = async () => { - await server.register(require('@hapi/inert')); + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); server.route({ method: 'GET', - path: '/hello', + path: '/', handler: (request, h) => { - return h.file('./public/hello.html'); + return 'Hello World!'; }, }); await server.start(); - console.log(`Server running at: ${server.info.uri}`); + console.log('Server running on %s', server.info.uri); }; -``` -위에서 `server.register()` 명령은 [inert](https://github.com/hapijs/inert) 플러그인을 hapi 응용프로그램에 추가합니다. - -`server.route`() 명령은 서버가 `/hello`에 대한 GET 요청을 받아들이고 `hello.html` 파일의 내용으로 응답하는 `/hello` 경로를 등록합니다. `inert` 플러그인을 등록한 후에 경로 등록을 합니다. 플러그인을 등록한 후 플러그인에 의존하는 코드를 실행하면 코드가 실행될 때 플러그인이 있다는 것을 확신할 수 있습니다. +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); -`node server.js`로 서버를 시작하고 브라우저에서 [`http://localhost:3000/hello`](http://localhost:3000/hello)로 이동하세요. 오 이런! `hello.html` 파일을 만들지 않았기 때문에 에러를 받았습니다. 이 에러를 제거하기 위해 빠진 파일을 만들어야 합니다. +init(); +``` -`hello.html` 파일을 저장할 `public`이라는 폴더를 디렉터리의 루트에 만들어주세요. `hello.html` 안에 다음 HTML을 넣어주세요.: +위 코드를 `index.ts`로 저장하고 서버를 시작합니다. -```html - - - - - Hapi.js is awesome! - - -

Hello World.

- - +```sh +node index.ts ``` -간단하고 유효한 HTML5 문서입니다. +Node.js 22.18 이상은 빌드 단계 없이 `.ts` 파일을 바로 실행합니다. 이 예제에는 타입 표기가 없으므로 `index.js`로 저장하고 `node index.js`로 실행해도 됩니다. `http://localhost:3000`에 접속하면 `Hello World!`가 보입니다. -이제 브라우저에서 페이지를 다시 불러옵니다. "Hello World."라는 머리말이 보일 것입니다. +라우트에는 필수 속성이 세 가지 있습니다. -[Inert](https://github.com/hapijs/inert)는 요청이 있을 때 하드 드라이브에 저장된 모든 콘텐츠를 제공합니다. 이는 실시간 다시 읽기 동작을 초래합니다. 취향에 맞춰 `/hello`에 대한 페이지를 바꿔보세요. +| 속성 | 역할 | +| --------- | -------------------------------------------------------------------------------------------------------------------- | +| `method` | HTTP 메서드. `'GET'`, `'POST'`, `['GET', 'POST']` 같은 배열, 또는 모든 메서드를 뜻하는 `'*'`. | +| `path` | URL 경로. `/users/{id}`처럼 파라미터를 포함할 수 있습니다. [라우팅 튜토리얼](/tutorials/ko_KR/routing)을 참고하세요. | +| `handler` | 응답을 만드는 함수. 값을 반환하거나, promise를 반환하거나, 에러를 던집니다. | -정적 콘첸츠 제공 방법에 대한 자세한 내용은 [Serving Static Content](/tutorials/servingfiles)에 자세히 설명되어 있습니다. 이 기술은 웹 응용프로그램에서 이미지, 스타일 시트 그리고 정적 페이지를 제공하는 데 일반적으로 사용됩니다. +## TypeScript 사용하기 -### 플러그인 사용하기 +이 섹션은 선택 사항입니다. hapi는 자체 TypeScript 타입을 함께 제공하므로 별도로 설치할 `@types/hapi__hapi` 패키지가 없습니다. -웹 응용프로그램을 만들때 일반적인 요구 사항은 접근 로그입니다. 응용프로그램에 기본 로깅을 추가하기 위해 [hapi pino](https://github.com/pinojs/hapi-pino) 플러그인을 로드합니다. +Node.js는 타입을 제거(strip)하는 방식으로 예제를 실행합니다. 타입을 지우기만 할 뿐 검사하지는 않습니다. 타입을 검사하려면 TypeScript 컴파일러와 Node.js 타입을 설치하세요. -시작하기 위해 npm에서 모듈들을 설치합니다.: - -```bash -npm install hapi-pino +```sh +npm install --save-dev typescript @types/node ``` -그리고 `server.js`를 수정합니다.: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); - -server.route({ - method: 'GET', - path: '/', - handler: (request, h) => { - return 'Hello, world!'; - }, -}); +그런 다음 `tsconfig.json`을 추가합니다. + +```json +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + } +} +``` -server.route({ - method: 'GET', - path: '/{name}', - handler: (request, h) => { - // request.log(['a', 'name'], "Request name"); - // or - request.logger.info('In handler %s', request.path); +타입을 검사하고 싶을 때마다 `npx tsc`를 실행하세요. `erasableSyntaxOnly`와 `verbatimModuleSyntax`는 Node.js가 제거할 수 없는 TypeScript 기능을 거부합니다. `enum`이나 `type` 키워드 없이 작성한 타입 import가 그런 예입니다. - return `Hello, ${encodeURIComponent(request.params.name)}!`; - }, -}); +위 라우트에서 `request`와 `h`에는 타입 표기가 필요 없습니다. TypeScript가 `server.route()` 시그니처로부터 이 둘을 hapi의 `Request`와 `ResponseToolkit`으로 추론합니다. 라우트 설정 바깥에서 핸들러를 작성할 때만 `@hapi/hapi`가 내보내는 타입으로 표기하세요. -const init = async () => { - await server.register({ - plugin: require('hapi-pino'), - options: { - prettyPrint: false, - logEvents: ['response', 'onPostStart'], - }, - }); +```ts +import type { Request, ResponseToolkit } from '@hapi/hapi'; - await server.start(); - console.log(`Server running at: ${server.info.uri}`); +const hello = (request: Request, h: ResponseToolkit) => { + return 'Hello World!'; }; - -process.on('unhandledRejection', (err) => { - console.log(err); - process.exit(1); -}); - -init(); -``` - -이제 서버가 시작할 때, 다음과 같은 것을 볼 것입니다.: - -```sh -[2017-12-03T17:15:45.114Z] INFO (10412 on box): server started - created: 1512321345014 - started: 1512321345092 - host: "localhost" - port: 3000 - protocol: "http" - id: "box:10412:jar12y2e" - uri: "http://localhost:3000" - address: "127.0.0.1" ``` -그리고 브라우저에서 [http://localhost:3000/](http://localhost:3000/)을 방문하면 터미널에 요청에 대한 로그가 출력중인 것을 볼 수 있습니다. - -등록 함수에 전달되는 `options`로 로거의 행동이 설정됩니다. +## 그 밖의 모든 것 -대단합니다! 플러그인으로 할 수 있는 짧은 예제 중 하나입니다. 자세한 내용은 [plugins tutorial](/tutorials/plugins)을 확인해주세요. +이 튜토리얼은 일부러 최소한으로 구성했습니다. 다음으로 읽어 볼 만한 내용은 다음과 같습니다. -### 그 밖의 모든 것 +- [플러그인](/tutorials/ko_KR/plugins): 애플리케이션을 재사용 가능한 조각으로 나눕니다. +- [라우팅](/tutorials/ko_KR/routing): 경로 파라미터, 메서드, 라우트 옵션. +- [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks): 요청의 각 단계, 그리고 서버 시작과 종료 시점에 코드를 실행합니다. +- [데코레이터](/tutorials/ko_KR/decorators): 서버, 요청, 응답 툴킷에 직접 만든 메서드를 추가합니다. +- [응답 마샬링](/tutorials/ko_KR/response-marshalling): 핸들러의 반환값이 HTTP 응답이 되는 과정. +- [테스트](/tutorials/ko_KR/testing): 포트를 열지 않고 라우트를 테스트합니다. -hapi는 많은 다른 기능들을 가지고 있으며 여기에서는 선택된 몇가지만 기술했습니다. 많은 기능들을 확인하려면 오른쪽 목록을 사용해주세요. 다른 모든 것은 [API reference](/api)에 문서화 되어 있으며 언제나처럼 [github](https://github.com/hapijs/discuss/issues)과 [gitter](https://gitter.im/hapijs/hapi)에 자유롭게 질문을 하거나 [slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg)을 방문해주세요. +나머지는 모두 [API 레퍼런스](/api)에 문서화되어 있습니다. 질문은 [GitHub](https://github.com/hapijs/hapi/issues)이나 [hapi Slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg)에 남겨 주세요. diff --git a/docs/tutorials/pt_BR/getting-started.md b/docs/tutorials/pt_BR/getting-started.md index 6a3aa290..3a3b0bc0 100644 --- a/docs/tutorials/pt_BR/getting-started.md +++ b/docs/tutorials/pt_BR/getting-started.md @@ -1,122 +1,162 @@ --- -title: Getting Started +title: Primeiros passos --- -## Instalando o hapi +# Primeiros passos -_Este tutorial é compatível com hapi v16_ +_Este tutorial é compatível com hapi v21 e versões mais recentes_ -Crie um novo diretório `meuprojeto`, entre nele e: +## Visão geral -- Execute: `npm init` e siga as instruções, esse comando irá gerar o arquivo package.json para você. -- Execute: `npm install --save hapi` esse comando instalará o hapi e irá salvá-lo no package.json como uma dependência do seu projeto. +Este tutorial monta um servidor hapi básico que mostra "Hello World!" no seu navegador. -É isso! Agora você tem tudo que precisa para criar um servidor usando hapi. +## Instalando o hapi -## Criando um servidor +```sh +mkdir myproject +cd myproject +npm init -y # creates package.json +npm pkg set type=module # makes Node.js treat your files as ES modules +npm install @hapi/hapi +``` -O servidor mais simples é semelhante ao seguinte: +Os exemplos destes tutoriais são escritos em TypeScript, mas o TypeScript é opcional. O hapi funciona da mesma forma em JavaScript puro: remova do exemplo as anotações de tipo, as linhas `import type`, os blocos `interface` e `declare module` e os argumentos de tipo como `server.route<...>`, e salve o arquivo como `.js`. Veja [Usando TypeScript](#typescript) para configurar a checagem de tipos. -```javascript -'use strict'; +## Criando um servidor -const Hapi = require('hapi'); +Um servidor hapi bem simples tem esta cara: -const server = new Hapi.Server(); -server.connection({ port: 3000 }); +```ts +import Hapi from '@hapi/hapi'; -server.start((err) => { - if (err) { - throw err; - } - console.log(`Server running at: ${server.info.uri}`); +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + await server.start(); + console.log('Server running on %s', server.info.uri); +}; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); }); + +init(); ``` -Primeiro, precisamos do hapi. Então, criamos um novo objeto do servidor hapi. Em seguida, adicionamos uma conexão ao servidor, passando o número da porta a ser escutada. E então, o servidor é iniciado e o log indicará que ele está sendo executado. +`Hapi.server()` cria o servidor e `server.start()` começa a escutar conexões. O handler de `unhandledRejection` encerra o processo se `init()` falhar, por exemplo quando a porta já está em uso. -Ao adicionar a conexão ao servidor, podemos informar um hostname, o endereço IP, ou até mesmo um arquivo de socket Unix ou uma pipe nomeada do Windows para vincular ao servidor. Para mais detalhes, veja [a API de referência](/api/#serverconnectionoptions). +`host` define em qual interface de rede o servidor escuta: -## Adicionando rotas +| `host` | Acessível a partir de | +| ------------- | ------------------------------------------------------------- | +| `'localhost'` | somente esta máquina; o padrão mais seguro em desenvolvimento | +| `'0.0.0.0'` | todas as interfaces; necessário dentro de um container Docker | +| omitido | todas as interfaces (o padrão do hapi) | -Agora que temos um servidor, vamos acrescentar uma ou duas rotas para que ele realmente faça alguma coisa. Vamos ver como ficaria: +Também é possível escutar em um socket Unix ou em um named pipe do Windows; veja [`server.options.port`](/api#server-options-port). -```javascript -'use strict'; +## Adicionando rotas -const Hapi = require('hapi'); +Adicione uma rota que retorna "Hello World!": -const server = new Hapi.Server(); -server.connection({ port: 3000 }); +```ts +import Hapi from '@hapi/hapi'; -server.route({ - method: 'GET', - path: '/', - handler: function (request, reply) { - reply('Olá, mundo!'); - }, -}); +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); -server.route({ - method: 'GET', - path: '/{name}', - handler: function (request, reply) { - reply('Olá, ' + encodeURIComponent(request.params.name) + '!'); - }, -}); + server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return 'Hello World!'; + }, + }); -server.start((err) => { - if (err) { - throw err; - } - console.log(`Servidor rodando em: ${server.info.uri}`); + await server.start(); + console.log('Server running on %s', server.info.uri); +}; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); }); + +init(); ``` -Salve o código acima como `server.js` e inicie o servidor com o comando `node server.js`. Agora, se você acessar o endereço http://localhost:3000 no seu navegador, verá o texto `Olá, mundo!`, e se acessar http://localhost:3000/stimpy verá `Olá, stimpy!`. +Salve o código acima como `index.ts` e inicie o servidor: -Perceba que nós codificamos o parâmetro name na URI, isso é para evitar ataques de injeção de conteúdo. Lembre-se, não é uma boa prática exibir os dados para o usuário sem antes codificá-lo! +```sh +node index.ts +``` -O parâmetro `method` pode ser qualquer método HTTP válido, uma coleção de métodos, ou ainda um asterisco (\*), para permitir qualquer método. Já o `path` define o caminho com a inclusão de parâmetros. Ele pode conter parâmetros opcionais, numerados e até mesmo coringas. Para mais detalhes, veja [o tutorial sobre rotas](/tutorials/routing). +O Node.js 22.18 e versões mais recentes executam arquivos `.ts` diretamente, sem etapa de build. Este exemplo não tem anotações de tipo, então salvá-lo como `index.js` e rodar `node index.js` também funciona. Acesse `http://localhost:3000` e você verá `Hello World!`. -## Criando páginas estáticas e conteúdo +Uma rota tem três propriedades obrigatórias: -Provamos que podemos iniciar uma simples aplicação Hapi com o nosso Alô Mundo. A seguir, vamos usar um plugin chamado **inert** para servir uma página estática. Antes de começar, pare o servidor com **CTRL + C**. +| Propriedade | O que faz | +| ----------- | -------------------------------------------------------------------------------------------------------------------- | +| `method` | O método HTTP: `'GET'`, `'POST'`, um array como `['GET', 'POST']`, ou `'*'` para qualquer método. | +| `path` | O caminho da URL. Pode conter parâmetros como `/users/{id}`; veja o tutorial [Roteamento](/tutorials/pt_BR/routing). | +| `handler` | A função que produz a resposta. Ela retorna um valor, retorna uma promise ou lança um erro. | -Para instalar o inert, execute o comando no terminal: `npm install --save inert`. Esse comando baixará o inert e o adicionará ao `package.json`, que é o arquivo que indica quais pacotes estão instalados. +## Usando TypeScript -Adicione o código abaixo ao seu arquivo `server.js`: +Esta seção é opcional. O hapi já vem com seus próprios tipos TypeScript, então não existe um pacote `@types/hapi__hapi` para instalar. -```javascript -server.register(require('inert'), (err) => { - if (err) { - throw err; - } +O Node.js executa os exemplos removendo os tipos, o que apenas os apaga; ele não os verifica. Para verificá-los, instale o compilador TypeScript e os tipos do Node.js: - server.route({ - method: 'GET', - path: '/hello', - handler: function (request, reply) { - reply.file('./public/hello.html'); - }, - }); -}); +```sh +npm install --save-dev typescript @types/node +``` + +Depois adicione um `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + } +} ``` -O comando `server.register()` adiciona o plugin inert na sua aplicação Hapi. Se algo de errado acontecer, queremos saber. Então, passamos uma função anônima que, se invocada, receberá `err` e em seguida lançará esse erro. Essa função de retorno é necessária quando registramos os plugins. +Rode `npx tsc` sempre que quiser checar os tipos. `erasableSyntaxOnly` e `verbatimModuleSyntax` rejeitam os recursos do TypeScript que o Node.js não consegue remover, como `enum` e importações de tipo escritas sem a palavra-chave `type`. -O comando `server.route()` registra a rota `/hello`, que informa ao seu servidor para aceitar requisições GET para `/hello` e responder com o conteúdo do arquivo `hello.html`. Colocamos a função de retorno do roteamento dentro do registro do plugin inert, pois precisamos garantir que inert seja registrado _antes_ de usá-lo para processar a página estática. Em geral, é recomandável executar o código que depende do plugin dentro da função de retorno responsável por registrá-lo, assim teremos a garantia da existência do plugin quando nosso código for executado. +Na rota acima, `request` e `h` não precisam de anotação de tipo: o TypeScript os infere como `Request` e `ResponseToolkit` do hapi a partir da assinatura de `server.route()`. Anote-os só quando escrever um handler fora da configuração de uma rota, usando os tipos exportados por `@hapi/hapi`: -Inicie o seu servidor com `npm start` e acesse a url `http://localhost:3000/hello` no seu navegador. Ah não! Recebemos o erro 404, pois nunca criamos o arquivo `hello.html`. Você precisa criar o arquivo para que o erro não volte a acontecer. +```ts +import type { Request, ResponseToolkit } from '@hapi/hapi'; -Crie um diretório chamado `public` na raiz do seu projeto. Depois, crie um arquivo chamado `hello.html`, dentro desse diretório, com o HTML a seguir: `

Olá mundo.

`. Em seguida, recarregue a página no seu navegador. Você deverá ler a expressão "Olá mundo.". +const hello = (request: Request, h: ResponseToolkit) => { + return 'Hello World!'; +}; +``` -Inert exibirá qualquer conteúdo salvo no seu disco rígido quando a requisição for feita, isto é o que leva a carregar em tempo real. Você pode personalizar a página `/hello` ao seu gosto. +## Todo o resto -Para mais detalhes sobre como o conteúdo estático funciona acesse [Servindo Conteúdo Estático](/tutorials/servingfiles). Essa técnica é comumente usada para imagens, folhas de estilos e páginas estáticas em aplicações web. +Este tutorial foi mínimo de propósito. Bons próximos passos: -## Outras informações +- [Plugins](/tutorials/pt_BR/plugins): divida sua aplicação em partes reutilizáveis. +- [Roteamento](/tutorials/pt_BR/routing): parâmetros de caminho, métodos e opções de rota. +- [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks): execute código em cada etapa de uma requisição e na inicialização e no desligamento do servidor. +- [Decorators](/tutorials/pt_BR/decorators): adicione seus próprios métodos ao servidor, à requisição e ao response toolkit. +- [Marshalling de respostas](/tutorials/pt_BR/response-marshalling): como o valor retornado por um handler vira uma resposta HTTP. +- [Testes](/tutorials/pt_BR/testing): teste rotas sem abrir uma porta. -Hapi oferece muitas mais capacidades, no entanto apenas algumas estão documentadas em tutoriais como este. Por favor, use a lista da direita para vê-los. Ademais, o restante da documentação pode ser vista na [API de referência](/api) e, como sempre, sinta-se à vontade para fazer perguntas no [github](https://github.com/hapijs/discuss/issues) ou visite-nos no [slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg). +Todo o resto está documentado na [referência da API](/api). Tire dúvidas no [GitHub](https://github.com/hapijs/hapi/issues) ou no [Slack do hapi](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg). diff --git a/docs/tutorials/tr_TR/getting-started.md b/docs/tutorials/tr_TR/getting-started.md index c77d9bbb..ceac7635 100644 --- a/docs/tutorials/tr_TR/getting-started.md +++ b/docs/tutorials/tr_TR/getting-started.md @@ -1,87 +1,44 @@ --- -title: Getting Started +title: Başlarken --- -## Başlarken +# Başlarken -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -### hapi kurulumu +## Genel Bakış -`projem` adında yeni bir dizin oluştur ve sonra: +Bu eğitimde, tarayıcında "Hello World!" gösteren basit bir hapi sunucusu kuracağız. -- `cd myproject` komutunu çalıştırarak proje dizinine gir. -- `npm init` komutunu çalıştırarak yönergeleri izle, bu komut senin için package.json dosyası oluşturur. -- `npm install --save @hapi/hapi@17.x.x` komutunu çalıştır. Bu komut hapiyi kurar ve package.json dosyasına gereksinim olarak kaydeder. +## hapi Kurulumu -Hepsi bu kadar! Artık hapi kullanarak bir sunucu oluşturmak için ihtiyacın olan herşeye sahipsin. - -### Bir sunucu olusturmak - -En basit sunucu şöyle bir şeydir: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); - -const init = async () => { - await server.start(); - console.log(`Server running at: ${server.info.uri}`); -}; - -process.on('unhandledRejection', (err) => { - console.log(err); - process.exit(1); -}); - -init(); +```sh +mkdir myproject +cd myproject +npm init -y # creates package.json +npm pkg set type=module # makes Node.js treat your files as ES modules +npm install @hapi/hapi ``` -Önce, hapiyi talep ediyoruz. Sonra dinlenecek konak (host) ve bağlantı noktası (port number) bilgilerini içeren bir yapılandırma nesnesi kullanarak yeni bir hapi sunucu nesnesi oluşturuyoruz. Daha sonra sunucuyu başlatarak çalıştığını günlüklüyoruz. - -Bir sunucu oluştururken, konak adı, IP adresi ve hatta bir Unix soket dosyası ya da bağlanılacak Windows adlı veri yolu (Windows named pipe) bile sağlayabiliriz. Daha fazla detay için bkz: [API Referansı](/api/#-server-options). - -### Güzergah eklemek - -Artık sunucumuz olduğuna göre, bir iki güzergah eklemeliyiz ki gerçekten bir şeyler yapsın. Bakalım nasıl olacak: +Bu eğitimlerdeki örnekler TypeScript ile yazılmıştır, ama TypeScript zorunlu değildir. hapi düz JavaScript ile de aynı şekilde çalışır: bir örnekten tip açıklamalarını, `import type` satırlarını, `interface` ve `declare module` bloklarını ve `server.route<...>` gibi tip argümanlarını çıkar, sonra dosyayı `.js` uzantısıyla kaydet. Tip denetimini kurmak için [TypeScript Kullanımı](#typescript) bölümüne bak. -```javascript -'use strict'; +## Sunucu Oluşturma -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); - -server.route({ - method: 'GET', - path: '/', - handler: (request, h) => { - return 'Hello, world!'; - }, -}); +Çok basit bir hapi sunucusu şöyle görünür: -server.route({ - method: 'GET', - path: '/{name}', - handler: (request, h) => { - return 'Hello, ' + encodeURIComponent(request.params.name) + '!'; - }, -}); +```ts +import Hapi from '@hapi/hapi'; const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + await server.start(); - console.log(`Server running at: ${server.info.uri}`); + console.log('Server running on %s', server.info.uri); }; process.on('unhandledRejection', (err) => { @@ -92,151 +49,114 @@ process.on('unhandledRejection', (err) => { init(); ``` -Yukarıdakilerin tamamını `server.js` olarak kaydet ve `node server.js` komutunu çalıştırarak sunucuyu çalıştır. Artık tarayıcından [http://localhost:3000](http://localhost:3000) adresini ziyaret ettiğinde `Hello, world!` ve [http://localhost:3000/stimpy](http://localhost:3000/stimpy) adresini ziyaret ettiğinde `Hello, stimpy!` yazdığını göreceksin. +`Hapi.server()` sunucuyu oluşturur, `server.start()` ise dinlemeye başlar. `unhandledRejection` handler'ı, `init()` başarısız olursa (örneğin port zaten kullanımdaysa) süreci durdurur. -name değiştirgesini URI encode (URI kodladığımıza) ettiğimize dikkat et, bu işlem içerik yerleştirme saldırılarının (content injection attack) önüne geçer. Unutma, kullanıcı tarafından sağlanan verileri çıktılarını kodlamadan önce yorumlamak asla iyi bir fikir değildir! +`host`, sunucunun hangi ağ arayüzünü dinleyeceğini belirler: -`method` değiştirgesi herhangi bir geçerli HTTP yöntemi, HTTP yöntem dizisi ya da herhangi bir yönetmi kabul etmek üzere yıldız işareti olabilir. `path` değiştirgesi değiştirgeleri ile birlikte yolu tanımlar. Seçmeli değiştirgeler, numaralanmış değiştirgeler ve hatta özel semboller (wildcard) bile içerebilir. Daha fazla detay için, bkz: [Yol Atama (routing) Eğitimi](/tutorials/routing). +| `host` | Nereden erişilebilir | +| ------------- | ------------------------------------------------------------ | +| `'localhost'` | yalnızca bu makineden; geliştirme için en güvenli varsayılan | +| `'0.0.0.0'` | tüm arayüzlerden; Docker container içinde gereklidir | +| belirtilmezse | tüm arayüzlerden (hapi'nin varsayılanı) | -### Duragan sayfalar ve içerikler yaratmak +Bir Unix soketini veya Windows named pipe'ı da dinleyebilirsin; bkz. [`server.options.port`](/api#server-options-port). -Hello world uygulamamızla basit bir hapi uygulaması başlatabildiğimizi kanıtladık. Şimdi, **inert** denen bir eklenti kullarak durağan bir sayfa sunacağız. Başlamadan önce, **CTRL + C** tuşlarına basarak sunucuyu durdur. +## Route Ekleme -[inert](https://github.com/hapijs/inert)i kurmak için komut satırında `npm install --save @hapi/inert` komutunu çalıştır. Bu, [inert](https://github.com/hapijs/inert)i indirerek hangi paketlerin kurulu olduğunu dokümante eden `package.json` dosyana ekleyecek. +"Hello World!" döndüren bir route ekle: -`server.js` dosyasını açarak `init` işlevini şu şekilde güncelle: +```ts +import Hapi from '@hapi/hapi'; -```javascript const init = async () => { - await server.register(require('@hapi/inert')); + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); server.route({ method: 'GET', - path: '/hello', + path: '/', handler: (request, h) => { - return h.file('./public/hello.html'); + return 'Hello World!'; }, }); await server.start(); - console.log(`Server running at: ${server.info.uri}`); + console.log('Server running on %s', server.info.uri); }; -``` - -Yukarıdaki `server.register()` komutu [inert](https://github.com/hapijs/inert) eklentisini hapi uygulamana ekler. - -`server.route()` komutu `/hello` yolunu kaydeder ve sunucuna `/hello` yolundan gelen GET isteklerini kabul ederek `hello.html` dosyası ile cevap vermesini söyler. Yol Atama işlemini `inert` eklentisini kaydettikten sonra yaptık. Bir eklenti kullanarak çalışan bir kodu eklentiyi kaydettikten sonra çalıştırmak genellikle bilgece bir harekettir. Böylece kod çalıştığında eklentinin yüklü olduğu kesinleşmiş olur. - -`node server.js` komutunu çalıştırarak sunucu çalıştır ve tarayıcından [`http://localhost:3000/hello`](http://localhost:3000/hello) adresine git. Olamaz! `hello.html` dosyasını oluşturmadığımız için hata alıyoruz. Bu hatadan kurtulmak için hello.html dosyasını oluşturman gerekecek. -Ana dizininde içerisinde `hello.html` olan `public` diye bir dizin oluştur ve içine şu HTMLi göm: +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); -```html - - - - - Hapi.js is awesome! - - -

Hello World.

- - +init(); ``` -Bu basit bir HTML5 dokümanı. +Yukarıdaki kodu `index.ts` olarak kaydet ve sunucuyu başlat: -Şimdi tarayıcını yenile. Hello world yazan bir başlık görmelisin. +```sh +node index.ts +``` -[Inert](https://github.com/hapijs/inert) istek yapıldığında diskinde ne varsa onu sunar. Bu canlı yenileme olayına sebep olan da bu. `/hello` yolunu zevkine göre düzenle. +Node.js 22.18 ve sonrası `.ts` dosyalarını derleme adımı olmadan doğrudan çalıştırır. Bu örnekte tip açıklaması olmadığı için dosyayı `index.js` olarak kaydedip `node index.js` ile çalıştırmak da işe yarar. `http://localhost:3000` adresini açtığında `Hello World!` yazısını göreceksin. -Durağan içeriklerin nasıl sunulduğu [Durağan İçerik Sunmak](/tutorials/servingfiles) adresinde detaylandırılıyor. Bu teknik genelde resim, biçem sayfaları (stylesheet) ve durağan sayfaları sunmak için kullanırılır. +Bir route'un üç zorunlu özelliği vardır: ---- +| Özellik | Ne yapar | +| --------- | ------------------------------------------------------------------------------------------------------- | +| `method` | HTTP metodu: `'GET'`, `'POST'`, `['GET', 'POST']` gibi bir dizi ya da herhangi bir metot için `'*'`. | +| `path` | URL yolu. `/users/{id}` gibi parametreler içerebilir; bkz. [Routing](/tutorials/tr_TR/routing) eğitimi. | +| `handler` | Yanıtı üreten fonksiyon. Bir değer döndürür, bir promise döndürür ya da hata fırlatır. | -### Eklentileri Kullanmak +## TypeScript Kullanımı -Herhangi bir web uygulaması oluştururken genelde erişim günlükleri istenir. Hadi uygulamamıza basit bir günlükleme özelliği eklemek için [hapi pino](https://github.com/pinojs/hapi-pino) eklentisini yükleyelim. +Bu bölüm isteğe bağlıdır. hapi kendi TypeScript tiplerini içerir, yani kurulması gereken bir `@types/hapi__hapi` paketi yoktur. -Modülü kurarak başla: +Node.js örnekleri tipleri ayıklayarak çalıştırır; bu işlem tipleri yalnızca siler, denetlemez. Tipleri denetlemek için TypeScript derleyicisini ve Node.js tiplerini kur: -```bash -npm install hapi-pino +```sh +npm install --save-dev typescript @types/node ``` -Sonra `server.js` dosyasını güncelle: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); +Ardından bir `tsconfig.json` ekle: + +```json +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + } +} +``` -server.route({ - method: 'GET', - path: '/', - handler: (request, h) => { - return 'Hello, world!'; - }, -}); +Tiplerini denetlemek istediğinde `npx tsc` komutunu çalıştır. `erasableSyntaxOnly` ve `verbatimModuleSyntax`, Node.js'in ayıklayamadığı TypeScript özelliklerini reddeder; örneğin `enum` ve `type` anahtar sözcüğü olmadan yazılmış tip import'ları. -server.route({ - method: 'GET', - path: '/{name}', - handler: (request, h) => { - // request.log(['a', 'name'], "Request name"); - // or - request.logger.info('In handler %s', request.path); +Yukarıdaki route'ta `request` ve `h` için tip açıklaması gerekmez: TypeScript bunları `server.route()` imzasından hapi'nin `Request` ve `ResponseToolkit` tipleri olarak çıkarır. Bunları yalnızca bir handler'ı route yapılandırmasının dışında yazdığında, `@hapi/hapi` paketinin dışa aktardığı tiplerle açıkla: - return `Hello, ${encodeURIComponent(request.params.name)}!`; - }, -}); +```ts +import type { Request, ResponseToolkit } from '@hapi/hapi'; -const init = async () => { - await server.register({ - plugin: require('hapi-pino'), - options: { - prettyPrint: false, - logEvents: ['response', 'onPostStart'], - }, - }); - - await server.start(); - console.log(`Server running at: ${server.info.uri}`); +const hello = (request: Request, h: ResponseToolkit) => { + return 'Hello World!'; }; - -process.on('unhandledRejection', (err) => { - console.log(err); - process.exit(1); -}); - -init(); -``` - -Artık sunucu başladığında şöyle bir şey göreceksin: - -```sh -[2017-12-03T17:15:45.114Z] INFO (10412 on box): server started - created: 1512321345014 - started: 1512321345092 - host: "localhost" - port: 3000 - protocol: "http" - id: "box:10412:jar12y2e" - uri: "http://localhost:3000" - address: "127.0.0.1" ``` -Ve eğer [http://localhost:3000/](http://localhost:3000/) adresini ziyaret edersek, istek ile ilgili günlüklerin terminale yazıldığını görebiliriz. - -Günlükleyicinin davranışları kayıt fonksiyonuna gönderilen `options` altında düzenlenir. +## Geri Kalan Her Şey -Güzel! Bu yalnızca eklentilerin neler yapabildiğini gösteren kısa bir örnekti. Daha fazla bilgi için [plugins tutorial](/tutorials/plugins) adresine göz at. +Bu eğitim bilerek kısa tutuldu. Sonraki adımlar için iyi seçenekler: -### Geriye kalan her sey +- [Plugin'ler](/tutorials/tr_TR/plugins): uygulamanı yeniden kullanılabilir parçalara böl. +- [Routing](/tutorials/tr_TR/routing): yol parametreleri, metotlar ve route seçenekleri. +- [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks): bir isteğin her adımında, ayrıca sunucu başlarken ve kapanırken kod çalıştır. +- [Decorator'lar](/tutorials/tr_TR/decorators): sunucuya, isteğe ve response toolkit'e kendi metotlarını ekle. +- [Yanıt Hazırlama](/tutorials/tr_TR/response-marshalling): bir handler'ın dönüş değerinin nasıl HTTP yanıtına dönüştüğü. +- [Test](/tutorials/tr_TR/testing): route'ları port açmadan test et. -Buradaki örneklerde hapi'nin başlıca birkaç kabiliyeti dokümante edilmiş olsada, onun daha bir çok yeteneği var. Bunlara ulaşmak için sağındaki listeyi kullanabilirsin. Geriye kalan her şey [API reference](/api) altında dökümante ediliyor. Her zaman olduğu gibi, [github](https://github.com/hapijs/discuss/issues)'ı soru sormak için kullanabilir ya da bizi [slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg) üzerinde ziyaret edebilirsin. +Geri kalan her şey [API referansında](/api) belgelenmiştir. Sorularını [GitHub](https://github.com/hapijs/hapi/issues) üzerinden ya da [hapi Slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg) kanalında sorabilirsin. diff --git a/docs/tutorials/zh_CN/getting-started.md b/docs/tutorials/zh_CN/getting-started.md index be0be9b4..9589a8bc 100644 --- a/docs/tutorials/zh_CN/getting-started.md +++ b/docs/tutorials/zh_CN/getting-started.md @@ -1,87 +1,44 @@ --- -title: Getting Started +title: 快速入门 --- -## 快速入门 +# 快速入门 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更新版本_ -### 安装 hapi +## 概述 -新建一个目录 `myproject`: +本教程搭建一个基础的 hapi 服务器,在浏览器中显示 "Hello World!"。 -- 运行: `cd myproject` 跳转到目录内。 -- 运行: `npm init` 根据提示生成文件 package.json。 -- 运行: `npm install --save @hapi/hapi@17.x.x` 将安装 hapi ,并将相应的依赖并保存至 package.json 中。 +## 安装 hapi -就是这么简单! 你现在已经做好了创建一个 hapi 应用的全部准备。 - -### 创建一个Web服务器 - -最基本的Web服务器代码如下: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); - -const init = async () => { - await server.start(); - console.log(`Server running at: ${server.info.uri}`); -}; - -process.on('unhandledRejection', (err) => { - console.log(err); - process.exit(1); -}); - -init(); +```sh +mkdir myproject +cd myproject +npm init -y # creates package.json +npm pkg set type=module # makes Node.js treat your files as ES modules +npm install @hapi/hapi ``` -首先引入 hapi,之后我们创建一个 web 服务器并且配置好 host 以及需要监听的端口,最后我们启动这个服务器,并且将运行的信息输出到日志中。 - -Web 服务器可以通过以下方式创建:指定主机名、填写IP地址、Unix socket文件或者通过Windows管道绑定到指定的服务器。更多细节内容请参考 [API reference](/api/#-server-options). - -### 添加路由 +这些教程中的示例使用 TypeScript 编写,但 TypeScript 不是必需的。在纯 JavaScript 中使用 hapi 的方式完全相同:从示例中删除类型注解、`import type` 语句、`interface` 和 `declare module` 代码块,以及 `server.route<...>` 这样的类型参数,然后保存为 `.js` 文件即可。如需配置类型检查,请参阅[使用 TypeScript](#typescript)。 -现在已经有了一个简单的Web服务器,下面将添加两条路由: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); +## 创建服务器 -server.route({ - method: 'GET', - path: '/', - handler: (request, h) => { - return 'Hello, world!'; - }, -}); +一个最基础的 hapi 服务器如下: -server.route({ - method: 'GET', - path: '/{name}', - handler: (request, h) => { - return 'Hello, ' + encodeURIComponent(request.params.name) + '!'; - }, -}); +```ts +import Hapi from '@hapi/hapi'; const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + await server.start(); - console.log(`Server running at: ${server.info.uri}`); + console.log('Server running on %s', server.info.uri); }; process.on('unhandledRejection', (err) => { @@ -92,149 +49,114 @@ process.on('unhandledRejection', (err) => { init(); ``` -将以上代码保存至 `server.js` 之后通过命令 `node server.js`启动服务器。在浏览器中访问 [http://localhost:3000](http://localhost:3000), 你将会看到以下文本 `Hello, world!`, 之后再访问 [http://localhost:3000/stimpy](http://localhost:3000/stimpy) 你将会看到 `Hello, stimpy!`。 +`Hapi.server()` 创建服务器,`server.start()` 开始监听。如果 `init()` 失败(例如端口已被占用),`unhandledRejection` 处理函数会终止进程。 -请注意这里使用了 URI 编码, 这可以有效地防止内容注入攻击。 因此,尽量使用内容编码去处理用户输入的信息。 +`host` 决定服务器监听哪个网络接口: -`method` 可以使用任何有效的 HTTP 方法或者 HTTP 方法数组,又或者使用`*`去通配所有方法。`path` 定义了该路径下所包含的参数,可以为可选参数,数字以及通配符。更多细节内容请参考 [路由教程](/tutorials/routing). +| `host` | 可访问范围 | +| ------------- | ---------------------------------------- | +| `'localhost'` | 仅本机;开发环境下最安全的默认值 | +| `'0.0.0.0'` | 所有网络接口;在 Docker 容器中运行时需要 | +| 省略 | 所有网络接口(hapi 的默认行为) | -### 建立静态内容 +服务器也可以监听 Unix socket 或 Windows 命名管道,参见 [`server.options.port`](/api#server-options-port)。 -我们已经验证了了可以使用 Hello World 应用程序启动一个简单的hapi应用。 之后我们将使用一个名为 **inert** 的插件去创建静态页面。 在你开始之前,通过命令 **CTRL + C** 停止你的服务器。 +## 添加路由 -安装 [inert](https://github.com/hapijs/inert) 使用以下命令: `npm install --save @hapi/inert` 这将会下载 [inert](https://github.com/hapijs/inert) 包,并且将其添加到用于记录依赖的 `package.json` 文件中。 +添加一个返回 "Hello World!" 的路由: -在你的 `server.js` 文件中修改 `init` 函数如下: +```ts +import Hapi from '@hapi/hapi'; -```javascript const init = async () => { - await server.register(require('@hapi/inert')); + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); server.route({ method: 'GET', - path: '/hello', + path: '/', handler: (request, h) => { - return h.file('./public/hello.html'); + return 'Hello World!'; }, }); await server.start(); - console.log(`Server running at: ${server.info.uri}`); + console.log('Server running on %s', server.info.uri); }; -``` -通过 `server.register()` 命令将插件 [inert](https://github.com/hapijs/inert) 注册到你的 hapi 应用中。 - -`server.route()` 将注册 `/hello` 路由, 并接收一个 GET 请求,此请求将会返回文件 `hello.html`。 这里我们在注册 `inert` 插件之后才注册路由,通常明智的做法是在注册插件后才运行依赖于插件的代码,这将在代码运行时可以确保插件已经存在。 +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); -通过 `node server.js` 启动你的服务,并在浏览器内访问[`http://localhost:3000/hello`](http://localhost:3000/hello)。哦 糟糕! 因为我们没有创建 `hello.html` 文件所以出错了。下面我们来创建缺失的文件来修正这个问题。 +init(); +``` -在你的根目录下新建一个名为 `public` 的文件夹,之后在里面创建一个名为 `hello.html` 的文件。 `hello.html` 内代码如下: +将上面的代码保存为 `index.ts`,然后启动服务器: -```html - - - - - Hapi.js is awesome! - - -

Hello World.

- - +```sh +node index.ts ``` -这是一份简单并符合规范的 HTML5 文档。 +Node.js 22.18 及更新版本可以直接运行 `.ts` 文件,无需构建步骤。这个示例没有类型注解,所以保存为 `index.js` 并运行 `node index.js` 也可以。访问 `http://localhost:3000`,就能看到 `Hello World!`。 -在浏览器内重新载入这个页面,你将会看到标题 "Hello World."。 +路由有三个必需属性: -当你对硬盘上的文件作出修改的时候 [Inert](https://github.com/hapijs/inert) 将会自动重新加载这些文件。 你可以根据自己的喜好调整这个页面。 +| 属性 | 作用 | +| --------- | -------------------------------------------------------------------------------------- | +| `method` | HTTP 方法:`'GET'`、`'POST'`,`['GET', 'POST']` 这样的数组,或表示任意方法的 `'*'`。 | +| `path` | URL 路径。可以包含参数,例如 `/users/{id}`;参见[路由教程](/tutorials/zh_CN/routing)。 | +| `handler` | 生成响应的函数。它可以返回一个值、返回一个 promise,或抛出错误。 | -更多关于静态内容的细节请访问 [静态内容](/tutorials/servingfiles)。这项技术一般用于图片,css样式文件以及静态页面。 +## 使用 TypeScript -### 使用插件 +本节是可选的。hapi 自带 TypeScript 类型定义,因此不需要安装 `@types/hapi__hapi` 包。 -日志对于 Web 开发的重要性毋庸置疑,我们通过 [hapi pino](https://github.com/pinojs/hapi-pino) 插件为应用提供日志服务。 +Node.js 运行示例时会剥离类型,这只是删除类型,并不检查类型。要检查类型,需要安装 TypeScript 编译器和 Node.js 类型定义: -通过npm安装所需要的插件: - -```bash -npm install hapi-pino +```sh +npm install --save-dev typescript @types/node ``` -之后修改 `server.js` 如下: - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); - -server.route({ - method: 'GET', - path: '/', - handler: (request, h) => { - return 'Hello, world!'; - }, -}); +然后添加一个 `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + } +} +``` -server.route({ - method: 'GET', - path: '/{name}', - handler: (request, h) => { - // request.log(['a', 'name'], "Request name"); - // or - request.logger.info('In handler %s', request.path); +需要检查类型时运行 `npx tsc`。`erasableSyntaxOnly` 和 `verbatimModuleSyntax` 会拒绝 Node.js 无法剥离的 TypeScript 特性,例如 `enum`,以及没有使用 `type` 关键字的类型导入。 - return `Hello, ${encodeURIComponent(request.params.name)}!`; - }, -}); +在上面的路由中,`request` 和 `h` 不需要类型注解:TypeScript 会根据 `server.route()` 的签名将它们推断为 hapi 的 `Request` 和 `ResponseToolkit`。只有在路由配置之外编写 handler 时才需要标注类型,使用 `@hapi/hapi` 导出的类型即可: -const init = async () => { - await server.register({ - plugin: require('hapi-pino'), - options: { - prettyPrint: false, - logEvents: ['response', 'onPostStart'], - }, - }); +```ts +import type { Request, ResponseToolkit } from '@hapi/hapi'; - await server.start(); - console.log(`Server running at: ${server.info.uri}`); +const hello = (request: Request, h: ResponseToolkit) => { + return 'Hello World!'; }; - -process.on('unhandledRejection', (err) => { - console.log(err); - process.exit(1); -}); - -init(); -``` - -当服务器启动的时候你将会看到以下内容: - -```sh -[2017-12-03T17:15:45.114Z] INFO (10412 on box): server started - created: 1512321345014 - started: 1512321345092 - host: "localhost" - port: 3000 - protocol: "http" - id: "box:10412:jar12y2e" - uri: "http://localhost:3000" - address: "127.0.0.1" ``` -之后在浏览器内访问 [http://localhost:3000/](http://localhost:3000/) 我们可以看到请求的信息已经输出到了终端中。 - -日志可以通过 `options` 配置并注册到服务中。 +## 其他内容 -很好! 这只是使用插件的一个简短示例, 更多信息请查看插件教程 [插件教程](/tutorials/plugins)。 +本教程刻意保持精简。接下来可以阅读: -### 其他 +- [插件](/tutorials/zh_CN/plugins):把应用拆分成可复用的模块。 +- [路由](/tutorials/zh_CN/routing):路径参数、方法和路由选项。 +- [生命周期钩子](/tutorials/zh_CN/lifecycle-hooks):在请求的每个阶段,以及服务器启动和关闭时运行代码。 +- [装饰器](/tutorials/zh_CN/decorators):为服务器、请求和响应工具包添加自定义方法。 +- [响应编组](/tutorials/zh_CN/response-marshalling):handler 的返回值如何变成 HTTP 响应。 +- [测试](/tutorials/zh_CN/testing):无需打开端口即可测试路由。 -hapi 拥有许多其他的功能,此教程只记录了其中少数几个功能,你可以在右侧的列表中找到它们。 其余内容请参阅 [API reference](/api)。 欢迎在 [github](https://github.com/hapijs/discuss/issues) 提问,或者在 [slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg) 找到我们。 +其余内容都记录在 [API 参考](/api)中。如有问题,可以在 [GitHub](https://github.com/hapijs/hapi/issues) 或 [hapi Slack](https://join.slack.com/t/hapihour/shared_invite/zt-g5ortpsk-ErlnRA2rUcPIWES21oXBOg) 上提问。 From f01537c4c51ccd711ec47e6c1fd6891a3e26ae8e Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:56 -0400 Subject: [PATCH 06/22] docs: rework authentication tutorial TypeScript examples, diagrams and tables in place of long prose, new scopes and jwt sections, and translations updated. --- docs/tutorials/en_US/auth.md | 567 +++++++++++++++++++++++------ docs/tutorials/es_ES/auth.md | 661 ++++++++++++++++++++++++++++++++++ docs/tutorials/ko_KR/auth.md | 632 +++++++++++++++++++++++++++++---- docs/tutorials/pt_BR/auth.md | 667 ++++++++++++++++++++++++++++++----- docs/tutorials/tr_TR/auth.md | 631 +++++++++++++++++++++++++++++---- docs/tutorials/zh_CN/auth.md | 632 +++++++++++++++++++++++++++++---- 6 files changed, 3406 insertions(+), 384 deletions(-) create mode 100644 docs/tutorials/es_ES/auth.md diff --git a/docs/tutorials/en_US/auth.md b/docs/tutorials/en_US/auth.md index ad221ca6..e2ec0f62 100644 --- a/docs/tutorials/en_US/auth.md +++ b/docs/tutorials/en_US/auth.md @@ -6,121 +6,364 @@ title: Authentication # Authentication -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -Most modern web apps use some form of authentication. Authentication within hapi is based on the concept of `schemes` and `strategies`. `Schemes` are a way of handling authentication within hapi. For example, the `@hapi/basic` and `@hapi/cookie` plugins would be considered `schemes`. A `strategy` is a pre-configured instance of a `scheme`. You use `strategies` to implement authentication `schemes` into your application. +Most modern web apps use some form of authentication. Authentication in hapi is built from two pieces, `schemes` and `strategies`: + +- A `scheme` is a general way of authenticating, such as "HTTP basic auth" or "session cookie". Plugins like `@hapi/basic` and `@hapi/cookie` each register a scheme. +- A `strategy` is a scheme with a name and a configuration, such as "basic auth, checked against my users table". Routes refer to strategies by name. + +Using `@hapi/basic`, the plugin registers a scheme, you configure a strategy from it, and a route uses that strategy: + +```ts +// Registers the 'basic' scheme. +await server.register(Basic); + +server.auth.strategy('simple', 'basic', { validate }); + +server.route({ + method: 'GET', + path: '/', + options: { auth: 'simple' }, + handler: (request, h) => 'welcome', +}); +``` ## Schemes -A `scheme` is a method with the signature `function (server, options)`. The `server` parameter is a reference to the server the scheme is being added to, while the `options` parameter is the configuration object provided when registering a strategy that uses this scheme. +A `scheme` is a function with the signature `function (server, options)`. `server` is the server the scheme is added to, and `options` is the configuration object passed when a strategy is created from this scheme. The function returns a scheme object, and hapi calls the methods on that object at three points in the [request lifecycle](/api#request-lifecycle): -This method must return an object with _at least_ the key `authenticate`. Other optional methods that can be used are `payload` and `response`. +```mermaid +flowchart TD + A["onRequest, route lookup,
cookie parsing, onPreAuth"] --> D["authenticate(request, h)
required"] + D --> E[payload parsing] + E --> F["payload(request, h)
optional"] + F --> G["onCredentials, authorization
(auth.access scopes), onPostAuth"] + G --> J["validation, handler,
onPreResponse"] + J --> L["response(request, h)
optional"] + L --> M[response sent] -You can either write your own authentication `scheme`, or use one of the many hapi auth plugins, such as `@hapi/basic` or `@hapi/cookie`. + class D,F,L highlight +``` -### authenticate +`payload` and `response` only run for requests that `authenticate` accepted. + +You can write your own scheme, or use one of the hapi auth plugins such as `@hapi/basic` or `@hapi/cookie`. Here is a complete scheme that reads an API token from a request header: -The `authenticate` method has a signature of `function (request, h)`, and is the only _required_ method in a scheme. +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; -In this context, `request` is the `request` object created by the server. It is the same object that becomes available in a route handler, and is documented in the [API reference](/api#request). +import type { ServerAuthScheme } from '@hapi/hapi'; -`h` is the standard hapi [response toolkit](/api#response-toolkit). +declare module '@hapi/hapi' { + interface UserCredentials { + id: string; + name: string; + } +} + +interface TokenOptions { + tokens: Map; +} + +const tokenScheme: ServerAuthScheme = (server, options) => { + return { + authenticate: (request, h) => { + const token = request.headers['x-api-token']; + if (typeof token !== 'string') { + throw Boom.unauthorized(null, 'token'); + } -When authentication is successful, you must call and return `h.authenticated({ credentials, artifacts })`. `credentials` property is an object representing the authenticated user (or the credentials the user attempted to authenticate with). Additionally, you may also have an `artifacts` key, which can contain any authentication related data that is not part of the user's credentials. + const user = options?.tokens.get(token); + if (!user) { + throw Boom.unauthorized('Invalid token', 'token'); + } + + return h.authenticated({ credentials: { user }, artifacts: { token } }); + }, + response: (request, h) => { + if (!Boom.isBoom(request.response)) { + request.response.header('x-authenticated-user', request.auth.credentials.user?.id ?? ''); + } -The `credentials` and `artifacts` properties can be accessed later (in a route handler, for example) as part of the `request.auth` object. + return h.continue; + }, + }; +}; -If authentication is unsuccessful, you can either throw an error or call and return `h.unauthenticated(error, [data])` where `error` is an authentication error and `data` is an optional object containing `credentials` and `artifacts`. There's no difference between calling `return h.unauthenticated(error)` or throwing an error if no `data` object is provided. The specifics of the error passed will affect the behavior. More information can be found in the API documentation for [`server.auth.scheme(name, scheme)`](). It is recommend to use [boom](/module/boom) for errors. +const server = Hapi.server({ port: 4000 }); -### payload +server.auth.scheme('token', tokenScheme); +server.auth.strategy('api', 'token', { + tokens: new Map([['a1b2c3', { id: '2133d32a', name: 'John Doe' }]]), +}); -The `payload` method has the signature `function (request, h)`. +server.route({ + method: 'GET', + path: '/me', + options: { auth: 'api' }, + handler: (request, h) => { + return request.auth.credentials.user; + }, +}); -Again, the standard hapi response toolkit is available here. To signal a failure throw an error, again it's recommended to use [boom](/module/boom) for errors. +await server.start(); +``` -To signal a successful authentication, return `h.continue`. +`ServerAuthScheme` types the `options` the scheme receives. The tokens live in a `Map` rather than a plain object on purpose: looking up a client-supplied key on a plain object also finds inherited properties, so a token of `constructor` or `toString` would authenticate. The `declare module` block tells TypeScript what your credentials contain: hapi exports an empty `UserCredentials` interface for you to extend, and after this `request.auth.credentials.user` is typed in every handler. -### response +### authenticate -The `response` method also has the signature `function (request, h)` and utilizes the standard response toolkit. +The `authenticate` method has the signature `function (request, h)`, and is the only _required_ method in a scheme. `request` is the same [request object](/api#request) a route handler receives, and `h` is the standard [response toolkit](/api#response-toolkit). -This method is intended to decorate the response object (`request.response`) with additional headers, before the response is sent to the user. +`authenticate` ends in one of three ways, and each one leads somewhere different: -Once any decoration is complete, you must return `h.continue`, and the response will be sent. +```mermaid +flowchart TD + A["authenticate(request, h)"] --> OK["success
h.authenticated()"] + A --> MISS["missing credentials
error with no message,
only the scheme name"] + A --> BAD["invalid credentials
any other error"] + OK --> OK2["handler sees
request.auth.credentials"] + MISS --> MISS2["next strategy; if none is left,
the route's mode decides"] + BAD --> BAD2["no other strategy tried;
the route's mode decides"] -If an error occurs, you should instead throw an error where the error is recommended to be a [boom](/module/boom). + class OK,MISS,BAD highlight +``` -## Strategies +- **Success:** return `h.authenticated({ credentials, artifacts })`. `credentials` represents the authenticated user. `artifacts` is optional and holds any other authentication data, such as the raw token. Both are available later as `request.auth.credentials` and `request.auth.artifacts`. +- **Missing credentials:** throw an error with no message and the scheme name, such as `Boom.unauthorized(null, 'token')`. hapi treats the request as carrying no credentials for this scheme, so it moves on to the route's next strategy, and the scheme name ends up in the `WWW-Authenticate` header. +- **Invalid credentials:** throw any other error, such as `Boom.unauthorized('Invalid token', 'token')`. hapi stops trying other strategies. A `Boom.unauthorized()` with no message _and_ no scheme name also counts as invalid. + +What happens after a failure depends on the route's [`mode`](#mode). + +Instead of throwing, you can return `h.unauthenticated(error, data)`. It behaves like throwing `error`, but `data` lets you attach `credentials` and `artifacts` to the failed request, which a route in `'try'` mode can then read. Without `data`, `return h.unauthenticated(error)` and `throw error` are the same. See [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) for the full rules. [boom](/module/boom) is the recommended way to create these errors. + +### payload + +The `payload` method has the signature `function (request, h)`. It runs after the payload is parsed, so it can check the payload itself, for example a signature computed over the request body. + +Return `h.continue` when the payload passes, and throw an error when it fails. Routes turn payload authentication on with the [`payload` route option](#route). A scheme that always needs it can return `options: { payload: true }` next to its methods, which stops routes from turning it off. + +### response -Once you've registered your scheme, you need a way to use it. This is where strategies come in. +The `response` method has the signature `function (request, h)`. It runs after `onPreResponse`, just before the response headers are written, so it can add authentication headers to `request.response`. The token scheme above uses it to add an `x-authenticated-user` header. -As mentioned above, a strategy is essentially a pre-configured instance of a scheme. +By the time `response` runs, hapi has already turned any error into a response object, so error responses get the header too. The `Boom.isBoom()` check narrows `request.response` to a response object, so `.header()` typechecks. Return `h.continue` when you are done. To fail, throw an error. -To register a strategy, you must first have a scheme registered. Once that's complete, use `server.auth.strategy(name, scheme, [options])` to register your strategy. +## Strategies + +A scheme does nothing until you create a strategy from it with `server.auth.strategy(name, scheme, [options])`: + +- `name` is the strategy's name. Routes use it to pick the strategy. +- `scheme` is the name of a registered scheme. +- `options` is passed to the scheme function as its `options` argument. -The `name` parameter must be a string, and will be used later to identify this specific strategy. `scheme` is also a string, and is the scheme's name this strategy is to be an instance of. The `options` parameter is used to customize the `strategy`'s options. +One scheme can back many strategies, each with its own configuration: -```js +```ts server.auth.strategy('session', 'cookie', { - name: 'sid-example', - password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', - isSecure: false, + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, +}); + +server.auth.strategy('admin-session', 'cookie', { + cookie: { + name: 'sid-admin', + password: 'Q7v!kW2#pL9@zR4$mT6^xB1&nD8*cF3%', + isSecure: false, + }, + redirectTo: '/admin/login', + requestDecoratorName: 'adminCookieAuth', }); ``` -In the above example, you register the `strategy` with `server.auth.strategy()`. You name the `strategy` `session`, and say that you are using the `cookie` scheme. Lastly, you configure the `strategy` by giving it a `name`, `password`, and setting `isSecure: false`. +Each `@hapi/cookie` strategy adds a `request.cookieAuth` helper, and hapi refuses to add the same request decoration twice, so the second strategy needs `requestDecoratorName` to give its helper another name. Its routes call `request.adminCookieAuth.set()`. The `@types/hapi__cookie` package (see [cookie](#cookie)) only types `cookieAuth`, so in TypeScript declare the renamed helper too: + +```ts +declare module '@hapi/hapi' { + interface Request { + adminCookieAuth: Request['cookieAuth']; + } +} +``` ## Default Strategy -You may set a default strategy by using `server.auth.default()`. +`server.auth.default()` sets the strategy every route uses unless it says otherwise. It accepts a strategy name, or an object in the same format as a route's [auth options](#route). The default applies to every route whose `auth` option names no strategy, including routes added _before_ `server.auth.default()` is called. The exception is a route whose `auth` sets only `mode` or `access`: hapi merges it with the default when the route is added, so call `server.auth.default()` first. -This method accepts one parameter, which may be either a string with the name of the strategy to be used as default, or an object in the same format as the route handler's [auth options](#route-configuration). +```ts +server.auth.default('session'); -Note that all routes will have the default applied to them, even the ones added _before_ `server.auth.default()` is called. +server.route({ + method: 'GET', + path: '/account', + handler: (request, h) => 'protected by the session strategy', +}); + +server.route({ + method: 'GET', + path: '/health', + options: { auth: false }, + handler: (request, h) => 'public', +}); +``` ## Route Configuration -Authentication can also be configured on a route, by the `options.auth` parameter. If set to `false`, authentication is disabled for the route. +A route sets authentication with its `options.auth` property: -It may also be set to a string with the name of the strategy to use, or an object with `mode`, `strategies`, and `payload` parameters. +```ts +server.route({ + method: 'GET', + path: '/feed', + options: { + auth: { + mode: 'try', + strategies: ['session', 'api'], + }, + }, + handler: (request, h) => { + if (!request.auth.isAuthenticated) { + return 'public feed'; + } -The `mode` parameter may be set to `'required'`, `'optional'`, or `'try'` and works the same as when registering a strategy. + return `personal feed, signed in with ${request.auth.strategy}`; + }, +}); +``` -If set to `'required'`, in order to access the route, the user must be authenticated, and their authentication must be valid, otherwise they will receive an error. +`options.auth` can be: -If `mode` is set to `'optional'` the strategy will still be applied to the route but in this case the user does _not_ need to be authenticated. Authentication data is optional, but must be valid if provided. +- `false`, to turn authentication off for the route. +- A strategy name, such as `'session'`. +- An object with `mode`, `strategy` or `strategies`, and `payload`. -The last `mode` setting is `'try'`. The difference between `'try'` and `'optional'` is that with `'try'` invalid authentication is accepted, and the user will still reach the route handler. +### mode -When specifying one strategy, you may set the `strategy` property to a string with the name of the strategy. When specifying more than one strategy, the parameter name must be `strategies` and should be an array of strings each naming a strategy to try. The strategies will then be attempted in order until one succeeds, or they have all failed. +`mode` decides what happens to a request that has no credentials, or bad ones: -Lastly, the `payload` parameter can be set to `false` denoting the payload is not to be authenticated, `'required'` or `true` meaning that it _must_ be authenticated, or `'optional'` meaning that if the client includes payload authentication information, the authentication must be valid. +| `mode` | No credentials | Invalid credentials | Valid credentials | +| ---------------------- | ----------------------------- | ----------------------------- | ----------------- | +| `'required'` (default) | 401 | 401 | handler runs | +| `'optional'` | handler runs, unauthenticated | 401 | handler runs | +| `'try'` | handler runs, unauthenticated | handler runs, unauthenticated | handler runs | -Is only possible to use the `payload` parameter, with a strategy that supports the `payload` method in its scheme. +"No credentials" and "invalid credentials" are the two kinds of [`authenticate` failure](#authenticate). When the handler runs after a failure, `request.auth.isAuthenticated` is `false` and `request.auth.error` holds the reason: `Missing authentication` when no strategy found credentials, or the error a strategy threw when it rejected them. -## basic +### strategy and strategies -The first `scheme` we will look at is the [@hapi/basic](/module/basic) plugin. Just like the name says, the `@hapi/basic` plugin uses basic authentication to validate users. Here is an example of setting up `@hapi/basic`: +Use `strategy: 'session'` for one strategy, or `strategies: ['session', 'api']` for several. hapi tries them in order, and moves to the next one only while each reports missing credentials: -```js -'use strict'; +```mermaid +flowchart TD + R[request] --> S1{"strategy 1
session"} + S1 -->|authenticated| H[handler] + S1 -->|missing| S2{"strategy 2
api"} + S1 -->|invalid| E["failed:
the route's mode decides"] + S2 -->|authenticated| H + S2 -->|invalid or missing| E +``` -const Bcrypt = require('bcrypt'); -const Hapi = require('@hapi/hapi'); +### payload -const users = { - john: { - username: 'john', - password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' - name: 'John Doe', - id: '2133d32a', - }, +`payload` turns on the scheme's [`payload` method](#payload) for the route: + +- `false`: the payload is not authenticated. +- `'required'` or `true`: the payload must be authenticated. +- `'optional'`: the payload is checked only if the client sent payload authentication information. + +It only works with a strategy whose scheme has a `payload` method. + +## Scopes + +Authentication decides _who_ is making the request. Scopes decide _what_ they may do. A strategy puts a list of scope strings in `credentials.scope`, a route lists the scopes it accepts in `options.auth.access.scope`, and hapi compares the two in its authorization step, after `onCredentials` and before your handler runs. A request without a matching scope gets `403 Insufficient scope`, and the handler never runs. + +Scopes usually come from the strategy's `validate` function. With the [basic](#basic) example's users, each given a `roles` array: + +```ts +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); + if (!user || !(await Bcrypt.compare(password, user.password))) { + return { isValid: false, credentials: null }; + } + + return { isValid: true, credentials: { id: user.id, name: user.name, scope: user.roles } }; }; +``` + +When the roles live somewhere else, an `onCredentials` hook can add them after authentication; see [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks#credentials). + +Routes then declare the scopes they accept: + +```ts +server.route({ + method: 'GET', + path: '/reports', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'analyst'] } } }, + handler: (request, h) => 'any admin or analyst', +}); -const validate = async (request, username, password) => { - const user = users[username]; +server.route({ + method: 'DELETE', + path: '/users/{id}', + options: { auth: { strategy: 'simple', access: { scope: ['+admin', '!suspended'] } } }, + handler: (request, h) => 'an admin who is not suspended', +}); + +server.route({ + method: 'PUT', + path: '/users/{id}/profile', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'user-{params.id}'] } } }, + handler: (request, h) => 'an admin, or the user whose id is in the path', +}); +``` + +| Route scope | `credentials.scope` must | +| ---------------------- | ---------------------------------------------------------------------------------------------- | +| `'admin'` | include `admin` | +| `['admin', 'analyst']` | include at least one of them | +| `'+admin'` | include `admin`, whatever else the list says | +| `'!suspended'` | not include `suspended` | +| `'user-{params.id}'` | include `user-42` for a request to `/users/42`; `query`, `payload`, and `credentials` work too | + +Scope rules that are easy to miss: + +- Scopes are checked only for authenticated requests. On a route in `'optional'` or `'try'` mode, a request without credentials still reaches the handler, with `request.auth.isAuthorized` set to `false`. Check it before doing anything only some users may do. +- `access.entity` restricts a route to users or applications: `'user'` requires `credentials.user` to be set, `'app'` requires it to be absent. Use it when the same strategy authenticates both people and machine clients. +- `access` can be a list of rules, and a request needs to satisfy only one of them. +- `server.auth.default()` accepts the same `access` options, so a scope can apply to every route. + +See [`route.options.auth.access`](/api#route-options-auth-access) for the full rules. + +## basic + +The first `scheme` we will look at is the [@hapi/basic](/module/basic) plugin. Just like the name says, the `@hapi/basic` plugin uses basic authentication to validate users. Here is an example of setting up `@hapi/basic`: + +```ts +import Basic from '@hapi/basic'; +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; + +const users = new Map([ + [ + 'john', + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, + ], +]); + +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); if (!user) { return { credentials: null, isValid: false }; } @@ -134,7 +377,7 @@ const validate = async (request, username, password) => { const start = async () => { const server = Hapi.server({ port: 4000 }); - await server.register(require('@hapi/basic')); + await server.register(Basic); server.auth.strategy('simple', 'basic', { validate }); @@ -157,25 +400,41 @@ const start = async () => { start(); ``` -First, you define your `users` database, which is a simple object in this example. Then you define a validation function, which is a feature specific to [@hapi/basic](/module/basic) and allows you to verify that the user has provided valid credentials. For this validation function, you use `Bcrypt` to compare the user provided password with the hashed password in your database. +First, you define your `users` database, a `Map` keyed by username in this example, for the reason given under [Schemes](#schemes). Then you define a validation function, which is a feature specific to [@hapi/basic](/module/basic) and allows you to verify that the user has provided valid credentials. Typing it as `Basic.Validate` lets TypeScript infer `request`, `username`, and `password`, and checks the object you return. For this validation function, you use `Bcrypt` to compare the user provided password with the hashed password in your database. -Next, you register the plugin, which creates a scheme with the name of `basic`. This is done within the plugin via [server.auth.scheme()](/api#-serverauthschemename-scheme). +Next, you register the plugin, which creates a scheme with the name of `basic`. This is done within the plugin via [server.auth.scheme()](/api#server-auth-scheme-name-scheme). -Once the plugin has been registered, you use [server.auth.strategy()]() to create a strategy with the name of `simple` that refers to your scheme named `basic`. You also pass an options object that gets passed to the scheme and allows you to configure its behavior. +Once the plugin has been registered, you use [server.auth.strategy()](/api#server-auth-strategy-name-scheme-options) to create a strategy with the name of `simple` that refers to your scheme named `basic`. You also pass an options object that gets passed to the scheme and allows you to configure its behavior. The last thing you do is tell a route to use the strategy named `simple` for authentication. ## cookie -[@hapi/cookie](/module/cookie) is a plugin that will store a cookie in the users browser once they are authenticated. This has the option of keeping the user logged in, even after they leave the site. Here is an example of setting up `@hapi/cookie`: - -In this example, the home route, "/", is restricted and can only be accessed once a user has authenticated themselves: - -```js -'use strict'; +[@hapi/cookie](/module/cookie) is a plugin that stores the session in an encrypted cookie in the user's browser once they are authenticated. Without a `cookie.ttl`, the cookie lasts until the browser closes; set `ttl` to keep the user logged in across browser sessions. + +In this example, the home route, "/", is restricted and can only be accessed once a user has logged in. The browser is redirected to the login form, posts the username and password, and then reaches the home page with the session cookie: + +```mermaid +sequenceDiagram + participant B as Browser + participant S as hapi server + B->>S: GET / + Note over S: no session cookie, so the session strategy fails + S-->>B: 302 redirect to /login (redirectTo) + B->>S: GET /login + S-->>B: 200 login form (auth: false) + B->>S: POST /login with username and password + Note over S: mode 'try': the handler runs without a session
and checks the password with Bcrypt + S-->>B: 302 redirect to /, Set-Cookie sid-example (encrypted { id }) + B->>S: GET / with Cookie sid-example + Note over S: validate(request, session) finds the account by session.id + S-->>B: 200 Welcome to the restricted home page! +``` -const Bcrypt = require('bcrypt'); -const Hapi = require('@hapi/hapi'); +```ts +import Bcrypt from 'bcrypt'; +import Cookie from '@hapi/cookie'; +import Hapi from '@hapi/hapi'; const users = [ { @@ -189,7 +448,7 @@ const users = [ const start = async () => { const server = Hapi.server({ port: 4000 }); - await server.register(require('@hapi/cookie')); + await server.register(Cookie); server.auth.strategy('session', 'cookie', { cookie: { @@ -199,13 +458,16 @@ const start = async () => { }, redirectTo: '/login', validate: async (request, session) => { - const account = await users.find((user) => user.id === session.id); + if (!session || !('id' in session)) { + return { isValid: false }; + } + const account = users.find((user) => user.id === session.id); if (!account) { return { isValid: false }; } - return { isValid: true, credentials: account }; + return { isValid: true, credentials: { id: account.id, name: account.name } }; }, }); @@ -240,28 +502,29 @@ const start = async () => { auth: false, }, }, - { - method: 'POST', - path: '/login', - handler: async (request, h) => { - const { username, password } = request.payload; - const account = users.find((user) => user.username === username); + ]); - if (!account || !(await Bcrypt.compare(password, account.password))) { - return h.redirect('/login'); - } + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); - request.cookieAuth.set({ id: account.id }); + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.redirect('/login'); + } - return h.redirect('/'); - }, - options: { - auth: { - mode: 'try', - }, + request.cookieAuth.set({ id: account.id }); + + return h.redirect('/'); + }, + options: { + auth: { + mode: 'try', }, }, - ]); + }); await server.start(); @@ -271,24 +534,128 @@ const start = async () => { start(); ``` -First, you need to do is register the `@hapi/cookie` plugin with `server.register`. Once the plugin is registered, you configure your `strategy` by calling `server.auth.strategy`. `server.auth.strategy` takes three parameters: name of the strategy, what scheme you are using, and an options object. For your strategy, you name it `session`. For the scheme, you will be using the `cookie` scheme. If you were using `@hapi/basic`, this parameter would be `basic`. The last parameter is an options object. This is how you can customize your auth strategy to fit your needs. +`@hapi/cookie` does not ship its own types. If you use TypeScript, install them with `npm install --save-dev @types/hapi__cookie`. They add `request.cookieAuth` to hapi's `Request` type and type the `cookie` strategy options. The session arrives in `validate` as a plain `object`, so the `'id' in session` check is what lets TypeScript read `session.id`. The `POST /login` route declares its form fields with a type argument to `server.route()`; this is a type only, so add [payload validation](/tutorials/en_US/validation) to enforce it at runtime. + +First, you register the `@hapi/cookie` plugin with `server.register`, which adds the `cookie` scheme. Then you create the `session` strategy from it with `server.auth.strategy`, passing three options: + +- `cookie` configures the cookie itself. `name` is the cookie's name, `sid-example`. `password` encrypts the cookie and must be at least 32 characters long. `isSecure: false` lets the cookie travel over plain HTTP, which is fine in development; leave it at its default of `true` in production. +- `redirectTo` is where the server sends an unauthenticated user who requests a protected route. +- `validate` runs on every request that carries the cookie, and checks that the session is still good. Here it looks the account up by the `id` stored in the cookie, so a deleted account is logged out on its next request. + +`server.auth.default('session')` protects every route with the `session` strategy. `GET /login` opts out with `auth: false`, so anyone can see the form. + +`POST /login` uses `mode: 'try'`, so the handler runs even though the user has no session yet. It finds the account by `username`, and compares the submitted password with the stored hash using `Bcrypt`. If either check fails, the user goes back to the login page. If both pass, `request.cookieAuth.set()` stores `{ id }` in the encrypted cookie, and the user is redirected to the home page. + +## jwt + +[@hapi/jwt](/module/jwt) authenticates requests that carry a JSON Web Token in the `Authorization: Bearer ` header. Like `@hapi/cookie`, it stores no session on the server; the difference is how the identity travels: + +| | `@hapi/cookie` | `@hapi/jwt` | +| -------- | ----------------------------------- | ----------------------------------------------------- | +| Sent in | a cookie the browser attaches | the `Authorization` header the client sets | +| Contents | encrypted: unreadable to the client | signed but readable by anyone who holds the token | +| Suits | browser apps on your own domain | APIs called from mobile apps, other servers, and SPAs | + +Never put secrets in a JWT payload, since signing prevents tampering but not reading. + +The client logs in once, then sends the token with every request: + +```mermaid +sequenceDiagram + participant C as Client + participant S as hapi server + C->>S: POST /login with username and password + Note over S: auth: false, checks the password with Bcrypt + S-->>C: 200 { token } signed with the server's secret + C->>S: GET /me with Authorization: Bearer token + Note over S: jwt strategy checks the signature, aud, iss, and token age,
then calls validate(artifacts) + S-->>C: 200 { id, name } +``` + +```ts +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; +import Jwt from '@hapi/jwt'; + +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; + +const secret = 'hR7#mX2!vQ9@kL4$wP8^zT1&nB6*cJ3%'; -The first property you configure is the `cookie` object. In your `strategy`, you will configure three properties of the `cookie` object. First, you set the name of the cookie, in this case `sid-example`. Next, you set the password that will be used to encrypt the cookie. This should be at least 32 characters long. Last, you set `isSecure` to `false`. This is ok for development while working over HTTP. In production, this should be switched back to `true`, which is the default setting. +const start = async () => { + const server = Hapi.server({ port: 4000 }); -The next property is `redirectTo`. This will tell the server where to redirect to if an unauthenticated user tries to access a resource that requires authentication. + await server.register(Jwt); -The last property is the `validate` function, which validates that a current cookie is still valid. For example, if a user authenticates themselves successfully, receives a cookie, and then leaves the site. Once they return, the `validate` function will check if their current cookie is still valid. + server.auth.strategy('bearer', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:my-api', + iss: 'urn:issuer:my-api', + sub: false, + maxAgeSec: 4 * 60 * 60, + }, + validate: async (artifacts, request, h) => { + const account = users.find((user) => user.id === artifacts.decoded.payload.id); + if (!account) { + return { isValid: false }; + } -You setup the default strategy by calling `server.auth.default('session')`. This will set the default auth strategy for all routes. + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -Once your strategy is set up, you need to set up route that will validate the provided username and password. In this case, your `POST` route to `'/login'` will do just that. First, it will pull the `username` and `password` from `request.payload`, which the user provided in the form from the `'/login'` `'GET'` route. Next, you find the user from the database by searching for their username: + server.auth.default('bearer'); -```js -const account = users.find((user) => user.username === username); + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + options: { auth: false }, + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); + + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.response({ error: 'Invalid username or password' }).code(401); + } + + const token = Jwt.token.generate( + { aud: 'urn:audience:my-api', iss: 'urn:issuer:my-api', id: account.id }, + secret, + ); + + return { token }; + }, + }); + + server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return request.auth.credentials; + }, + }); + + await server.start(); + + console.log('server running at: ' + server.info.uri); +}; + +start(); ``` -If the user doesn't exist, or if the provided password is wrong, you redirect the user back to the login page. You use `Bcrypt` to compare the user provided password with the hashed password from the database. +You register the plugin, which adds the `jwt` scheme, and create the `bearer` strategy from it: + +- `keys` is the secret the tokens are signed with. A shared secret works when the same server issues and checks the tokens. To accept tokens from an identity provider instead, `keys` can point to the provider's public keys; see the `keys` options in the [@hapi/jwt API](/module/jwt). +- `verify` lists the claims a token must carry. `aud` (audience) and `iss` (issuer) must match the values you issue tokens with. `maxAgeSec` rejects tokens whose `iat` (issued at) is more than four hours ago. The tokens in this example carry no `exp`; if yours do, `exp` and `nbf` are checked by default. +- `validate` runs only after the signature and claims pass. It receives the decoded token as `artifacts.decoded.payload`, and decides what goes into `request.auth.credentials`. Here it also checks that the account still exists, since a token stays valid until it expires even if the account is deleted. -Lastly, if the user does exist, and the password matches, the user is then redirected to the homepage. +`POST /login` opts out of authentication with `auth: false`, checks the password, and returns a token from `Jwt.token.generate()` with the same `aud` and `iss` the strategy verifies. The client then calls `GET /me` with `Authorization: Bearer `. `artifacts.decoded.payload` is not typed, because hapi cannot know which claims you put in a token; `validate` is where you check them and build typed credentials. For more info on additional hapi auth plugins, please see the [plugin section](/plugins). diff --git a/docs/tutorials/es_ES/auth.md b/docs/tutorials/es_ES/auth.md new file mode 100644 index 00000000..cf141df3 --- /dev/null +++ b/docs/tutorials/es_ES/auth.md @@ -0,0 +1,661 @@ +--- +title: Autenticación +--- + + + +# Autenticación + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +La mayoría de las aplicaciones web modernas usan algún tipo de autenticación. En hapi, la autenticación se construye con dos piezas, los `schemes` (esquemas) y las `strategies` (estrategias): + +- Un `scheme` es una forma general de autenticar, como "HTTP basic auth" o "cookie de sesión". Plugins como `@hapi/basic` y `@hapi/cookie` registran cada uno un scheme. +- Una `strategy` es un scheme con un nombre y una configuración, como "basic auth, comprobado contra mi tabla de usuarios". Las rutas se refieren a las strategies por su nombre. + +Con `@hapi/basic`, el plugin registra un scheme, tú configuras una strategy a partir de él y una ruta usa esa strategy: + +```ts +// Registers the 'basic' scheme. +await server.register(Basic); + +server.auth.strategy('simple', 'basic', { validate }); + +server.route({ + method: 'GET', + path: '/', + options: { auth: 'simple' }, + handler: (request, h) => 'welcome', +}); +``` + +## Schemes + +Un `scheme` es una función con la firma `function (server, options)`. `server` es el servidor al que se añade el scheme, y `options` es el objeto de configuración que se pasa al crear una strategy a partir de este scheme. La función devuelve un objeto scheme, y hapi llama a los métodos de ese objeto en tres puntos del [ciclo de vida de la petición](/api#request-lifecycle): + +```mermaid +flowchart TD + A["onRequest, búsqueda de ruta,
análisis de cookies, onPreAuth"] --> D["authenticate(request, h)
obligatorio"] + D --> E[análisis del payload] + E --> F["payload(request, h)
opcional"] + F --> G["onCredentials, autorización
(scopes de auth.access), onPostAuth"] + G --> J["validación, handler,
onPreResponse"] + J --> L["response(request, h)
opcional"] + L --> M[respuesta enviada] + + class D,F,L highlight +``` + +`payload` y `response` solo se ejecutan en las peticiones que `authenticate` ha aceptado. + +Puedes escribir tu propio scheme o usar uno de los plugins de autenticación de hapi, como `@hapi/basic` o `@hapi/cookie`. Este es un scheme completo que lee un token de API de una cabecera de la petición: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +import type { ServerAuthScheme } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface UserCredentials { + id: string; + name: string; + } +} + +interface TokenOptions { + tokens: Map; +} + +const tokenScheme: ServerAuthScheme = (server, options) => { + return { + authenticate: (request, h) => { + const token = request.headers['x-api-token']; + if (typeof token !== 'string') { + throw Boom.unauthorized(null, 'token'); + } + + const user = options?.tokens.get(token); + if (!user) { + throw Boom.unauthorized('Invalid token', 'token'); + } + + return h.authenticated({ credentials: { user }, artifacts: { token } }); + }, + response: (request, h) => { + if (!Boom.isBoom(request.response)) { + request.response.header('x-authenticated-user', request.auth.credentials.user?.id ?? ''); + } + + return h.continue; + }, + }; +}; + +const server = Hapi.server({ port: 4000 }); + +server.auth.scheme('token', tokenScheme); +server.auth.strategy('api', 'token', { + tokens: new Map([['a1b2c3', { id: '2133d32a', name: 'John Doe' }]]), +}); + +server.route({ + method: 'GET', + path: '/me', + options: { auth: 'api' }, + handler: (request, h) => { + return request.auth.credentials.user; + }, +}); + +await server.start(); +``` + +`ServerAuthScheme` tipa las `options` que recibe el scheme. Los tokens se guardan en un `Map` y no en un objeto normal a propósito: buscar en un objeto normal una clave que envía el cliente también encuentra propiedades heredadas, así que un token `constructor` o `toString` autenticaría. El bloque `declare module` le dice a TypeScript qué contienen tus credenciales: hapi exporta una interfaz `UserCredentials` vacía para que la extiendas, y a partir de ahí `request.auth.credentials.user` está tipado en todos los handlers. + +### authenticate + +El método `authenticate` tiene la firma `function (request, h)` y es el único método _obligatorio_ de un scheme. `request` es el mismo [objeto request](/api#request) que recibe un handler de ruta, y `h` es el [response toolkit](/api#response-toolkit) estándar. + +`authenticate` termina de una de tres formas, y cada una lleva a un sitio distinto: + +```mermaid +flowchart TD + A["authenticate(request, h)"] --> OK["éxito
h.authenticated()"] + A --> MISS["faltan credenciales
error sin mensaje,
solo el nombre del scheme"] + A --> BAD["credenciales no válidas
cualquier otro error"] + OK --> OK2["el handler ve
request.auth.credentials"] + MISS --> MISS2["siguiente strategy, y si no quedan,
decide el mode de la ruta"] + BAD --> BAD2["no se prueba ninguna otra strategy,
decide el mode de la ruta"] + + class OK,MISS,BAD highlight +``` + +- **Éxito:** devuelve `h.authenticated({ credentials, artifacts })`. `credentials` representa al usuario autenticado. `artifacts` es opcional y guarda cualquier otro dato de autenticación, como el token en bruto. Ambos están disponibles después como `request.auth.credentials` y `request.auth.artifacts`. +- **Faltan credenciales:** lanza un error sin mensaje y con el nombre del scheme, como `Boom.unauthorized(null, 'token')`. hapi trata la petición como si no llevara credenciales para este scheme, así que pasa a la siguiente strategy de la ruta, y el nombre del scheme acaba en la cabecera `WWW-Authenticate`. +- **Credenciales no válidas:** lanza cualquier otro error, como `Boom.unauthorized('Invalid token', 'token')`. hapi deja de probar otras strategies. Un `Boom.unauthorized()` sin mensaje _y_ sin nombre de scheme también cuenta como no válido. + +Lo que ocurre después de un fallo depende del [`mode`](#mode) de la ruta. + +En lugar de lanzar el error, puedes devolver `h.unauthenticated(error, data)`. Se comporta como lanzar `error`, pero `data` te permite adjuntar `credentials` y `artifacts` a la petición fallida, que una ruta en modo `'try'` puede leer después. Sin `data`, `return h.unauthenticated(error)` y `throw error` son lo mismo. Consulta [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) para ver las reglas completas. [boom](/module/boom) es la forma recomendada de crear estos errores. + +### payload + +El método `payload` tiene la firma `function (request, h)`. Se ejecuta después de parsear el payload, así que puede comprobar el propio payload, por ejemplo una firma calculada sobre el cuerpo de la petición. + +Devuelve `h.continue` cuando el payload es correcto y lanza un error cuando no lo es. Las rutas activan la autenticación del payload con la [opción de ruta `payload`](#route). Un scheme que siempre la necesita puede devolver `options: { payload: true }` junto a sus métodos, lo que impide que las rutas la desactiven. + +### response + +El método `response` tiene la firma `function (request, h)`. Se ejecuta después de `onPreResponse`, justo antes de escribir las cabeceras de la respuesta, así que puede añadir cabeceras de autenticación a `request.response`. El scheme de token de arriba lo usa para añadir una cabecera `x-authenticated-user`. + +Cuando se ejecuta `response`, hapi ya ha convertido cualquier error en un objeto response, así que las respuestas de error también reciben la cabecera. La comprobación `Boom.isBoom()` acota `request.response` a un objeto response, de modo que `.header()` pasa la comprobación de tipos. Devuelve `h.continue` cuando termines. Para fallar, lanza un error. + +## Strategies + +Un scheme no hace nada hasta que creas una strategy a partir de él con `server.auth.strategy(name, scheme, [options])`: + +- `name` es el nombre de la strategy. Las rutas lo usan para elegirla. +- `scheme` es el nombre de un scheme registrado. +- `options` se pasa a la función del scheme como su argumento `options`. + +Un mismo scheme puede servir de base a muchas strategies, cada una con su propia configuración: + +```ts +server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, +}); + +server.auth.strategy('admin-session', 'cookie', { + cookie: { + name: 'sid-admin', + password: 'Q7v!kW2#pL9@zR4$mT6^xB1&nD8*cF3%', + isSecure: false, + }, + redirectTo: '/admin/login', + requestDecoratorName: 'adminCookieAuth', +}); +``` + +Cada strategy de `@hapi/cookie` añade un helper `request.cookieAuth`, y hapi se niega a añadir dos veces la misma decoración de petición, así que la segunda strategy necesita `requestDecoratorName` para dar otro nombre a su helper. Sus rutas llaman a `request.adminCookieAuth.set()`. El paquete `@types/hapi__cookie` (consulta [cookie](#cookie)) solo tipa `cookieAuth`, así que en TypeScript declara también el helper renombrado: + +```ts +declare module '@hapi/hapi' { + interface Request { + adminCookieAuth: Request['cookieAuth']; + } +} +``` + +## Strategy por defecto + +`server.auth.default()` fija la strategy que usan todas las rutas salvo que indiquen otra cosa. Acepta el nombre de una strategy, o un objeto con el mismo formato que las [opciones auth](#route) de una ruta. El valor por defecto se aplica a todas las rutas cuya opción `auth` no nombra ninguna strategy, incluidas las rutas añadidas _antes_ de llamar a `server.auth.default()`. La excepción es una ruta cuyo `auth` solo fija `mode` o `access`: hapi la combina con el valor por defecto al añadir la ruta, así que llama primero a `server.auth.default()`. + +```ts +server.auth.default('session'); + +server.route({ + method: 'GET', + path: '/account', + handler: (request, h) => 'protected by the session strategy', +}); + +server.route({ + method: 'GET', + path: '/health', + options: { auth: false }, + handler: (request, h) => 'public', +}); +``` + +## Configuración de la ruta + +Una ruta configura la autenticación con su propiedad `options.auth`: + +```ts +server.route({ + method: 'GET', + path: '/feed', + options: { + auth: { + mode: 'try', + strategies: ['session', 'api'], + }, + }, + handler: (request, h) => { + if (!request.auth.isAuthenticated) { + return 'public feed'; + } + + return `personal feed, signed in with ${request.auth.strategy}`; + }, +}); +``` + +`options.auth` puede ser: + +- `false`, para desactivar la autenticación en la ruta. +- El nombre de una strategy, como `'session'`. +- Un objeto con `mode`, `strategy` o `strategies`, y `payload`. + +### mode + +`mode` decide qué ocurre con una petición que no tiene credenciales o que las tiene incorrectas: + +| `mode` | Sin credenciales | Credenciales no válidas | Credenciales válidas | +| -------------------------- | ------------------------------------- | ------------------------------------- | --------------------- | +| `'required'` (por defecto) | 401 | 401 | se ejecuta el handler | +| `'optional'` | se ejecuta el handler, sin autenticar | 401 | se ejecuta el handler | +| `'try'` | se ejecuta el handler, sin autenticar | se ejecuta el handler, sin autenticar | se ejecuta el handler | + +"Sin credenciales" y "credenciales no válidas" son los dos tipos de [fallo de `authenticate`](#authenticate). Cuando el handler se ejecuta después de un fallo, `request.auth.isAuthenticated` es `false` y `request.auth.error` contiene el motivo: `Missing authentication` cuando ninguna strategy encontró credenciales, o el error que lanzó una strategy al rechazarlas. + +### strategy y strategies + +Usa `strategy: 'session'` para una sola strategy, o `strategies: ['session', 'api']` para varias. hapi las prueba en orden, y solo pasa a la siguiente mientras cada una informe de que faltan credenciales: + +```mermaid +flowchart TD + R[petición] --> S1{"strategy 1
session"} + S1 -->|autenticada| H[handler] + S1 -->|faltan| S2{"strategy 2
api"} + S1 -->|no válidas| E["fallo:
decide el mode de la ruta"] + S2 -->|autenticada| H + S2 -->|no válidas o faltan| E +``` + +### payload + +`payload` activa el [método `payload`](#payload) del scheme para la ruta: + +- `false`: el payload no se autentica. +- `'required'` o `true`: el payload tiene que estar autenticado. +- `'optional'`: el payload solo se comprueba si el cliente ha enviado información de autenticación del payload. + +Solo funciona con una strategy cuyo scheme tenga un método `payload`. + +## Scopes + +La autenticación decide _quién_ hace la petición. Los scopes deciden _qué_ puede hacer. Una strategy pone una lista de cadenas de scope en `credentials.scope`, una ruta enumera los scopes que acepta en `options.auth.access.scope`, y hapi compara ambas en su paso de autorización, después de `onCredentials` y antes de que se ejecute tu handler. Una petición sin un scope que coincida recibe `403 Insufficient scope`, y el handler no llega a ejecutarse. + +Los scopes suelen venir de la función `validate` de la strategy. Con los usuarios del ejemplo de [basic](#basic), cada uno con un array `roles`: + +```ts +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); + if (!user || !(await Bcrypt.compare(password, user.password))) { + return { isValid: false, credentials: null }; + } + + return { isValid: true, credentials: { id: user.id, name: user.name, scope: user.roles } }; +}; +``` + +Cuando los roles están en otro sitio, un hook `onCredentials` puede añadirlos después de la autenticación; consulta [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks#credentials). + +Después, las rutas declaran los scopes que aceptan: + +```ts +server.route({ + method: 'GET', + path: '/reports', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'analyst'] } } }, + handler: (request, h) => 'any admin or analyst', +}); + +server.route({ + method: 'DELETE', + path: '/users/{id}', + options: { auth: { strategy: 'simple', access: { scope: ['+admin', '!suspended'] } } }, + handler: (request, h) => 'an admin who is not suspended', +}); + +server.route({ + method: 'PUT', + path: '/users/{id}/profile', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'user-{params.id}'] } } }, + handler: (request, h) => 'an admin, or the user whose id is in the path', +}); +``` + +| Scope de la ruta | `credentials.scope` debe | +| ---------------------- | ----------------------------------------------------------------------------------------------------- | +| `'admin'` | incluir `admin` | +| `['admin', 'analyst']` | incluir al menos uno de ellos | +| `'+admin'` | incluir `admin`, diga lo que diga el resto de la lista | +| `'!suspended'` | no incluir `suspended` | +| `'user-{params.id}'` | incluir `user-42` en una petición a `/users/42`; también funcionan `query`, `payload` y `credentials` | + +Reglas de los scopes que es fácil pasar por alto: + +- Los scopes solo se comprueban en las peticiones autenticadas. En una ruta en modo `'optional'` o `'try'`, una petición sin credenciales sigue llegando al handler, con `request.auth.isAuthorized` a `false`. Compruébalo antes de hacer algo que solo algunos usuarios pueden hacer. +- `access.entity` restringe una ruta a usuarios o a aplicaciones: `'user'` exige que `credentials.user` esté fijado y `'app'` exige que no lo esté. Úsalo cuando la misma strategy autentica tanto a personas como a clientes máquina. +- `access` puede ser una lista de reglas, y basta con que la petición cumpla una de ellas. +- `server.auth.default()` acepta las mismas opciones `access`, así que un scope puede aplicarse a todas las rutas. + +Consulta [`route.options.auth.access`](/api#route-options-auth-access) para ver las reglas completas. + +## basic + +El primer `scheme` que vamos a ver es el del plugin [@hapi/basic](/module/basic). Como indica su nombre, el plugin `@hapi/basic` usa autenticación básica para validar a los usuarios. Este es un ejemplo de configuración de `@hapi/basic`: + +```ts +import Basic from '@hapi/basic'; +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; + +const users = new Map([ + [ + 'john', + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, + ], +]); + +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); + if (!user) { + return { credentials: null, isValid: false }; + } + + const isValid = await Bcrypt.compare(password, user.password); + const credentials = { id: user.id, name: user.name }; + + return { isValid, credentials }; +}; + +const start = async () => { + const server = Hapi.server({ port: 4000 }); + + await server.register(Basic); + + server.auth.strategy('simple', 'basic', { validate }); + + server.route({ + method: 'GET', + path: '/', + options: { + auth: 'simple', + }, + handler: function (request, h) { + return 'welcome'; + }, + }); + + await server.start(); + + console.log('server running at: ' + server.info.uri); +}; + +start(); +``` + +Primero defines tu base de datos de `users`, en este ejemplo un `Map` indexado por nombre de usuario, por el motivo explicado en [Schemes](#schemes). Después defines una función de validación, que es una característica propia de [@hapi/basic](/module/basic) y te permite verificar que el usuario ha proporcionado credenciales válidas. Tiparla como `Basic.Validate` permite a TypeScript inferir `request`, `username` y `password`, y comprueba el objeto que devuelves. En esta función de validación usas `Bcrypt` para comparar la contraseña que proporciona el usuario con el hash guardado en tu base de datos. + +A continuación registras el plugin, que crea un scheme llamado `basic`. El plugin lo hace internamente con [server.auth.scheme()](/api#server-auth-scheme-name-scheme). + +Una vez registrado el plugin, usas [server.auth.strategy()](/api#server-auth-strategy-name-scheme-options) para crear una strategy llamada `simple` que hace referencia a tu scheme `basic`. También pasas un objeto de opciones que se entrega al scheme y te permite configurar su comportamiento. + +Por último, indicas a una ruta que use la strategy `simple` para la autenticación. + +## cookie + +[@hapi/cookie](/module/cookie) es un plugin que guarda la sesión en una cookie cifrada en el navegador del usuario una vez que este se ha autenticado. Sin un `cookie.ttl`, la cookie dura hasta que se cierra el navegador; fija `ttl` para que el usuario siga con la sesión iniciada entre sesiones del navegador. + +En este ejemplo, la ruta de inicio, "/", está restringida y solo se puede acceder a ella después de iniciar sesión. El navegador se redirige al formulario de login, envía el nombre de usuario y la contraseña, y después llega a la página de inicio con la cookie de sesión: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as Servidor hapi + B->>S: GET / + Note over S: no hay cookie de sesión, así que la strategy session falla + S-->>B: 302 redirección a /login (redirectTo) + B->>S: GET /login + S-->>B: 200 formulario de login (auth: false) + B->>S: POST /login con usuario y contraseña + Note over S: mode 'try': el handler se ejecuta sin sesión
y comprueba la contraseña con Bcrypt + S-->>B: 302 redirección a /, Set-Cookie sid-example (cifrada { id }) + B->>S: GET / con Cookie sid-example + Note over S: validate(request, session) busca la cuenta por session.id + S-->>B: 200 Welcome to the restricted home page! +``` + +```ts +import Bcrypt from 'bcrypt'; +import Cookie from '@hapi/cookie'; +import Hapi from '@hapi/hapi'; + +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; + +const start = async () => { + const server = Hapi.server({ port: 4000 }); + + await server.register(Cookie); + + server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, + redirectTo: '/login', + validate: async (request, session) => { + if (!session || !('id' in session)) { + return { isValid: false }; + } + + const account = users.find((user) => user.id === session.id); + if (!account) { + return { isValid: false }; + } + + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); + + server.auth.default('session'); + + server.route([ + { + method: 'GET', + path: '/', + handler: function (request, h) { + return 'Welcome to the restricted home page!'; + }, + }, + { + method: 'GET', + path: '/login', + handler: function (request, h) { + return ` + + Login page + + +

Please Log In

+
+ Username:
+ Password:
+
+ + `; + }, + options: { + auth: false, + }, + }, + ]); + + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); + + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.redirect('/login'); + } + + request.cookieAuth.set({ id: account.id }); + + return h.redirect('/'); + }, + options: { + auth: { + mode: 'try', + }, + }, + }); + + await server.start(); + + console.log('server running at: ' + server.info.uri); +}; + +start(); +``` + +`@hapi/cookie` no incluye sus propios tipos. Si usas TypeScript, instálalos con `npm install --save-dev @types/hapi__cookie`. Añaden `request.cookieAuth` al tipo `Request` de hapi y tipan las opciones de la strategy `cookie`. La sesión llega a `validate` como un `object` sin más, así que la comprobación `'id' in session` es lo que permite a TypeScript leer `session.id`. La ruta `POST /login` declara los campos de su formulario con un argumento de tipo en `server.route()`; esto es solo un tipo, así que añade [validación del payload](/tutorials/es_ES/validation) para imponerlo en tiempo de ejecución. + +Primero registras el plugin `@hapi/cookie` con `server.register`, lo que añade el scheme `cookie`. Después creas a partir de él la strategy `session` con `server.auth.strategy`, pasando tres opciones: + +- `cookie` configura la propia cookie. `name` es el nombre de la cookie, `sid-example`. `password` cifra la cookie y debe tener al menos 32 caracteres. `isSecure: false` permite que la cookie viaje por HTTP sin cifrar, lo que vale en desarrollo; en producción déjala con su valor por defecto, `true`. +- `redirectTo` es a dónde envía el servidor a un usuario no autenticado que pide una ruta protegida. +- `validate` se ejecuta en cada petición que lleva la cookie y comprueba que la sesión sigue siendo válida. Aquí busca la cuenta por el `id` guardado en la cookie, de modo que una cuenta eliminada pierde la sesión en su siguiente petición. + +`server.auth.default('session')` protege todas las rutas con la strategy `session`. `GET /login` se excluye con `auth: false`, para que cualquiera pueda ver el formulario. + +`POST /login` usa `mode: 'try'`, así que el handler se ejecuta aunque el usuario todavía no tenga sesión. Busca la cuenta por `username` y compara con `Bcrypt` la contraseña enviada con el hash guardado. Si falla cualquiera de las dos comprobaciones, el usuario vuelve a la página de login. Si pasan las dos, `request.cookieAuth.set()` guarda `{ id }` en la cookie cifrada y el usuario se redirige a la página de inicio. + +## jwt + +[@hapi/jwt](/module/jwt) autentica las peticiones que llevan un JSON Web Token en la cabecera `Authorization: Bearer `. Igual que `@hapi/cookie`, no guarda ninguna sesión en el servidor; la diferencia está en cómo viaja la identidad: + +| | `@hapi/cookie` | `@hapi/jwt` | +| ------------- | ---------------------------------------------- | --------------------------------------------------------- | +| Se envía en | una cookie que adjunta el navegador | la cabecera `Authorization` que fija el cliente | +| Contenido | cifrado: el cliente no puede leerlo | firmado, pero lo puede leer cualquiera que tenga el token | +| Adecuado para | aplicaciones de navegador en tu propio dominio | APIs llamadas desde apps móviles, otros servidores y SPAs | + +No pongas nunca secretos en el payload de un JWT, ya que la firma impide modificarlo pero no leerlo. + +El cliente inicia sesión una vez y después envía el token en cada petición: + +```mermaid +sequenceDiagram + participant C as Cliente + participant S as Servidor hapi + C->>S: POST /login con usuario y contraseña + Note over S: auth: false, comprueba la contraseña con Bcrypt + S-->>C: 200 { token } firmado con el secreto del servidor + C->>S: GET /me con Authorization: Bearer token + Note over S: la strategy jwt comprueba la firma, aud, iss y la antigüedad del token,
y después llama a validate(artifacts) + S-->>C: 200 { id, name } +``` + +```ts +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; +import Jwt from '@hapi/jwt'; + +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; + +const secret = 'hR7#mX2!vQ9@kL4$wP8^zT1&nB6*cJ3%'; + +const start = async () => { + const server = Hapi.server({ port: 4000 }); + + await server.register(Jwt); + + server.auth.strategy('bearer', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:my-api', + iss: 'urn:issuer:my-api', + sub: false, + maxAgeSec: 4 * 60 * 60, + }, + validate: async (artifacts, request, h) => { + const account = users.find((user) => user.id === artifacts.decoded.payload.id); + if (!account) { + return { isValid: false }; + } + + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); + + server.auth.default('bearer'); + + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + options: { auth: false }, + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); + + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.response({ error: 'Invalid username or password' }).code(401); + } + + const token = Jwt.token.generate( + { aud: 'urn:audience:my-api', iss: 'urn:issuer:my-api', id: account.id }, + secret, + ); + + return { token }; + }, + }); + + server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return request.auth.credentials; + }, + }); + + await server.start(); + + console.log('server running at: ' + server.info.uri); +}; + +start(); +``` + +Registras el plugin, que añade el scheme `jwt`, y creas a partir de él la strategy `bearer`: + +- `keys` es el secreto con el que se firman los tokens. Un secreto compartido sirve cuando el mismo servidor emite y comprueba los tokens. Para aceptar en su lugar tokens de un proveedor de identidad, `keys` puede apuntar a las claves públicas del proveedor; consulta las opciones `keys` en la [API de @hapi/jwt](/module/jwt). +- `verify` enumera los claims que debe llevar un token. `aud` (audience) e `iss` (issuer) tienen que coincidir con los valores con los que emites los tokens. `maxAgeSec` rechaza los tokens cuyo `iat` (issued at) tenga más de cuatro horas. Los tokens de este ejemplo no llevan `exp`; si los tuyos lo llevan, `exp` y `nbf` se comprueban por defecto. +- `validate` solo se ejecuta cuando la firma y los claims son correctos. Recibe el token decodificado como `artifacts.decoded.payload` y decide qué va en `request.auth.credentials`. Aquí también comprueba que la cuenta sigue existiendo, ya que un token sigue siendo válido hasta que caduca aunque se elimine la cuenta. + +`POST /login` se excluye de la autenticación con `auth: false`, comprueba la contraseña y devuelve un token de `Jwt.token.generate()` con los mismos `aud` e `iss` que verifica la strategy. Después, el cliente llama a `GET /me` con `Authorization: Bearer `. `artifacts.decoded.payload` no está tipado, porque hapi no puede saber qué claims pones en un token; `validate` es donde los compruebas y construyes credenciales tipadas. + +Para más información sobre otros plugins de autenticación de hapi, consulta la [sección de plugins](/plugins). diff --git a/docs/tutorials/ko_KR/auth.md b/docs/tutorials/ko_KR/auth.md index c3cce13b..5572bfac 100644 --- a/docs/tutorials/ko_KR/auth.md +++ b/docs/tutorials/ko_KR/auth.md @@ -1,36 +1,369 @@ --- -title: Authentication +title: 인증 --- -## 인증 +# 인증 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -hapi에서 인증은 `schemes`와 `strategies` 개념을 기반으로 하고 있습니다. +## 개요 -"basic", "digest" 같은 인증의 일반적인 유형으로 scheme을 생각하면 됩니다. 반면에 strategy는 미리 설정하고 이름을 붙인 scheme의 인스턴스입니다. +요즘 웹 앱은 대부분 어떤 형태로든 인증을 사용합니다. hapi의 인증은 `schemes`와 `strategies`라는 두 요소로 구성됩니다. -우선 [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic)을 사용하는 예를 보겠습니다.: +- `scheme`은 "HTTP basic 인증"이나 "세션 쿠키"처럼 인증을 수행하는 일반적인 방식입니다. `@hapi/basic`, `@hapi/cookie` 같은 플러그인은 각각 스킴 하나를 등록합니다. +- `strategy`는 이름과 설정을 붙인 스킴입니다. "basic 인증, 내 사용자 테이블로 검사" 같은 것이 전략입니다. 라우트는 전략을 이름으로 참조합니다. -```javascript -'use strict'; +`@hapi/basic`을 예로 들면, 플러그인이 스킴을 등록하고, 여러분이 그 스킴으로 전략을 설정하고, 라우트가 그 전략을 사용합니다. -const Bcrypt = require('bcrypt'); -const Hapi = require('@hapi/hapi'); +```ts +// Registers the 'basic' scheme. +await server.register(Basic); -const users = { - john: { - username: 'john', - password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' - name: 'John Doe', - id: '2133d32a', +server.auth.strategy('simple', 'basic', { validate }); + +server.route({ + method: 'GET', + path: '/', + options: { auth: 'simple' }, + handler: (request, h) => 'welcome', +}); +``` + +## 스킴 + +`scheme`은 `function (server, options)` 시그니처를 가진 함수입니다. `server`는 스킴이 추가되는 서버이고, `options`는 이 스킴으로 전략을 만들 때 전달하는 설정 객체입니다. 이 함수는 스킴 객체를 반환하며, hapi는 [요청 라이프사이클](/api#request-lifecycle)의 세 지점에서 이 객체의 메서드를 호출합니다. + +```mermaid +flowchart TD + A["onRequest, 라우트 조회,
쿠키 파싱, onPreAuth"] --> D["authenticate(request, h)
필수"] + D --> E[페이로드 파싱] + E --> F["payload(request, h)
선택"] + F --> G["onCredentials, 인가
(auth.access 스코프), onPostAuth"] + G --> J["검증, 핸들러,
onPreResponse"] + J --> L["response(request, h)
선택"] + L --> M[응답 전송] + + class D,F,L highlight +``` + +`payload`와 `response`는 `authenticate`가 통과시킨 요청에서만 실행됩니다. + +스킴을 직접 작성할 수도 있고, `@hapi/basic`이나 `@hapi/cookie` 같은 hapi 인증 플러그인을 사용할 수도 있습니다. 다음은 요청 헤더에서 API 토큰을 읽는 완전한 스킴입니다. + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +import type { ServerAuthScheme } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface UserCredentials { + id: string; + name: string; + } +} + +interface TokenOptions { + tokens: Map; +} + +const tokenScheme: ServerAuthScheme = (server, options) => { + return { + authenticate: (request, h) => { + const token = request.headers['x-api-token']; + if (typeof token !== 'string') { + throw Boom.unauthorized(null, 'token'); + } + + const user = options?.tokens.get(token); + if (!user) { + throw Boom.unauthorized('Invalid token', 'token'); + } + + return h.authenticated({ credentials: { user }, artifacts: { token } }); + }, + response: (request, h) => { + if (!Boom.isBoom(request.response)) { + request.response.header('x-authenticated-user', request.auth.credentials.user?.id ?? ''); + } + + return h.continue; + }, + }; +}; + +const server = Hapi.server({ port: 4000 }); + +server.auth.scheme('token', tokenScheme); +server.auth.strategy('api', 'token', { + tokens: new Map([['a1b2c3', { id: '2133d32a', name: 'John Doe' }]]), +}); + +server.route({ + method: 'GET', + path: '/me', + options: { auth: 'api' }, + handler: (request, h) => { + return request.auth.credentials.user; }, +}); + +await server.start(); +``` + +`ServerAuthScheme`는 스킴이 받는 `options`의 타입을 지정합니다. 토큰을 일반 객체가 아닌 `Map`에 담은 데는 이유가 있습니다. 클라이언트가 보낸 키로 일반 객체를 조회하면 상속된 속성까지 찾기 때문에, `constructor`나 `toString`이라는 토큰도 인증을 통과하게 됩니다. `declare module` 블록은 자격 증명에 무엇이 들어 있는지 TypeScript에 알려 줍니다. hapi는 확장용으로 빈 `UserCredentials` 인터페이스를 내보내며, 이렇게 확장하면 모든 핸들러에서 `request.auth.credentials.user`에 타입이 붙습니다. + +### authenticate + +`authenticate` 메서드의 시그니처는 `function (request, h)`이며, 스킴에서 유일한 _필수_ 메서드입니다. `request`는 라우트 핸들러가 받는 것과 같은 [요청 객체](/api#request)이고, `h`는 표준 [응답 툴킷](/api#response-toolkit)입니다. + +`authenticate`는 세 가지 방식 중 하나로 끝나며, 각각 다른 결과로 이어집니다. + +```mermaid +flowchart TD + A["authenticate(request, h)"] --> OK["성공
h.authenticated()"] + A --> MISS["자격 증명 없음
메시지 없이
스킴 이름만 담은 에러"] + A --> BAD["잘못된 자격 증명
그 밖의 모든 에러"] + OK --> OK2["핸들러가
request.auth.credentials를 받음"] + MISS --> MISS2["다음 전략으로. 남은 것이 없으면
라우트의 모드가 결정"] + BAD --> BAD2["다른 전략은 시도하지 않음.
라우트의 모드가 결정"] + + class OK,MISS,BAD highlight +``` + +- **성공:** `h.authenticated({ credentials, artifacts })`를 반환합니다. `credentials`는 인증된 사용자를 나타냅니다. `artifacts`는 선택 사항이며 원본 토큰처럼 그 밖의 인증 데이터를 담습니다. 둘 다 나중에 `request.auth.credentials`와 `request.auth.artifacts`로 사용할 수 있습니다. +- **자격 증명 없음:** `Boom.unauthorized(null, 'token')`처럼 메시지 없이 스킴 이름만 담은 에러를 던집니다. hapi는 이 요청에 해당 스킴의 자격 증명이 없다고 보고 라우트의 다음 전략으로 넘어가며, 스킴 이름은 `WWW-Authenticate` 헤더에 들어갑니다. +- **잘못된 자격 증명:** `Boom.unauthorized('Invalid token', 'token')`처럼 그 밖의 에러를 던집니다. hapi는 다른 전략을 더 시도하지 않습니다. 메시지 _와_ 스킴 이름이 모두 없는 `Boom.unauthorized()`도 잘못된 자격 증명으로 취급됩니다. + +실패한 뒤 어떻게 되는지는 라우트의 [`mode`](#mode)에 따라 달라집니다. + +에러를 던지는 대신 `h.unauthenticated(error, data)`를 반환할 수도 있습니다. `error`를 던지는 것과 같이 동작하지만, `data`로 실패한 요청에 `credentials`와 `artifacts`를 붙일 수 있고, `'try'` 모드의 라우트에서 이 값을 읽을 수 있습니다. `data`가 없으면 `return h.unauthenticated(error)`와 `throw error`는 같습니다. 전체 규칙은 [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme)을 참고하세요. 이런 에러는 [boom](/module/boom)으로 만드는 것을 권장합니다. + +### payload + +`payload` 메서드의 시그니처는 `function (request, h)`입니다. 페이로드가 파싱된 뒤에 실행되므로, 요청 본문으로 계산한 서명처럼 페이로드 자체를 검사할 수 있습니다. + +페이로드가 통과하면 `h.continue`를 반환하고, 실패하면 에러를 던집니다. 라우트는 [`payload` 라우트 옵션](#route)으로 페이로드 인증을 켭니다. 항상 페이로드 인증이 필요한 스킴은 메서드와 함께 `options: { payload: true }`를 반환할 수 있으며, 그러면 라우트에서 이를 끌 수 없습니다. + +### response + +`response` 메서드의 시그니처는 `function (request, h)`입니다. `onPreResponse` 다음, 응답 헤더가 기록되기 직전에 실행되므로 `request.response`에 인증 관련 헤더를 추가할 수 있습니다. 위의 토큰 스킴은 이 메서드로 `x-authenticated-user` 헤더를 추가합니다. + +`response`가 실행될 시점에는 hapi가 이미 모든 에러를 응답 객체로 바꿔 두었으므로 에러 응답에도 헤더가 붙습니다. `Boom.isBoom()` 검사는 `request.response`를 응답 객체로 좁혀 주므로 `.header()`가 타입 검사를 통과합니다. 작업이 끝나면 `h.continue`를 반환합니다. 실패시키려면 에러를 던집니다. + +## 전략 + +스킴은 `server.auth.strategy(name, scheme, [options])`로 전략을 만들기 전까지는 아무 일도 하지 않습니다. + +- `name`은 전략의 이름입니다. 라우트는 이 이름으로 전략을 선택합니다. +- `scheme`은 등록된 스킴의 이름입니다. +- `options`는 스킴 함수의 `options` 인자로 전달됩니다. + +스킴 하나로 각자 설정이 다른 전략을 여러 개 만들 수 있습니다. + +```ts +server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, +}); + +server.auth.strategy('admin-session', 'cookie', { + cookie: { + name: 'sid-admin', + password: 'Q7v!kW2#pL9@zR4$mT6^xB1&nD8*cF3%', + isSecure: false, + }, + redirectTo: '/admin/login', + requestDecoratorName: 'adminCookieAuth', +}); +``` + +`@hapi/cookie` 전략은 각각 `request.cookieAuth` 헬퍼를 추가하는데, hapi는 같은 요청 데코레이션을 두 번 추가하지 않습니다. 그래서 두 번째 전략은 `requestDecoratorName`으로 헬퍼에 다른 이름을 붙여야 합니다. 이 전략의 라우트는 `request.adminCookieAuth.set()`을 호출합니다. `@types/hapi__cookie` 패키지([cookie](#cookie) 참고)는 `cookieAuth`의 타입만 제공하므로, TypeScript에서는 이름을 바꾼 헬퍼도 선언해 주어야 합니다. + +```ts +declare module '@hapi/hapi' { + interface Request { + adminCookieAuth: Request['cookieAuth']; + } +} +``` + +## 기본 전략 + +`server.auth.default()`는 라우트가 따로 지정하지 않는 한 모든 라우트가 사용할 전략을 정합니다. 전략 이름이나, 라우트의 [auth 옵션](#route)과 같은 형식의 객체를 받습니다. 기본값은 `auth` 옵션에 전략을 지정하지 않은 모든 라우트에 적용되며, `server.auth.default()`를 호출하기 _전에_ 추가한 라우트도 포함됩니다. 예외는 `auth`에 `mode`나 `access`만 설정한 라우트입니다. hapi는 라우트를 추가하는 시점에 이를 기본값과 병합하므로, 이 경우에는 `server.auth.default()`를 먼저 호출하세요. + +```ts +server.auth.default('session'); + +server.route({ + method: 'GET', + path: '/account', + handler: (request, h) => 'protected by the session strategy', +}); + +server.route({ + method: 'GET', + path: '/health', + options: { auth: false }, + handler: (request, h) => 'public', +}); +``` + +## 라우트 설정 + +라우트는 `options.auth` 속성으로 인증을 설정합니다. + +```ts +server.route({ + method: 'GET', + path: '/feed', + options: { + auth: { + mode: 'try', + strategies: ['session', 'api'], + }, + }, + handler: (request, h) => { + if (!request.auth.isAuthenticated) { + return 'public feed'; + } + + return `personal feed, signed in with ${request.auth.strategy}`; + }, +}); +``` + +`options.auth`에는 다음을 지정할 수 있습니다. + +- `false`: 라우트의 인증을 끕니다. +- `'session'` 같은 전략 이름. +- `mode`, `strategy` 또는 `strategies`, `payload`를 담은 객체. + +### mode + +`mode`는 자격 증명이 없거나 잘못된 요청을 어떻게 처리할지 정합니다. + +| `mode` | 자격 증명 없음 | 잘못된 자격 증명 | 유효한 자격 증명 | +| --------------------- | ----------------------- | ----------------------- | ---------------- | +| `'required'` (기본값) | 401 | 401 | 핸들러 실행 | +| `'optional'` | 핸들러 실행, 인증 안 됨 | 401 | 핸들러 실행 | +| `'try'` | 핸들러 실행, 인증 안 됨 | 핸들러 실행, 인증 안 됨 | 핸들러 실행 | + +"자격 증명 없음"과 "잘못된 자격 증명"은 [`authenticate` 실패](#authenticate)의 두 가지 유형입니다. 실패한 뒤 핸들러가 실행되면 `request.auth.isAuthenticated`는 `false`이고, `request.auth.error`에 이유가 담깁니다. 어떤 전략도 자격 증명을 찾지 못했으면 `Missing authentication`이, 전략이 자격 증명을 거부했으면 그 전략이 던진 에러가 들어갑니다. + +### strategy와 strategies + +전략 하나는 `strategy: 'session'`으로, 여러 개는 `strategies: ['session', 'api']`로 지정합니다. hapi는 순서대로 시도하며, 각 전략이 자격 증명 없음을 보고하는 동안에만 다음 전략으로 넘어갑니다. + +```mermaid +flowchart TD + R[요청] --> S1{"전략 1
session"} + S1 -->|인증됨| H[핸들러] + S1 -->|없음| S2{"전략 2
api"} + S1 -->|잘못됨| E["실패:
라우트의 모드가 결정"] + S2 -->|인증됨| H + S2 -->|잘못됨 또는 없음| E +``` + +### payload + +`payload`는 라우트에서 스킴의 [`payload` 메서드](#payload)를 켭니다. + +- `false`: 페이로드를 인증하지 않습니다. +- `'required'` 또는 `true`: 페이로드가 반드시 인증되어야 합니다. +- `'optional'`: 클라이언트가 페이로드 인증 정보를 보낸 경우에만 검사합니다. + +스킴에 `payload` 메서드가 있는 전략에서만 동작합니다. + +## 스코프 + +인증은 요청을 보낸 사람이 _누구인지_ 결정합니다. 스코프는 그 사람이 _무엇을_ 할 수 있는지 결정합니다. 전략은 `credentials.scope`에 스코프 문자열 목록을 넣고, 라우트는 허용할 스코프를 `options.auth.access.scope`에 나열하며, hapi는 인가 단계에서 둘을 비교합니다. 인가 단계는 `onCredentials` 다음, 핸들러가 실행되기 전입니다. 일치하는 스코프가 없는 요청은 `403 Insufficient scope`를 받고, 핸들러는 실행되지 않습니다. + +스코프는 보통 전략의 `validate` 함수에서 옵니다. [basic](#basic) 예제의 사용자마다 `roles` 배열이 있다면 다음과 같습니다. + +```ts +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); + if (!user || !(await Bcrypt.compare(password, user.password))) { + return { isValid: false, credentials: null }; + } + + return { isValid: true, credentials: { id: user.id, name: user.name, scope: user.roles } }; }; +``` + +역할 정보가 다른 곳에 있다면 `onCredentials` 훅에서 인증 후에 추가할 수 있습니다. [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks#credentials)을 참고하세요. + +그런 다음 라우트에서 허용할 스코프를 선언합니다. + +```ts +server.route({ + method: 'GET', + path: '/reports', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'analyst'] } } }, + handler: (request, h) => 'any admin or analyst', +}); + +server.route({ + method: 'DELETE', + path: '/users/{id}', + options: { auth: { strategy: 'simple', access: { scope: ['+admin', '!suspended'] } } }, + handler: (request, h) => 'an admin who is not suspended', +}); + +server.route({ + method: 'PUT', + path: '/users/{id}/profile', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'user-{params.id}'] } } }, + handler: (request, h) => 'an admin, or the user whose id is in the path', +}); +``` + +| 라우트 스코프 | `credentials.scope`의 조건 | +| ---------------------- | -------------------------------------------------------------------------------------------------- | +| `'admin'` | `admin`을 포함해야 함 | +| `['admin', 'analyst']` | 둘 중 하나 이상을 포함해야 함 | +| `'+admin'` | 목록의 다른 항목과 관계없이 `admin`을 포함해야 함 | +| `'!suspended'` | `suspended`를 포함하지 않아야 함 | +| `'user-{params.id}'` | `/users/42` 요청이라면 `user-42`를 포함해야 함. `query`, `payload`, `credentials`도 사용할 수 있음 | + +놓치기 쉬운 스코프 규칙은 다음과 같습니다. + +- 스코프는 인증된 요청에서만 검사합니다. `'optional'`이나 `'try'` 모드의 라우트에서는 자격 증명이 없는 요청도 핸들러에 도달하며, 이때 `request.auth.isAuthorized`는 `false`입니다. 일부 사용자만 할 수 있는 작업을 하기 전에 이 값을 확인하세요. +- `access.entity`는 라우트를 사용자 또는 애플리케이션으로 제한합니다. `'user'`는 `credentials.user`가 설정되어 있어야 하고, `'app'`은 없어야 합니다. 같은 전략이 사람과 기계 클라이언트를 모두 인증할 때 사용하세요. +- `access`는 규칙의 목록일 수 있으며, 요청은 그중 하나만 만족하면 됩니다. +- `server.auth.default()`도 같은 `access` 옵션을 받으므로, 스코프를 모든 라우트에 적용할 수 있습니다. + +전체 규칙은 [`route.options.auth.access`](/api#route-options-auth-access)를 참고하세요. + +## basic -const validate = async (request, username, password) => { - const user = users[username]; +처음 살펴볼 `scheme`은 [@hapi/basic](/module/basic) 플러그인입니다. 이름 그대로 `@hapi/basic` 플러그인은 basic 인증으로 사용자를 검증합니다. 다음은 `@hapi/basic`을 설정하는 예입니다. + +```ts +import Basic from '@hapi/basic'; +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; + +const users = new Map([ + [ + 'john', + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, + ], +]); + +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); if (!user) { return { credentials: null, isValid: false }; } @@ -44,7 +377,7 @@ const validate = async (request, username, password) => { const start = async () => { const server = Hapi.server({ port: 4000 }); - await server.register(require('hapi-auth-basic')); + await server.register(Basic); server.auth.strategy('simple', 'basic', { validate }); @@ -67,91 +400,262 @@ const start = async () => { start(); ``` -먼저 이 예제에서 간단한 객체인 `users`라는 데이터베이스를 정의했습니다. 그런 다음 [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic)의 고유한 기능인 검증 함수를 정의하고 사용자가 유효한 자격 증명을 제공했는지 검증할 수 있습니다. +먼저 `users` 데이터베이스를 정의합니다. 이 예제에서는 사용자 이름을 키로 하는 `Map`이며, 그 이유는 [스킴](#schemes)에서 설명했습니다. 다음으로 검증 함수를 정의합니다. 이는 [@hapi/basic](/module/basic) 전용 기능으로, 사용자가 유효한 자격 증명을 제공했는지 확인할 수 있게 해 줍니다. `Basic.Validate`로 타입을 지정하면 TypeScript가 `request`, `username`, `password`를 추론하고 반환하는 객체도 검사합니다. 이 검증 함수에서는 `Bcrypt`로 사용자가 입력한 비밀번호와 데이터베이스에 저장된 해시를 비교합니다. -다음으로 플러그인을 등록하면 `basic` 이름을 가진 scheme을 생성합니다. 플러그인 안에서 [server.auth.scheme()](/api#serverauthschemename-scheme)을 통해 이뤄집니다. +그런 다음 플러그인을 등록하면 `basic`이라는 이름의 스킴이 만들어집니다. 이 작업은 플러그인 내부에서 [server.auth.스킴()](/api#server-auth-scheme-name-scheme)으로 이루어집니다. -플러그인이 등록되면 `basic`이라는 scheme을 참조하는 `simple` 이름의 strategy를 생성하기 위해 [server.auth.strategy()](/api#serverauthstrategyname-scheme-mode-options)를 사용합니다. scheme에 전달되고 행동을 설정할 수 있는 옵션 객체를 전달합니다. +플러그인을 등록한 뒤에는 [server.auth.전략()](/api#server-auth-strategy-name-scheme-options)로 `basic` 스킴을 참조하는 `simple`이라는 전략을 만듭니다. 이때 옵션 객체도 전달하는데, 이 객체는 스킴에 전달되어 동작을 설정하는 데 쓰입니다. -마지막으로 route에 인증에 사용할 strategy 이름인 `simple`을 알려줍니다. +마지막으로 라우트가 인증에 `simple` 전략을 사용하도록 지정합니다. -## Schemes +## cookie -`scheme`은 `function (server, options)` 형태의 메소드 입니다. `server` 인자는 scheme이 추가될 서버를 참조하고 `options` 인자는 이 scheme을 사용하는 strategy가 등록될 때 제공되는 설정 객체입니다. +[@hapi/cookie](/module/cookie)는 사용자가 인증되면 세션을 사용자 브라우저의 암호화된 쿠키에 저장하는 플러그인입니다. `cookie.ttl`이 없으면 쿠키는 브라우저를 닫을 때까지 유지됩니다. 브라우저 세션이 바뀌어도 로그인 상태를 유지하려면 `ttl`을 설정하세요. -이 메소드는 _최소한_ `authenticate` 키를 가진 객체를 반환해야 합니다. 사용될 수 있는 다른 선택적인 메소드는 `payload`와 `response`입니다. +이 예제에서 홈 라우트 "/"는 제한되어 있어 로그인한 사용자만 접근할 수 있습니다. 브라우저는 로그인 폼으로 리다이렉트되고, 사용자 이름과 비밀번호를 POST로 보낸 다음, 세션 쿠키를 가지고 홈 페이지에 도달합니다. -### `authenticate` +```mermaid +sequenceDiagram + participant B as 브라우저 + participant S as hapi 서버 + B->>S: GET / + Note over S: 세션 쿠키가 없으므로 session 전략이 실패 + S-->>B: 302 /login으로 리다이렉트 (redirectTo) + B->>S: GET /login + S-->>B: 200 로그인 폼 (auth: false) + B->>S: 사용자 이름과 비밀번호로 POST /login + Note over S: 'try' 모드: 세션 없이 핸들러가 실행되고
Bcrypt로 비밀번호를 검사 + S-->>B: 302 /로 리다이렉트, Set-Cookie sid-example (암호화된 { id }) + B->>S: Cookie sid-example과 함께 GET / + Note over S: validate(request, session)가 session.id로 계정을 찾음 + S-->>B: 200 Welcome to the restricted home page! +``` -`authenticate` 메소드는 `function (request, reply)` 모양을 가지고 scheme에서 유일한 _필수_ 메소드입니다. +```ts +import Bcrypt from 'bcrypt'; +import Cookie from '@hapi/cookie'; +import Hapi from '@hapi/hapi'; -`request`는 서버에서 생성된 `request` 객체입니다. route 처리기에서 사용 가능한 것과 같은 객체이고 [API reference](/api#request-object)에 문서로 만들어 져 있습니다. +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; -`h`는 표준 hapi [응답 도구](https://hapijs.com/api#response-toolkit)입니다. +const start = async () => { + const server = Hapi.server({ port: 4000 }); -인증이 성공하면 `h.authenticated({ credentials, artifacts })`를 호출하고 반환하여야 합니다. `credentials` 속성은 인증된 사용자를 (또는 사용자가 인증을 시도한 자격 증명) 표현하는 객체입니다. 또한 사용자의 자격 증명에 포함되지 않는 인증 관련 데이터들을 포함하는 `artifacts` 키를 가질 수 있습니다. + await server.register(Cookie); -`credentials`와 `artfacts` 속성은 뒤에서 (예를 들면 경로 처리기에서) `request.auth` 객체의 부분으로 접근할 수 있습니다. + server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, + redirectTo: '/login', + validate: async (request, session) => { + if (!session || !('id' in session)) { + return { isValid: false }; + } + + const account = users.find((user) => user.id === session.id); + if (!account) { + return { isValid: false }; + } + + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -만약 인증이 실패하면, 에러를 던지거나 인증 에러인 `error` 객체이고 `credentials`와 `artifacts`를 포함하는 선택적인 `data` 객체로 `h.unauthenticated(error, [data])`를 호출하고 반환할 수 있습니다. 전달된 에러의 세부 사항은 행동에 영향을 줄 것입니다. 자세한 내용은 API 문서 [`server.auth.scheme(name, scheme)`](https://hapijs.com/api#-serverauthschemename-scheme)를 참조하세요. 에러에 대해서는 [boom](https://github.com/hapijs/boom)을 사용하는 것을 추천합니다. + server.auth.default('session'); -### `payload` + server.route([ + { + method: 'GET', + path: '/', + handler: function (request, h) { + return 'Welcome to the restricted home page!'; + }, + }, + { + method: 'GET', + path: '/login', + handler: function (request, h) { + return ` + + Login page + + +

Please Log In

+
+ Username:
+ Password:
+
+ + `; + }, + options: { + auth: false, + }, + }, + ]); -`payload` 메소드는 `function (request, h)` 형태로 되어 있습니다. + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); -다시 표준 hapi 응답 도구가 여기에 있습니다. 에러를 던져 실패를 알리기 위해서는 다시 [boom](https://github.com/hapijs/boom)을 사용하는 것을 추천합니다. + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.redirect('/login'); + } -인증 성공을 알리려면 `h.continue`를 반환하세요. + request.cookieAuth.set({ id: account.id }); -### `response` + return h.redirect('/'); + }, + options: { + auth: { + mode: 'try', + }, + }, + }); + + await server.start(); + + console.log('server running at: ' + server.info.uri); +}; + +start(); +``` -`response` 메소드 역시 `function (request, h)` 형채로 되어 있으며 표준 응답 도구를 활용합니다. +`@hapi/cookie`는 자체 타입을 제공하지 않습니다. TypeScript를 사용한다면 `npm install --save-dev @types/hapi__cookie`로 타입을 설치하세요. 이 패키지는 hapi의 `Request` 타입에 `request.cookieAuth`를 추가하고 `cookie` 전략 옵션의 타입을 지정합니다. `validate`에 전달되는 세션은 일반 `object` 타입이므로, `'id' in session` 검사가 있어야 TypeScript가 `session.id`를 읽을 수 있습니다. `POST /login` 라우트는 `server.route()`의 타입 인자로 폼 필드를 선언합니다. 이는 타입일 뿐이므로, 런타임에 강제하려면 [페이로드 검증](/tutorials/ko_KR/validation)을 추가하세요. -이 메소드는 응답이 사용자에게 보내지기 전에 추가 헤더로 응답 객체를 (`request.response`) 꾸미기 위한 것입니다. +먼저 `server.register`로 `@hapi/cookie` 플러그인을 등록하면 `cookie` 스킴이 추가됩니다. 그런 다음 `server.auth.strategy`로 이 스킴에서 `session` 전략을 만들고, 옵션 세 가지를 전달합니다. -한번 꾸미기가 완료되면 `h.continue`를 반환해야 합니다. 그리고 응답이 보내질 것입니다. +- `cookie`는 쿠키 자체를 설정합니다. `name`은 쿠키 이름인 `sid-example`입니다. `password`는 쿠키를 암호화하며 32자 이상이어야 합니다. `isSecure: false`는 쿠키가 일반 HTTP로도 전송되게 하는데, 개발 환경에서는 괜찮지만 프로덕션에서는 기본값인 `true`로 두세요. +- `redirectTo`는 인증되지 않은 사용자가 보호된 라우트를 요청했을 때 서버가 보낼 위치입니다. +- `validate`는 쿠키가 포함된 모든 요청에서 실행되어 세션이 아직 유효한지 확인합니다. 여기서는 쿠키에 저장된 `id`로 계정을 조회하므로, 삭제된 계정은 다음 요청에서 로그아웃됩니다. -만약 에러가 발생하면 [boom](https://github.com/hapijs/boom)으로 권장되는 에러를 대신 던져야 합니다. +`server.auth.default('session')`은 모든 라우트를 `session` 전략으로 보호합니다. `GET /login`은 `auth: false`로 이를 해제하므로 누구나 폼을 볼 수 있습니다. -### 등록하기 +`POST /login`은 `mode: 'try'`를 사용하므로, 사용자에게 아직 세션이 없어도 핸들러가 실행됩니다. 핸들러는 `username`으로 계정을 찾고, `Bcrypt`로 제출된 비밀번호와 저장된 해시를 비교합니다. 둘 중 하나라도 실패하면 사용자는 로그인 페이지로 돌아갑니다. 둘 다 통과하면 `request.cookieAuth.set()`이 암호화된 쿠키에 `{ id }`를 저장하고, 사용자는 홈 페이지로 리다이렉트됩니다. -scheme을 등록하려면 `server.auth.scheme(name, scheme)`을 사용해야 합니다. `name` 인자는 이 특정 scheme을 식별하는 문자열이고, `scheme` 인자는 위에 설명한 메소드입니다. +## jwt -## Strategies +[@hapi/jwt](/module/jwt)는 `Authorization: Bearer ` 헤더에 JSON Web Token을 담은 요청을 인증합니다. `@hapi/cookie`와 마찬가지로 서버에 세션을 저장하지 않으며, 차이는 신원 정보가 전달되는 방식에 있습니다. + +| | `@hapi/cookie` | `@hapi/jwt` | +| --------- | ----------------------------------- | ------------------------------------------ | +| 전달 위치 | 브라우저가 붙여 보내는 쿠키 | 클라이언트가 설정하는 `Authorization` 헤더 | +| 내용 | 암호화됨: 클라이언트가 읽을 수 없음 | 서명됨: 토큰을 가진 누구나 읽을 수 있음 | +| 적합한 곳 | 자체 도메인의 브라우저 앱 | 모바일 앱, 다른 서버, SPA에서 호출하는 API | + +JWT 페이로드에는 절대 비밀 정보를 넣지 마세요. 서명은 변조를 막지만 읽는 것은 막지 못합니다. + +클라이언트는 한 번 로그인한 뒤, 모든 요청에 토큰을 함께 보냅니다. + +```mermaid +sequenceDiagram + participant C as 클라이언트 + participant S as hapi 서버 + C->>S: 사용자 이름과 비밀번호로 POST /login + Note over S: auth: false, Bcrypt로 비밀번호를 검사 + S-->>C: 200 서버의 secret으로 서명한 { token } + C->>S: Authorization: Bearer token과 함께 GET /me + Note over S: jwt 전략이 서명, aud, iss, 토큰 나이를 검사한 뒤
validate(artifacts)를 호출 + S-->>C: 200 { id, name } +``` + +```ts +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; +import Jwt from '@hapi/jwt'; + +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; -scheme을 등록했으면 scheme을 사용해야 합니다. 이때 strategy가 들어옵니다. +const secret = 'hR7#mX2!vQ9@kL4$wP8^zT1&nB6*cJ3%'; -앞서 언급했듯이 strategy는 본래 미리 설정된 scheme의 사본입니다. +const start = async () => { + const server = Hapi.server({ port: 4000 }); -strategy를 등록하려면 먼저 등록된 scheme이 있어야 합니다. scheme 등록이 완료되었으면 strategy를 등록하기 위해 `server.auth.strategy(name, scheme, [options])`를 사용합니다. + await server.register(Jwt); -`name` 인자는 문자열이어야 하고 이후에 특정 strategy를 식별하기 위해 사용됩니다. `scheme` 또한 문자열이고 strategy로 인스턴스가 되는 scheme의 이름입니다. + server.auth.strategy('bearer', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:my-api', + iss: 'urn:issuer:my-api', + sub: false, + maxAgeSec: 4 * 60 * 60, + }, + validate: async (artifacts, request, h) => { + const account = users.find((user) => user.id === artifacts.decoded.payload.id); + if (!account) { + return { isValid: false }; + } -### Options + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -마지막 선택 인자는 명명한 scheme에 직접 전달되는 `options`입니다.2 + server.auth.default('bearer'); -### 기본 strategy 설정 + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + options: { auth: false }, + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); -`server.auth.default()`을 사용하여 기본 strategy를 설정할 수 있습니다. + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.response({ error: 'Invalid username or password' }).code(401); + } -이 메소드는 하나의 인자를 받습니다. 이 인자는 기본으로 사용할 strategy의 이름이거나 경로 처리기의 [auth options](#경로-설정)과 같은 형식의 객체입니다. + const token = Jwt.token.generate( + { aud: 'urn:audience:my-api', iss: 'urn:issuer:my-api', id: account.id }, + secret, + ); -## 경로 설정 + return { token }; + }, + }); -`options.auth` 인자로 경로에서 인증을 설정할 수 있습니다. 만약 `false`로 설정되어 있다면 그 경로에서 인증은 비활성화 됩니다. + server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return request.auth.credentials; + }, + }); -사용할 strategy의 이름의 문자열 또는 `mode`, `strategies`, `payload` 인자가 있는 객체로 설정할 수 있습니다. + await server.start(); -`mode` 인자는 `'required'`, `'optional'` 또는 `'try'`로 설정할 수 있습니다. 그리고 strategy를 등록할 때와 동일하게 동작합니다. + console.log('server running at: ' + server.info.uri); +}; -`'required'`로 설정하면 경로에 접근하기 위해선 사용자는 인증되어야 합니다. 인증이 유효하지 않다면 에러를 받을 것입니다. -`mode`가 `'optional'`로 설정되면 strategy는 여전히 경로에 적용될 것입니다. 그러나 이 경우 사용자가 인증 받을 필요는 _없습니다_. 인증 데이터는 선택적입니다. 제공된다면 유효해야 합니다. +start(); +``` -마지막 `mode` 설정은 `'try'`입니다. `'try'`와 `'optional'`의 차이점은 `'try'`에서 유효하지 않은 인증도 받아들여지고 사용자는 경로 처리기에 도달할 것입니다. +플러그인을 등록하면 `jwt` 스킴이 추가되고, 이 스킴으로 `bearer` 전략을 만듭니다. -하나의 strategy를 지정할 때 strategy 이름의 문자열로 `strategy` 속성을 설정할 수 있습니다. 하나 이상의 strategy를 지정한다면 인자 이름은 `strategies`가 돼야 하고 시도할 각 strategy의 이름 문자열의 배열이어야 합니다. strategy는 성공할 때 까지 하나씩 시도되거나 모두 실패합니다. +- `keys`는 토큰 서명에 쓰는 secret입니다. 같은 서버가 토큰을 발급하고 검사한다면 공유 secret으로 충분합니다. 대신 ID 제공자(identity provider)가 발급한 토큰을 받으려면 `keys`가 제공자의 공개 키를 가리키게 할 수 있습니다. [@hapi/jwt API](/module/jwt)의 `keys` 옵션을 참고하세요. +- `verify`는 토큰이 가져야 할 클레임을 나열합니다. `aud`(audience)와 `iss`(issuer)는 토큰을 발급할 때 사용한 값과 일치해야 합니다. `maxAgeSec`는 `iat`(issued at)가 4시간보다 오래된 토큰을 거부합니다. 이 예제의 토큰에는 `exp`가 없습니다. 여러분의 토큰에 있다면 `exp`와 `nbf`는 기본으로 검사됩니다. +- `validate`는 서명과 클레임 검사를 통과한 뒤에만 실행됩니다. 디코딩된 토큰을 `artifacts.decoded.payload`로 받고, `request.auth.credentials`에 무엇을 넣을지 결정합니다. 여기서는 계정이 아직 존재하는지도 확인합니다. 토큰은 계정이 삭제되어도 만료될 때까지 유효하기 때문입니다. -마지막으로 `payload` 인자는 payload는 인증되지 않음을 가리키는 `false`, _반드시_ 인증돼야 하는 `'required'` 또는 `true`, 클라이언트가 payload 인증 정보를 포함한다면 반드시 유효해야 하는 `'optional'`로 설정될 수 있습니다. +`POST /login`은 `auth: false`로 인증을 해제하고, 비밀번호를 검사한 뒤, 전략이 검증하는 것과 같은 `aud`와 `iss`로 `Jwt.token.generate()`가 만든 토큰을 반환합니다. 그러면 클라이언트는 `Authorization: Bearer `과 함께 `GET /me`를 호출합니다. hapi는 토큰에 어떤 클레임을 넣었는지 알 수 없으므로 `artifacts.decoded.payload`에는 타입이 없습니다. `validate`에서 클레임을 검사하고 타입이 지정된 자격 증명을 만드세요. -`payload` 인자는 scheme에서 `payload` 메소드를 지원하는 strategy에서만 사용 가능합니다. +그 밖의 hapi 인증 플러그인에 대해서는 [플러그인 섹션](/plugins)을 참고하세요. diff --git a/docs/tutorials/pt_BR/auth.md b/docs/tutorials/pt_BR/auth.md index 9b1a3401..d3b3500a 100644 --- a/docs/tutorials/pt_BR/auth.md +++ b/docs/tutorials/pt_BR/auth.md @@ -1,176 +1,661 @@ --- -title: Authentication +title: Autenticação --- -## Autenticação +# Autenticação -_Este tutorial é compatível com hapi v16_ +_Este tutorial é compatível com hapi v21 e versões mais recentes_ -A autenticação no hapi tem como base um conceito de esquemas (`schemes`) e estratégias (`strategies`). +## Visão geral -Pense num esquema como um tipo geral de autenticação, como "basic" ou "digest". Por outro lado, uma estratégia é uma instância pré-configurada e nomeada de um esquema. +A maioria das aplicações web modernas usa alguma forma de autenticação. No hapi, a autenticação é construída a partir de duas peças, `schemes` e `strategies`: -Para começar, der uma olhada num exemplo de como utilizar [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic): +- Um `scheme` é uma forma genérica de autenticar, como "HTTP basic auth" ou "cookie de sessão". Plugins como `@hapi/basic` e `@hapi/cookie` registram, cada um, um scheme. +- Uma `strategy` é um scheme com um nome e uma configuração, como "basic auth, verificada contra a minha tabela de usuários". As rotas se referem às strategies pelo nome. -```javascript -'use strict'; +Com `@hapi/basic`, o plugin registra um scheme, você configura uma strategy a partir dele e uma rota usa essa strategy: -const Bcrypt = require('bcrypt'); -const Hapi = require('hapi'); -const Basic = require('hapi-auth-basic'); +```ts +// Registers the 'basic' scheme. +await server.register(Basic); -const server = new Hapi.Server(); -server.connection({ port: 3000 }); +server.auth.strategy('simple', 'basic', { validate }); -const users = { - john: { - username: 'john', - password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secreto' - name: 'John Doe', - id: '2133d32a', +server.route({ + method: 'GET', + path: '/', + options: { auth: 'simple' }, + handler: (request, h) => 'welcome', +}); +``` + +## Schemes + +Um `scheme` é uma função com a assinatura `function (server, options)`. `server` é o servidor ao qual o scheme é adicionado, e `options` é o objeto de configuração passado quando uma strategy é criada a partir desse scheme. A função retorna um objeto de scheme, e o hapi chama os métodos desse objeto em três pontos do [ciclo de vida da requisição](/api#request-lifecycle): + +```mermaid +flowchart TD + A["onRequest, busca da rota,
parsing de cookies, onPreAuth"] --> D["authenticate(request, h)
obrigatório"] + D --> E[parsing do payload] + E --> F["payload(request, h)
opcional"] + F --> G["onCredentials, autorização
(scopes de auth.access), onPostAuth"] + G --> J["validação, handler,
onPreResponse"] + J --> L["response(request, h)
opcional"] + L --> M[resposta enviada] + + class D,F,L highlight +``` + +`payload` e `response` só rodam para requisições que `authenticate` aceitou. + +Você pode escrever seu próprio scheme ou usar um dos plugins de autenticação do hapi, como `@hapi/basic` ou `@hapi/cookie`. Aqui está um scheme completo que lê um token de API de um header da requisição: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +import type { ServerAuthScheme } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface UserCredentials { + id: string; + name: string; + } +} + +interface TokenOptions { + tokens: Map; +} + +const tokenScheme: ServerAuthScheme = (server, options) => { + return { + authenticate: (request, h) => { + const token = request.headers['x-api-token']; + if (typeof token !== 'string') { + throw Boom.unauthorized(null, 'token'); + } + + const user = options?.tokens.get(token); + if (!user) { + throw Boom.unauthorized('Invalid token', 'token'); + } + + return h.authenticated({ credentials: { user }, artifacts: { token } }); + }, + response: (request, h) => { + if (!Boom.isBoom(request.response)) { + request.response.header('x-authenticated-user', request.auth.credentials.user?.id ?? ''); + } + + return h.continue; + }, + }; +}; + +const server = Hapi.server({ port: 4000 }); + +server.auth.scheme('token', tokenScheme); +server.auth.strategy('api', 'token', { + tokens: new Map([['a1b2c3', { id: '2133d32a', name: 'John Doe' }]]), +}); + +server.route({ + method: 'GET', + path: '/me', + options: { auth: 'api' }, + handler: (request, h) => { + return request.auth.credentials.user; + }, +}); + +await server.start(); +``` + +`ServerAuthScheme` tipa as `options` que o scheme recebe. Os tokens ficam em um `Map`, e não em um objeto comum, de propósito: buscar uma chave enviada pelo cliente em um objeto comum também encontra propriedades herdadas, então um token `constructor` ou `toString` seria autenticado. O bloco `declare module` diz ao TypeScript o que suas credenciais contêm: o hapi exporta uma interface `UserCredentials` vazia para você estender, e depois disso `request.auth.credentials.user` fica tipado em todos os handlers. + +### authenticate + +O método `authenticate` tem a assinatura `function (request, h)` e é o único método _obrigatório_ de um scheme. `request` é o mesmo [objeto de requisição](/api#request) que um handler de rota recebe, e `h` é o [response toolkit](/api#response-toolkit) padrão. + +`authenticate` termina de uma de três formas, e cada uma leva a um lugar diferente: + +```mermaid +flowchart TD + A["authenticate(request, h)"] --> OK["sucesso
h.authenticated()"] + A --> MISS["credenciais ausentes
erro sem mensagem,
só com o nome do scheme"] + A --> BAD["credenciais inválidas
qualquer outro erro"] + OK --> OK2["o handler vê
request.auth.credentials"] + MISS --> MISS2["próxima strategy; se não sobrar nenhuma,
o mode da rota decide"] + BAD --> BAD2["nenhuma outra strategy é tentada;
o mode da rota decide"] + + class OK,MISS,BAD highlight +``` + +- **Sucesso:** retorne `h.authenticated({ credentials, artifacts })`. `credentials` representa o usuário autenticado. `artifacts` é opcional e guarda qualquer outro dado de autenticação, como o token bruto. Os dois ficam disponíveis depois como `request.auth.credentials` e `request.auth.artifacts`. +- **Credenciais ausentes:** lance um erro sem mensagem e com o nome do scheme, como `Boom.unauthorized(null, 'token')`. O hapi trata a requisição como se ela não trouxesse credenciais para esse scheme, então passa para a próxima strategy da rota, e o nome do scheme vai parar no header `WWW-Authenticate`. +- **Credenciais inválidas:** lance qualquer outro erro, como `Boom.unauthorized('Invalid token', 'token')`. O hapi para de tentar outras strategies. Um `Boom.unauthorized()` sem mensagem _e_ sem nome de scheme também conta como inválido. + +O que acontece depois de uma falha depende do [`mode`](#mode) da rota. + +Em vez de lançar o erro, você pode retornar `h.unauthenticated(error, data)`. O comportamento é o mesmo de lançar `error`, mas `data` permite anexar `credentials` e `artifacts` à requisição que falhou, e uma rota em mode `'try'` pode lê-los. Sem `data`, `return h.unauthenticated(error)` e `throw error` são equivalentes. Veja [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) para as regras completas. O [boom](/module/boom) é a forma recomendada de criar esses erros. + +### payload + +O método `payload` tem a assinatura `function (request, h)`. Ele roda depois que o payload é processado, então pode verificar o próprio payload, por exemplo uma assinatura calculada sobre o corpo da requisição. + +Retorne `h.continue` quando o payload passar e lance um erro quando falhar. As rotas ativam a autenticação do payload com a [opção de rota `payload`](#route). Um scheme que sempre precisa dela pode retornar `options: { payload: true }` junto com seus métodos, o que impede as rotas de desativá-la. + +### response + +O método `response` tem a assinatura `function (request, h)`. Ele roda depois de `onPreResponse`, logo antes de os headers da resposta serem escritos, então pode adicionar headers de autenticação a `request.response`. O scheme de token acima o usa para adicionar um header `x-authenticated-user`. + +Quando `response` roda, o hapi já transformou qualquer erro em um objeto de resposta, então as respostas de erro também recebem o header. A checagem `Boom.isBoom()` restringe `request.response` a um objeto de resposta, e assim `.header()` passa na checagem de tipos. Retorne `h.continue` ao terminar. Para falhar, lance um erro. + +## Strategies + +Um scheme não faz nada até você criar uma strategy a partir dele com `server.auth.strategy(name, scheme, [options])`: + +- `name` é o nome da strategy. As rotas o usam para escolher a strategy. +- `scheme` é o nome de um scheme registrado. +- `options` é passado à função do scheme como seu argumento `options`. + +Um mesmo scheme pode servir de base para várias strategies, cada uma com sua própria configuração: + +```ts +server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, +}); + +server.auth.strategy('admin-session', 'cookie', { + cookie: { + name: 'sid-admin', + password: 'Q7v!kW2#pL9@zR4$mT6^xB1&nD8*cF3%', + isSecure: false, }, + redirectTo: '/admin/login', + requestDecoratorName: 'adminCookieAuth', +}); +``` + +Cada strategy do `@hapi/cookie` adiciona um helper `request.cookieAuth`, e o hapi se recusa a adicionar a mesma decoração de request duas vezes, então a segunda strategy precisa de `requestDecoratorName` para dar outro nome ao seu helper. As rotas dela chamam `request.adminCookieAuth.set()`. O pacote `@types/hapi__cookie` (veja [cookie](#cookie)) só tipa `cookieAuth`, então em TypeScript declare também o helper renomeado: + +```ts +declare module '@hapi/hapi' { + interface Request { + adminCookieAuth: Request['cookieAuth']; + } +} +``` + +## Strategy padrão + +`server.auth.default()` define a strategy que toda rota usa, a menos que ela diga o contrário. Aceita o nome de uma strategy ou um objeto no mesmo formato das [opções de auth](#route) de uma rota. O padrão vale para toda rota cuja opção `auth` não nomeia uma strategy, inclusive rotas adicionadas _antes_ de `server.auth.default()` ser chamado. A exceção é uma rota cujo `auth` define apenas `mode` ou `access`: o hapi a mescla com o padrão no momento em que a rota é adicionada, então chame `server.auth.default()` primeiro. + +```ts +server.auth.default('session'); + +server.route({ + method: 'GET', + path: '/account', + handler: (request, h) => 'protected by the session strategy', +}); + +server.route({ + method: 'GET', + path: '/health', + options: { auth: false }, + handler: (request, h) => 'public', +}); +``` + +## Configuração da rota + +Uma rota define a autenticação com sua propriedade `options.auth`: + +```ts +server.route({ + method: 'GET', + path: '/feed', + options: { + auth: { + mode: 'try', + strategies: ['session', 'api'], + }, + }, + handler: (request, h) => { + if (!request.auth.isAuthenticated) { + return 'public feed'; + } + + return `personal feed, signed in with ${request.auth.strategy}`; + }, +}); +``` + +`options.auth` pode ser: + +- `false`, para desativar a autenticação na rota. +- O nome de uma strategy, como `'session'`. +- Um objeto com `mode`, `strategy` ou `strategies`, e `payload`. + +### mode + +`mode` decide o que acontece com uma requisição sem credenciais, ou com credenciais inválidas: + +| `mode` | Sem credenciais | Credenciais inválidas | Credenciais válidas | +| --------------------- | -------------------------------- | -------------------------------- | ------------------- | +| `'required'` (padrão) | 401 | 401 | o handler roda | +| `'optional'` | o handler roda, sem autenticação | 401 | o handler roda | +| `'try'` | o handler roda, sem autenticação | o handler roda, sem autenticação | o handler roda | + +"Sem credenciais" e "credenciais inválidas" são os dois tipos de [falha de `authenticate`](#authenticate). Quando o handler roda depois de uma falha, `request.auth.isAuthenticated` é `false` e `request.auth.error` guarda o motivo: `Missing authentication` quando nenhuma strategy encontrou credenciais, ou o erro que uma strategy lançou ao rejeitá-las. + +### strategy e strategies + +Use `strategy: 'session'` para uma strategy ou `strategies: ['session', 'api']` para várias. O hapi as tenta em ordem e só passa para a próxima enquanto cada uma reportar credenciais ausentes: + +```mermaid +flowchart TD + R[requisição] --> S1{"strategy 1
session"} + S1 -->|autenticada| H[handler] + S1 -->|ausentes| S2{"strategy 2
api"} + S1 -->|inválidas| E["falhou:
o mode da rota decide"] + S2 -->|autenticada| H + S2 -->|inválidas ou ausentes| E +``` + +### payload + +`payload` ativa o [método `payload`](#payload) do scheme para a rota: + +- `false`: o payload não é autenticado. +- `'required'` ou `true`: o payload precisa ser autenticado. +- `'optional'`: o payload só é verificado se o cliente enviou informações de autenticação do payload. + +Só funciona com uma strategy cujo scheme tenha um método `payload`. + +## Scopes + +A autenticação decide _quem_ está fazendo a requisição. Os scopes decidem _o que_ essa pessoa pode fazer. Uma strategy coloca uma lista de strings de scope em `credentials.scope`, uma rota lista os scopes que aceita em `options.auth.access.scope`, e o hapi compara os dois na etapa de autorização, depois de `onCredentials` e antes de o seu handler rodar. Uma requisição sem um scope correspondente recebe `403 Insufficient scope`, e o handler nunca roda. + +Os scopes normalmente vêm da função `validate` da strategy. Com os usuários do exemplo [basic](#basic), cada um com um array `roles`: + +```ts +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); + if (!user || !(await Bcrypt.compare(password, user.password))) { + return { isValid: false, credentials: null }; + } + + return { isValid: true, credentials: { id: user.id, name: user.name, scope: user.roles } }; }; +``` + +Quando os papéis ficam em outro lugar, um hook `onCredentials` pode adicioná-los depois da autenticação; veja [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks#credentials). + +As rotas então declaram os scopes que aceitam: + +```ts +server.route({ + method: 'GET', + path: '/reports', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'analyst'] } } }, + handler: (request, h) => 'any admin or analyst', +}); + +server.route({ + method: 'DELETE', + path: '/users/{id}', + options: { auth: { strategy: 'simple', access: { scope: ['+admin', '!suspended'] } } }, + handler: (request, h) => 'an admin who is not suspended', +}); -const validate = function (request, username, password, callback) { - const user = users[username]; +server.route({ + method: 'PUT', + path: '/users/{id}/profile', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'user-{params.id}'] } } }, + handler: (request, h) => 'an admin, or the user whose id is in the path', +}); +``` + +| Scope da rota | `credentials.scope` precisa | +| ---------------------- | -------------------------------------------------------------------------------------------------------- | +| `'admin'` | incluir `admin` | +| `['admin', 'analyst']` | incluir pelo menos um deles | +| `'+admin'` | incluir `admin`, independentemente do resto da lista | +| `'!suspended'` | não incluir `suspended` | +| `'user-{params.id}'` | incluir `user-42` para uma requisição a `/users/42`; `query`, `payload` e `credentials` também funcionam | + +Regras de scope fáceis de deixar passar: + +- Os scopes só são verificados em requisições autenticadas. Em uma rota com mode `'optional'` ou `'try'`, uma requisição sem credenciais ainda chega ao handler, com `request.auth.isAuthorized` igual a `false`. Verifique esse valor antes de fazer algo que só alguns usuários podem fazer. +- `access.entity` restringe uma rota a usuários ou a aplicações: `'user'` exige que `credentials.user` esteja definido, `'app'` exige que ele esteja ausente. Use quando a mesma strategy autentica tanto pessoas quanto clientes de máquina. +- `access` pode ser uma lista de regras, e a requisição só precisa satisfazer uma delas. +- `server.auth.default()` aceita as mesmas opções de `access`, então um scope pode valer para todas as rotas. + +Veja [`route.options.auth.access`](/api#route-options-auth-access) para as regras completas. + +## basic + +O primeiro `scheme` que vamos ver é o plugin [@hapi/basic](/module/basic). Como o nome diz, o plugin `@hapi/basic` usa basic authentication para validar usuários. Aqui está um exemplo de configuração do `@hapi/basic`: + +```ts +import Basic from '@hapi/basic'; +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; + +const users = new Map([ + [ + 'john', + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, + ], +]); + +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); if (!user) { - return callback(null, false); + return { credentials: null, isValid: false }; } - Bcrypt.compare(password, user.password, (err, isValid) => { - callback(err, isValid, { id: user.id, name: user.name }); - }); + const isValid = await Bcrypt.compare(password, user.password); + const credentials = { id: user.id, name: user.name }; + + return { isValid, credentials }; }; -server.register(Basic, (err) => { - if (err) { - throw err; - } +const start = async () => { + const server = Hapi.server({ port: 4000 }); + + await server.register(Basic); server.auth.strategy('simple', 'basic', { validate }); + server.route({ method: 'GET', path: '/', options: { auth: 'simple', - handler: function (request, reply) { - reply('hello, ' + request.auth.credentials.name); - }, + }, + handler: function (request, h) { + return 'welcome'; }, }); - server.start((err) => { - if (err) { - throw err; - } + await server.start(); - console.log('server running at: ' + server.info.uri); - }); -}); + console.log('server running at: ' + server.info.uri); +}; + +start(); ``` -Neste exemplo, primeiro definimos nossa base de dados `users` com um simples objeto javascript. Então definimos uma função de validação, que é uma funcionalidade específica de [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic) que nos permite verificar se as crendenciais fornecidas pelo usuário são válidas. +Primeiro, você define seu banco de `users`, neste exemplo um `Map` indexado pelo nome de usuário, pelo motivo explicado em [Schemes](#schemes). Depois, define uma função de validação, um recurso específico do [@hapi/basic](/module/basic) que permite verificar se o usuário forneceu credenciais válidas. Tipá-la como `Basic.Validate` deixa o TypeScript inferir `request`, `username` e `password`, e verifica o objeto que você retorna. Nessa função de validação, você usa o `Bcrypt` para comparar a senha informada pelo usuário com o hash da senha guardado no banco. -Em seguida, registramos o plugin que cria um esquema com o nome de `basic`. Isto é feito dentro do plugin via [server.auth.scheme()](/api#serverauthschemename-scheme). +Em seguida, você registra o plugin, que cria um scheme com o nome `basic`. Isso é feito dentro do plugin com [server.auth.scheme()](/api#server-auth-scheme-name-scheme). -Uma vez que o plugin foi registrado, nos utilizamos [server.auth.strategy()](/api#serverauthstrategyname-scheme-mode-options) para criar uma estratégia com o nome de `simple` que faz referência ao nosso esquema chamado `basic`. Também passamos um objeto de opções que são repassadas para o esquema, essas opções permitem configurar o comportamento deste esquema. +Com o plugin registrado, você usa [server.auth.strategy()](/api#server-auth-strategy-name-scheme-options) para criar uma strategy chamada `simple` que se refere ao seu scheme chamado `basic`. Você também passa um objeto de opções, que é repassado ao scheme e permite configurar o comportamento dele. -Por último, definimos uma rota que utiliza a estratégia denominada `simple` para a autenticação. +Por último, você diz a uma rota para usar a strategy chamada `simple` na autenticação. -## Esquemas (`Schemes`) +## cookie -Um esquema (`scheme`) é um método com a assinatura `function (server, options)`. O parâmetros `server` é uma referência ao servidor em que o esquema está sendo adicionado, e o parâmetro `options` é o objeto de configuração fornecido quando a estratégia que utiliza este esquema foi registrada. +[@hapi/cookie](/module/cookie) é um plugin que guarda a sessão em um cookie criptografado no navegador do usuário depois que ele se autentica. Sem um `cookie.ttl`, o cookie dura até o navegador ser fechado; defina `ttl` para manter o usuário logado entre sessões do navegador. -Este método precisa retornar um objeto com _pelo menos_ a chave `authenticate`. Outros métodos que podem ser utilizados opcionalmente são `payload` e `response`. +Neste exemplo, a rota inicial, "/", é restrita e só pode ser acessada depois que o usuário faz login. O navegador é redirecionado para o formulário de login, envia o usuário e a senha e então chega à página inicial com o cookie de sessão: -### `authenticate` +```mermaid +sequenceDiagram + participant B as Navegador + participant S as servidor hapi + B->>S: GET / + Note over S: sem cookie de sessão, então a strategy session falha + S-->>B: 302 redireciona para /login (redirectTo) + B->>S: GET /login + S-->>B: 200 formulário de login (auth: false) + B->>S: POST /login com usuário e senha + Note over S: mode 'try': o handler roda sem sessão
e verifica a senha com Bcrypt + S-->>B: 302 redireciona para /, Set-Cookie sid-example (criptografado { id }) + B->>S: GET / com Cookie sid-example + Note over S: validate(request, session) encontra a conta por session.id + S-->>B: 200 Welcome to the restricted home page! +``` -O método `authenticate` tem a assinatura `function (request, reply)`, e é um único método _obrigatório_ em um esquema. +```ts +import Bcrypt from 'bcrypt'; +import Cookie from '@hapi/cookie'; +import Hapi from '@hapi/hapi'; -Neste contexto, `request` é o objeto de requisição criado pelo servidor. É o mesmo objeto que se torna disponível para um manipulador de rota (`route handler`) e está documentado na [referência da API](/api#request-object). +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; -`reply` é a interface de resposta padrão do hapi, e aceita `err` e `result` como parâmetros (nesta ordem). +const start = async () => { + const server = Hapi.server({ port: 4000 }); -Se `err` não é um valor null, isto indica uma falha da autenticação e o error poderá ser enviado como uma resposta ao usuário final. É aconselhável utilizar [boom](https://github.com/hapijs/boom) para criar este erro e simplificar o fornecimento de um apropriado `status code` e menssagem de erro. + await server.register(Cookie); -O parâmetro `result` deve ser um objeto. No entanto, se `err` é fornecido, o parâmetro `result` bem como suas chaves serão opcionais. + server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, + redirectTo: '/login', + validate: async (request, session) => { + if (!session || !('id' in session)) { + return { isValid: false }; + } + + const account = users.find((user) => user.id === session.id); + if (!account) { + return { isValid: false }; + } + + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -Mas se você quiser fornecer mais detalhes em caso de uma falha, o objeto `result` pode ser utilizado e deverá ter uma propriedade `credentials` que é um objeto representando o usuário autenticado (ou as credenciais que o usuário tentou utilizar para a autenticação) e deve ser chamado como `reply(error, null, result);`. + server.auth.default('session'); -Quando a autenticação é bem sucedida, você precisa chamar `reply.continue(result)` onde _result_ é um objeto com uma propriedade `credentials`. + server.route([ + { + method: 'GET', + path: '/', + handler: function (request, h) { + return 'Welcome to the restricted home page!'; + }, + }, + { + method: 'GET', + path: '/login', + handler: function (request, h) { + return ` + + Login page + + +

Please Log In

+
+ Username:
+ Password:
+
+ + `; + }, + options: { + auth: false, + }, + }, + ]); -Adicionalmente, você também pode ter uma chave `artifacts`, que pode conter qualquer dado relacionado à autenticação. Os dados em `artifacts` não são parte das credenciais do usuário. + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); -As propriedades `credentials` e `artifacts` podem ser acessados depois (em um manipulador de rota, por exemplo) como parte do objeto `request.auth`. + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.redirect('/login'); + } -### `payload` + request.cookieAuth.set({ id: account.id }); -O método `payload` tem a assinatura `function (request, reply)`. + return h.redirect('/'); + }, + options: { + auth: { + mode: 'try', + }, + }, + }); -Novamente a interface `reply`, padrão do hapi, está disponível aqui. Para sinalizar uma falha chame `reply(error, result)` ou simplesmente `reply(error)` (Mais uma vez recomendamos a utilização de [boom](https://github.com/hapijs/boom)) para erros. + await server.start(); -Para sinalizar uma autenticação bem sucedida, chame ll `reply.continue()` sem nenhum parâmetro. + console.log('server running at: ' + server.info.uri); +}; -### `response` +start(); +``` -O método `response` também tem a assinatura `function (request, reply)` e utiliza a interface padrão `reply`. +O `@hapi/cookie` não traz seus próprios tipos. Se você usa TypeScript, instale-os com `npm install --save-dev @types/hapi__cookie`. Eles adicionam `request.cookieAuth` ao tipo `Request` do hapi e tipam as opções da strategy `cookie`. A sessão chega em `validate` como um `object` comum, então é a checagem `'id' in session` que permite ao TypeScript ler `session.id`. A rota `POST /login` declara os campos do formulário com um argumento de tipo para `server.route()`; isso é só um tipo, então adicione [validação do payload](/tutorials/pt_BR/validation) para garanti-lo em tempo de execução. -Este método é usado para "decorar" o objeto de requisição (`request.response`) com cabeçalhos adicionais, antes da resposta ser enviada para o usuário. +Primeiro, você registra o plugin `@hapi/cookie` com `server.register`, o que adiciona o scheme `cookie`. Depois, cria a strategy `session` a partir dele com `server.auth.strategy`, passando três opções: -Uma vez que toda a decoração está completa, você precisa chamar `reply.continue()`, e a resposta será enviada. +- `cookie` configura o próprio cookie. `name` é o nome do cookie, `sid-example`. `password` criptografa o cookie e precisa ter pelo menos 32 caracteres. `isSecure: false` permite que o cookie trafegue por HTTP sem TLS, o que não tem problema em desenvolvimento; em produção, deixe o valor padrão `true`. +- `redirectTo` é para onde o servidor manda um usuário não autenticado que pede uma rota protegida. +- `validate` roda em toda requisição que traz o cookie e verifica se a sessão continua válida. Aqui ele busca a conta pelo `id` guardado no cookie, então uma conta apagada é deslogada na sua próxima requisição. -Se ocorrer algum erro, você deve preferencialmente chamar `reply(error)` onde é recomendado que `error` seja um [boom](https://github.com/hapijs/boom). +`server.auth.default('session')` protege todas as rotas com a strategy `session`. `GET /login` fica de fora com `auth: false`, então qualquer pessoa pode ver o formulário. -### Registro de um esquema +`POST /login` usa `mode: 'try'`, então o handler roda mesmo que o usuário ainda não tenha sessão. Ele encontra a conta pelo `username` e compara a senha enviada com o hash guardado usando o `Bcrypt`. Se alguma das verificações falhar, o usuário volta para a página de login. Se as duas passarem, `request.cookieAuth.set()` guarda `{ id }` no cookie criptografado e o usuário é redirecionado para a página inicial. -Para registrar um esquema, use `server.auth.scheme(name, scheme)`. O parâmetro `name` é uma string usada para identificar este esquema específico. O parâmetro `scheme` é um método como descrito acima. +## jwt -## Estratégias +O [@hapi/jwt](/module/jwt) autentica requisições que trazem um JSON Web Token no header `Authorization: Bearer `. Como o `@hapi/cookie`, ele não guarda sessão no servidor; a diferença está em como a identidade trafega: -Uma vez que seu esquema foi registrado, você precisa utilizá-lo de algum modo. É aqui que entram as estratégias. +| | `@hapi/cookie` | `@hapi/jwt` | +| ------------- | ---------------------------------------------- | ------------------------------------------------------- | +| Enviado em | um cookie que o navegador anexa | o header `Authorization` que o cliente define | +| Conteúdo | criptografado: ilegível para o cliente | assinado, mas legível por qualquer um que tenha o token | +| Indicado para | aplicações de navegador no seu próprio domínio | APIs chamadas por apps mobile, outros servidores e SPAs | -Como mencionado acima, uma estratégia é essencialmente uma cópia pré-configurada de um esquema. +Nunca coloque segredos no payload de um JWT, já que a assinatura impede a adulteração, mas não a leitura. -Para registrar uma estratégia, precisamos primeiramente ter um esquema registrado. Uma vez que você já tenha registrado um esquema, utilize `server.auth.strategy(name, scheme, [mode], [options])` para registrar sua estratégia. +O cliente faz login uma vez e depois envia o token em toda requisição: + +```mermaid +sequenceDiagram + participant C as Cliente + participant S as servidor hapi + C->>S: POST /login com usuário e senha + Note over S: auth: false, verifica a senha com Bcrypt + S-->>C: 200 { token } assinado com o segredo do servidor + C->>S: GET /me com Authorization: Bearer token + Note over S: a strategy jwt verifica a assinatura, aud, iss e a idade do token,
depois chama validate(artifacts) + S-->>C: 200 { id, name } +``` -O parâmetro `name` deve ser uma string que será utilizada posteriormente para identificar esta estratégia específica. `scheme` também é uma string que é o nome do esquema cuja instância será usada na estratégia. +```ts +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; +import Jwt from '@hapi/jwt'; -### Mode +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; -`mode` é o primeiro parâmetro opcional, e poderá ser `true`, `false`, `'required'`, `'optional'`, ou `'try'`. +const secret = 'hR7#mX2!vQ9@kL4$wP8^zT1&nB6*cJ3%'; -O valor padrão é `false`, o que significa que a estratégia será registrada mas não aplicada em lugar algum até que você faça isso manualmente. +const start = async () => { + const server = Hapi.server({ port: 4000 }); -Se mode é definido como `true` ou `'required'`, que são iguais, a estratégia será automaticamente assinalada para todas as rotas que não contenham uma configuração `auth`. Essa configuração significa que para acessar uma rota o usuário precisa ser autenticado, e a autenticação precisa ser válida, caso contrário o usuário receberá um erro. + await server.register(Jwt); -Se mode é definido como `'optional'` a estratégia será aplicada mesmo em rotas sem configuração `auth`, mas nesse caso o usuário _não_ necessita ser autenticado. Os dados de autenticação são opcionais, mas se forem fornecidos precisam ser válidos. + server.auth.strategy('bearer', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:my-api', + iss: 'urn:issuer:my-api', + sub: false, + maxAgeSec: 4 * 60 * 60, + }, + validate: async (artifacts, request, h) => { + const account = users.find((user) => user.id === artifacts.decoded.payload.id); + if (!account) { + return { isValid: false }; + } -A última configuração de mode é `'try'` que também é aplicada em todas as rotas sem uma configuração `auth`. A diferença entre `'try'` e `'optional'` é que com o uso de `'try'` autenticações inválidas também são aceitas, e o usuário ainda chegará no manipulador de rota (route handler). + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -### Options + server.auth.default('bearer'); -O último parâmetro (opcional) é `options`, que será repassado diretamente para o esquema nomeado. + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + options: { auth: false }, + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); -### Configurando uma estratégia padrão + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.response({ error: 'Invalid username or password' }).code(401); + } -Como previamente mencionado, o parâmetro `mode` pode ser usado com `server.auth.strategy()` para definir uma estratégia padrão. Você pode também definir explicitamente uma estatégia padrão com o uso de `server.auth.default()`. + const token = Jwt.token.generate( + { aud: 'urn:audience:my-api', iss: 'urn:issuer:my-api', id: account.id }, + secret, + ); -Este método aceita um parâmetro, que pode ser uma string com o nome da estratégia a ser utilizada por padrão, ou um objeto formatado da mesma forma como os manipuladores de rota [auth options](#route-configuration). + return { token }; + }, + }); -É importante observar que a estratégia padrão é aplicada a todas as rotas, inclusive as rotas que foram definidas _antes_ da chamada do método `server.auth.default()`. + server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return request.auth.credentials; + }, + }); -## Configuração de rota + await server.start(); -A autenticação também pode ser configurada em uma rota, pelo parâmetro `options.auth`. Se definido para `false`, a autenticação é desabilitada para a rota. + console.log('server running at: ' + server.info.uri); +}; -Também podemos definir com uma string com o nome da estratégia a ser utilizada, ou um objeto com os parâmetros `mode`, `strategies`, e `payload`. +start(); +``` -O parâmetro `mode` pode ser definido para `'required'`, `'optional'`, ou `'try'` e funciona da mesma forma como quando registramos uma estratégia. +Você registra o plugin, que adiciona o scheme `jwt`, e cria a strategy `bearer` a partir dele: -Ao especificar uma estratégia, você pode definir a propriedade `strategy` para uma string com o nome da estratégia. Para especificar mais que uma estratégia, o nome do parâmetro precisa ser `strategies` e deve ser um array de strings com o nome das estratégias para tentar. As estratégias serão então tentadas na ordem de sucessão até que uma tenha sucesso, ou que todas falhem. +- `keys` é o segredo com que os tokens são assinados. Um segredo compartilhado funciona quando o mesmo servidor emite e verifica os tokens. Para aceitar tokens de um provedor de identidade, `keys` pode apontar para as chaves públicas do provedor; veja as opções de `keys` na [API do @hapi/jwt](/module/jwt). +- `verify` lista as claims que um token precisa ter. `aud` (audience) e `iss` (issuer) precisam bater com os valores usados na emissão dos tokens. `maxAgeSec` rejeita tokens cujo `iat` (issued at) foi há mais de quatro horas. Os tokens deste exemplo não têm `exp`; se os seus tiverem, `exp` e `nbf` são verificados por padrão. +- `validate` só roda depois que a assinatura e as claims passam. Ele recebe o token decodificado como `artifacts.decoded.payload` e decide o que vai para `request.auth.credentials`. Aqui ele também verifica se a conta ainda existe, já que um token continua válido até expirar, mesmo que a conta seja apagada. -Finalmente, o parâmetro `payload` pode ser definido como `false` indicando que o payload não será autenticado, `'required'` ou `true` indica que payload _precisa_ ser autenticado, ou `'optional'` indica que se o cliente incluir informações para autenticação do payload, essa autenticação precisa ser válida. +`POST /login` fica fora da autenticação com `auth: false`, verifica a senha e retorna um token de `Jwt.token.generate()` com os mesmos `aud` e `iss` que a strategy verifica. O cliente então chama `GET /me` com `Authorization: Bearer `. `artifacts.decoded.payload` não é tipado, porque o hapi não tem como saber quais claims você colocou no token; é em `validate` que você as verifica e monta credenciais tipadas. -O parâmetro `payload` só pode ser utilizado com uma estratégia que dê suporte ao método `payload` no seu esquema. +Para mais informações sobre outros plugins de autenticação do hapi, veja a [seção de plugins](/plugins). diff --git a/docs/tutorials/tr_TR/auth.md b/docs/tutorials/tr_TR/auth.md index 4e10651d..158fb971 100644 --- a/docs/tutorials/tr_TR/auth.md +++ b/docs/tutorials/tr_TR/auth.md @@ -1,36 +1,369 @@ --- -title: Authentication +title: Kimlik Doğrulama --- -## Kimlik Dogrulama +# Kimlik Doğrulama -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -hapide kimlik doğrulama şema (`schema`) ve stratejilerin (`strategy`) üzerine kuruludur. +## Genel Bakış -Şemayı genel bir kimlik doğrulama tipi olarak düşün. "basic" ya da "digest" gibi. Bir strateji ise, önceden ayarlanmış ve adlandırılmış bir şemadır. +Modern web uygulamalarının çoğu bir tür kimlik doğrulama kullanır. hapi'de kimlik doğrulama iki parçadan oluşur: `schemes` (şemalar) ve `strategies` (stratejiler): -Önce, [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic)'i kullandığımız bir örneğe bakalım: +- `scheme`, "HTTP basic auth" ya da "oturum cookie'si" gibi genel bir kimlik doğrulama yöntemidir. `@hapi/basic` ve `@hapi/cookie` gibi plugin'lerin her biri bir şema kaydeder. +- `strategy`, adı ve yapılandırması olan bir şemadır; örneğin "kullanıcılar tablomla denetlenen basic auth". Route'lar stratejilere adlarıyla başvurur. -```javascript -'use strict'; +`@hapi/basic` örneğinde plugin bir şema kaydeder, sen bu şemadan bir strateji yapılandırırsın ve bir route o stratejiyi kullanır: -const Bcrypt = require('bcrypt'); -const Hapi = require('@hapi/hapi'); +```ts +// Registers the 'basic' scheme. +await server.register(Basic); -const users = { - john: { - username: 'john', - password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' - name: 'John Doe', - id: '2133d32a', +server.auth.strategy('simple', 'basic', { validate }); + +server.route({ + method: 'GET', + path: '/', + options: { auth: 'simple' }, + handler: (request, h) => 'welcome', +}); +``` + +## Şemalar + +`scheme`, imzası `function (server, options)` olan bir fonksiyondur. `server`, şemanın eklendiği sunucudur; `options` ise bu şemadan bir strateji oluşturulurken verilen yapılandırma nesnesidir. Fonksiyon bir şema nesnesi döndürür ve hapi bu nesnenin metotlarını [istek yaşam döngüsünün](/api#request-lifecycle) üç noktasında çağırır: + +```mermaid +flowchart TD + A["onRequest, route arama,
cookie ayrıştırma, onPreAuth"] --> D["authenticate(request, h)
zorunlu"] + D --> E[payload ayrıştırma] + E --> F["payload(request, h)
isteğe bağlı"] + F --> G["onCredentials, yetkilendirme
(auth.access scope'ları), onPostAuth"] + G --> J["doğrulama, handler,
onPreResponse"] + J --> L["response(request, h)
isteğe bağlı"] + L --> M[yanıt gönderildi] + + class D,F,L highlight +``` + +`payload` ve `response` yalnızca `authenticate` tarafından kabul edilen isteklerde çalışır. + +Kendi şemanı yazabilir ya da `@hapi/basic` veya `@hapi/cookie` gibi hapi kimlik doğrulama plugin'lerinden birini kullanabilirsin. Aşağıda bir istek header'ından API token'ı okuyan eksiksiz bir şema var: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +import type { ServerAuthScheme } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface UserCredentials { + id: string; + name: string; + } +} + +interface TokenOptions { + tokens: Map; +} + +const tokenScheme: ServerAuthScheme = (server, options) => { + return { + authenticate: (request, h) => { + const token = request.headers['x-api-token']; + if (typeof token !== 'string') { + throw Boom.unauthorized(null, 'token'); + } + + const user = options?.tokens.get(token); + if (!user) { + throw Boom.unauthorized('Invalid token', 'token'); + } + + return h.authenticated({ credentials: { user }, artifacts: { token } }); + }, + response: (request, h) => { + if (!Boom.isBoom(request.response)) { + request.response.header('x-authenticated-user', request.auth.credentials.user?.id ?? ''); + } + + return h.continue; + }, + }; +}; + +const server = Hapi.server({ port: 4000 }); + +server.auth.scheme('token', tokenScheme); +server.auth.strategy('api', 'token', { + tokens: new Map([['a1b2c3', { id: '2133d32a', name: 'John Doe' }]]), +}); + +server.route({ + method: 'GET', + path: '/me', + options: { auth: 'api' }, + handler: (request, h) => { + return request.auth.credentials.user; + }, +}); + +await server.start(); +``` + +`ServerAuthScheme`, şemanın aldığı `options` değerinin tipini belirler. Token'ların düz bir nesnede değil de `Map` içinde tutulması bilinçli bir tercihtir: istemcinin gönderdiği bir anahtarı düz bir nesnede aramak kalıtılan özellikleri de bulur, yani `constructor` ya da `toString` değerinde bir token kimlik doğrulamayı geçerdi. `declare module` bloğu TypeScript'e kimlik bilgilerinin neleri içerdiğini söyler: hapi genişletmen için boş bir `UserCredentials` arayüzü dışa aktarır ve bundan sonra `request.auth.credentials.user` her handler'da tiplenmiş olur. + +### authenticate + +`authenticate` metodunun imzası `function (request, h)` şeklindedir ve bir şemadaki tek _zorunlu_ metottur. `request`, bir route handler'ının aldığı [request nesnesinin](/api#request) aynısıdır; `h` ise standart [response toolkit'tir](/api#response-toolkit). + +`authenticate` üç şekilde sona erer ve her biri farklı bir yere çıkar: + +```mermaid +flowchart TD + A["authenticate(request, h)"] --> OK["başarılı
h.authenticated()"] + A --> MISS["eksik kimlik bilgileri
mesajsız hata,
yalnızca şema adı"] + A --> BAD["geçersiz kimlik bilgileri
başka herhangi bir hata"] + OK --> OK2["handler
request.auth.credentials değerini görür"] + MISS --> MISS2["sonraki strateji; hiç kalmadıysa
route'un mode değeri karar verir"] + BAD --> BAD2["başka strateji denenmez;
route'un mode değeri karar verir"] + + class OK,MISS,BAD highlight +``` + +- **Başarılı:** `h.authenticated({ credentials, artifacts })` döndür. `credentials`, kimliği doğrulanmış kullanıcıyı temsil eder. `artifacts` isteğe bağlıdır ve ham token gibi diğer kimlik doğrulama verilerini taşır. İkisine de daha sonra `request.auth.credentials` ve `request.auth.artifacts` üzerinden erişilir. +- **Eksik kimlik bilgileri:** mesajı olmayan ve şema adını taşıyan bir hata fırlat; örneğin `Boom.unauthorized(null, 'token')`. hapi isteği bu şema için kimlik bilgisi taşımıyor sayar, route'un sıradaki stratejisine geçer ve şema adı `WWW-Authenticate` header'ına eklenir. +- **Geçersiz kimlik bilgileri:** başka herhangi bir hata fırlat; örneğin `Boom.unauthorized('Invalid token', 'token')`. hapi diğer stratejileri denemeyi bırakır. Mesajı _ve_ şema adı olmayan bir `Boom.unauthorized()` da geçersiz sayılır. + +Başarısızlıktan sonra ne olacağı route'un [`mode`](#mode) değerine bağlıdır. + +Hata fırlatmak yerine `h.unauthenticated(error, data)` döndürebilirsin. Bu, `error` fırlatmakla aynı şekilde davranır, ama `data` sayesinde başarısız isteğe `credentials` ve `artifacts` ekleyebilirsin; `'try'` modundaki bir route bunları okuyabilir. `data` olmadan `return h.unauthenticated(error)` ile `throw error` aynıdır. Kuralların tamamı için [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) sayfasına bak. Bu hataları oluşturmanın önerilen yolu [boom](/module/boom)'dur. + +### payload + +`payload` metodunun imzası `function (request, h)` şeklindedir. Payload ayrıştırıldıktan sonra çalışır, bu yüzden payload'ın kendisini denetleyebilir; örneğin istek gövdesi üzerinden hesaplanmış bir imzayı. + +Payload geçerliyse `h.continue` döndür, geçersizse bir hata fırlat. Route'lar payload kimlik doğrulamasını [`payload` route seçeneğiyle](#route) açar. Buna her zaman ihtiyaç duyan bir şema, metotlarının yanında `options: { payload: true }` döndürebilir; bu da route'ların onu kapatmasını engeller. + +### response + +`response` metodunun imzası `function (request, h)` şeklindedir. `onPreResponse` sonrasında, yanıt header'ları yazılmadan hemen önce çalışır; bu yüzden `request.response` üzerine kimlik doğrulama header'ları ekleyebilir. Yukarıdaki token şeması bunu bir `x-authenticated-user` header'ı eklemek için kullanır. + +`response` çalıştığında hapi her hatayı zaten bir response nesnesine dönüştürmüştür, yani hata yanıtları da bu header'ı alır. `Boom.isBoom()` denetimi `request.response` tipini response nesnesine daraltır ve böylece `.header()` tip denetiminden geçer. İşin bittiğinde `h.continue` döndür. Başarısız olmak için bir hata fırlat. + +## Stratejiler + +Bir şema, `server.auth.strategy(name, scheme, [options])` ile ondan bir strateji oluşturana kadar hiçbir şey yapmaz: + +- `name` stratejinin adıdır. Route'lar stratejiyi bu adla seçer. +- `scheme`, kayıtlı bir şemanın adıdır. +- `options`, şema fonksiyonuna `options` argümanı olarak verilir. + +Bir şema, her biri kendi yapılandırmasına sahip birçok stratejinin temelini oluşturabilir: + +```ts +server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, +}); + +server.auth.strategy('admin-session', 'cookie', { + cookie: { + name: 'sid-admin', + password: 'Q7v!kW2#pL9@zR4$mT6^xB1&nD8*cF3%', + isSecure: false, + }, + redirectTo: '/admin/login', + requestDecoratorName: 'adminCookieAuth', +}); +``` + +Her `@hapi/cookie` stratejisi bir `request.cookieAuth` yardımcısı ekler ve hapi aynı request decoration'ını iki kez eklemeyi reddeder; bu yüzden ikinci stratejinin, yardımcısına başka bir ad vermek için `requestDecoratorName` seçeneğine ihtiyacı vardır. Onun route'ları `request.adminCookieAuth.set()` çağırır. `@types/hapi__cookie` paketi (bkz. [cookie](#cookie)) yalnızca `cookieAuth` için tip sağlar, bu yüzden TypeScript'te yeniden adlandırılmış yardımcıyı da bildir: + +```ts +declare module '@hapi/hapi' { + interface Request { + adminCookieAuth: Request['cookieAuth']; + } +} +``` + +## Varsayılan Strateji + +`server.auth.default()`, aksini belirtmeyen her route'un kullanacağı stratejiyi ayarlar. Bir strateji adı ya da bir route'un [auth seçenekleriyle](#route) aynı biçimde bir nesne alır. Varsayılan, `auth` seçeneğinde strateji adı geçmeyen her route'a uygulanır; buna `server.auth.default()` çağrılmadan _önce_ eklenen route'lar da dahildir. İstisna, `auth` değeri yalnızca `mode` ya da `access` belirten route'lardır: hapi bunları route eklenirken varsayılanla birleştirir, bu yüzden önce `server.auth.default()` çağır. + +```ts +server.auth.default('session'); + +server.route({ + method: 'GET', + path: '/account', + handler: (request, h) => 'protected by the session strategy', +}); + +server.route({ + method: 'GET', + path: '/health', + options: { auth: false }, + handler: (request, h) => 'public', +}); +``` + +## Route Yapılandırması + +Bir route, kimlik doğrulamayı `options.auth` özelliğiyle ayarlar: + +```ts +server.route({ + method: 'GET', + path: '/feed', + options: { + auth: { + mode: 'try', + strategies: ['session', 'api'], + }, }, + handler: (request, h) => { + if (!request.auth.isAuthenticated) { + return 'public feed'; + } + + return `personal feed, signed in with ${request.auth.strategy}`; + }, +}); +``` + +`options.auth` şunlardan biri olabilir: + +- Route için kimlik doğrulamayı kapatmak üzere `false`. +- `'session'` gibi bir strateji adı. +- `mode`, `strategy` ya da `strategies` ve `payload` içeren bir nesne. + +### mode + +`mode`, kimlik bilgisi olmayan ya da kimlik bilgileri hatalı olan bir isteğe ne olacağını belirler: + +| `mode` | Kimlik bilgisi yok | Geçersiz kimlik bilgileri | Geçerli kimlik bilgileri | +| ------------------------- | ----------------------------------- | ----------------------------------- | ------------------------ | +| `'required'` (varsayılan) | 401 | 401 | handler çalışır | +| `'optional'` | handler çalışır, kimlik doğrulanmaz | 401 | handler çalışır | +| `'try'` | handler çalışır, kimlik doğrulanmaz | handler çalışır, kimlik doğrulanmaz | handler çalışır | + +"Kimlik bilgisi yok" ve "geçersiz kimlik bilgileri", iki tür [`authenticate` başarısızlığıdır](#authenticate). Handler bir başarısızlıktan sonra çalıştığında `request.auth.isAuthenticated` değeri `false` olur ve `request.auth.error` nedeni taşır: hiçbir strateji kimlik bilgisi bulamadıysa `Missing authentication`, bir strateji kimlik bilgilerini reddettiyse o stratejinin fırlattığı hata. + +### strategy ve strategies + +Tek bir strateji için `strategy: 'session'`, birden fazlası için `strategies: ['session', 'api']` kullan. hapi bunları sırayla dener ve bir sonrakine yalnızca her biri eksik kimlik bilgisi bildirdiği sürece geçer: + +```mermaid +flowchart TD + R[istek] --> S1{"strateji 1
session"} + S1 -->|doğrulandı| H[handler] + S1 -->|eksik| S2{"strateji 2
api"} + S1 -->|geçersiz| E["başarısız:
route'un mode değeri karar verir"] + S2 -->|doğrulandı| H + S2 -->|geçersiz ya da eksik| E +``` + +### payload + +`payload`, route için şemanın [`payload` metodunu](#payload) açar: + +- `false`: payload'ın kimliği doğrulanmaz. +- `'required'` ya da `true`: payload'ın kimliği doğrulanmak zorundadır. +- `'optional'`: payload yalnızca istemci payload kimlik doğrulama bilgisi gönderdiyse denetlenir. + +Yalnızca şeması bir `payload` metoduna sahip olan stratejilerle çalışır. + +## Scope'lar + +Kimlik doğrulama, isteği _kimin_ yaptığını belirler. Scope'lar ise o kişinin _ne_ yapabileceğini belirler. Bir strateji `credentials.scope` içine scope string'lerinden oluşan bir liste koyar, bir route kabul ettiği scope'ları `options.auth.access.scope` içinde listeler ve hapi ikisini, `onCredentials` sonrasında ve handler'ın çalışmasından önceki yetkilendirme adımında karşılaştırır. Eşleşen bir scope'u olmayan istek `403 Insufficient scope` alır ve handler hiç çalışmaz. + +Scope'lar genellikle stratejinin `validate` fonksiyonundan gelir. [basic](#basic) örneğindeki kullanıcılara birer `roles` dizisi verildiğinde: + +```ts +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); + if (!user || !(await Bcrypt.compare(password, user.password))) { + return { isValid: false, credentials: null }; + } + + return { isValid: true, credentials: { id: user.id, name: user.name, scope: user.roles } }; }; +``` + +Roller başka bir yerde tutuluyorsa, bir `onCredentials` hook'u bunları kimlik doğrulamadan sonra ekleyebilir; bkz. [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks#credentials). + +Ardından route'lar kabul ettikleri scope'ları bildirir: + +```ts +server.route({ + method: 'GET', + path: '/reports', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'analyst'] } } }, + handler: (request, h) => 'any admin or analyst', +}); + +server.route({ + method: 'DELETE', + path: '/users/{id}', + options: { auth: { strategy: 'simple', access: { scope: ['+admin', '!suspended'] } } }, + handler: (request, h) => 'an admin who is not suspended', +}); + +server.route({ + method: 'PUT', + path: '/users/{id}/profile', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'user-{params.id}'] } } }, + handler: (request, h) => 'an admin, or the user whose id is in the path', +}); +``` + +| Route scope'u | `credentials.scope` şunu sağlamalı | +| ---------------------- | ------------------------------------------------------------------------------------------------- | +| `'admin'` | `admin` içermeli | +| `['admin', 'analyst']` | bunlardan en az birini içermeli | +| `'+admin'` | listede başka ne olursa olsun `admin` içermeli | +| `'!suspended'` | `suspended` içermemeli | +| `'user-{params.id}'` | `/users/42` isteği için `user-42` içermeli; `query`, `payload` ve `credentials` de kullanılabilir | + +Gözden kaçması kolay scope kuralları: + +- Scope'lar yalnızca kimliği doğrulanmış isteklerde denetlenir. `'optional'` ya da `'try'` modundaki bir route'ta kimlik bilgisi olmayan bir istek yine handler'a ulaşır ve `request.auth.isAuthorized` değeri `false` olur. Yalnızca bazı kullanıcıların yapabileceği bir işlemden önce bunu denetle. +- `access.entity`, bir route'u kullanıcılara ya da uygulamalara kısıtlar: `'user'`, `credentials.user` değerinin ayarlanmış olmasını; `'app'` ise olmamasını gerektirir. Aynı strateji hem insanların hem de makine istemcilerinin kimliğini doğruluyorsa bunu kullan. +- `access` bir kurallar listesi olabilir; bir isteğin bunlardan yalnızca birini sağlaması yeterlidir. +- `server.auth.default()` aynı `access` seçeneklerini kabul eder, yani bir scope her route'a uygulanabilir. -const validate = async (request, username, password) => { - const user = users[username]; +Kuralların tamamı için [`route.options.auth.access`](/api#route-options-auth-access) sayfasına bak. + +## basic + +İnceleyeceğimiz ilk `scheme`, [@hapi/basic](/module/basic) plugin'idir. Adından da anlaşılacağı gibi `@hapi/basic`, kullanıcıları doğrulamak için basic authentication kullanır. `@hapi/basic` kurulumuna bir örnek: + +```ts +import Basic from '@hapi/basic'; +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; + +const users = new Map([ + [ + 'john', + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, + ], +]); + +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); if (!user) { return { credentials: null, isValid: false }; } @@ -44,7 +377,7 @@ const validate = async (request, username, password) => { const start = async () => { const server = Hapi.server({ port: 4000 }); - await server.register(require('hapi-auth-basic')); + await server.register(Basic); server.auth.strategy('simple', 'basic', { validate }); @@ -67,94 +400,262 @@ const start = async () => { start(); ``` -Önce, bu örnekte basit bir nesne olan kullanıcı veri tabanımızı (`users`) tanımlıyoruz. Daha sonra [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic)e özel olan ve kullanıcının doğru kimlik bilgilerini verdiğini doğrulayan bir doğrulama işlevi tanımlıyoruz. +Önce `users` veritabanını tanımlarsın; bu örnekte [Şemalar](#schemes) bölümünde açıklanan nedenle kullanıcı adına göre anahtarlanmış bir `Map`. Ardından bir doğrulama fonksiyonu tanımlarsın. Bu, [@hapi/basic](/module/basic)'e özgü bir özelliktir ve kullanıcının geçerli kimlik bilgileri verdiğini doğrulamanı sağlar. Fonksiyonun tipini `Basic.Validate` olarak belirtmek, TypeScript'in `request`, `username` ve `password` tiplerini çıkarmasını ve döndürdüğün nesneyi denetlemesini sağlar. Bu doğrulama fonksiyonunda, kullanıcının girdiği parolayı veritabanındaki hash'lenmiş parolayla karşılaştırmak için `Bcrypt` kullanırsın. + +Sonra plugin'i kaydedersin; bu, `basic` adında bir şema oluşturur. Bu işlem plugin içinde [server.auth.scheme()](/api#server-auth-scheme-name-scheme) ile yapılır. + +Plugin kaydedildikten sonra, `basic` adlı şemana başvuran `simple` adında bir strateji oluşturmak için [server.auth.strategy()](/api#server-auth-strategy-name-scheme-options) kullanırsın. Ayrıca şemaya iletilen ve davranışını yapılandırmanı sağlayan bir seçenekler nesnesi de verirsin. + +Son olarak bir route'a kimlik doğrulama için `simple` adlı stratejiyi kullanmasını söylersin. + +## cookie -Daha sonra, `basic` adında bir şema yaratan eklentiyi kaydediyoruz. Bu, eklenti içerisinde [server.auth.scheme()](/api#serverauthschemename-scheme) ile gerçekleştiriliyor. +[@hapi/cookie](/module/cookie), kullanıcının kimliği doğrulandıktan sonra oturumu kullanıcının tarayıcısında şifrelenmiş bir cookie içinde saklayan bir plugin'dir. `cookie.ttl` olmadan cookie tarayıcı kapanana kadar geçerli kalır; kullanıcının tarayıcı oturumları arasında oturum açık kalması için `ttl` ayarla. -Eklentimizi kaydettikten sonra, [server.auth.strategy()](/api#serverauthstrategyname-scheme-mode-options) kullanarak `basic` şemamıza işaret eden `simple` adında bir strateji oluşturuyoruz. Aynı zamanda davranışını düzenlememize yarayan seçenekler içeren bir nesne de gönderiyoruz. +Bu örnekte ana sayfa route'u "/" kısıtlıdır ve yalnızca kullanıcı oturum açtıktan sonra erişilebilir. Tarayıcı giriş formuna yönlendirilir, kullanıcı adını ve parolayı gönderir, ardından oturum cookie'siyle ana sayfaya ulaşır: + +```mermaid +sequenceDiagram + participant B as Tarayıcı + participant S as hapi sunucusu + B->>S: GET / + Note over S: oturum cookie'si yok, bu yüzden session stratejisi başarısız olur + S-->>B: 302 /login adresine yönlendirme (redirectTo) + B->>S: GET /login + S-->>B: 200 giriş formu (auth: false) + B->>S: kullanıcı adı ve parolayla POST /login + Note over S: mode 'try': handler oturum olmadan çalışır
ve parolayı Bcrypt ile denetler + S-->>B: 302 / adresine yönlendirme, Set-Cookie sid-example (şifrelenmiş { id }) + B->>S: Cookie sid-example ile GET / + Note over S: validate(request, session) hesabı session.id ile bulur + S-->>B: 200 Welcome to the restricted home page! +``` + +```ts +import Bcrypt from 'bcrypt'; +import Cookie from '@hapi/cookie'; +import Hapi from '@hapi/hapi'; + +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; -Son olarak bir yola (route) kimlik doğrulama için `simple` adlı stratejiyi kullanmasını söylüyoruz. +const start = async () => { + const server = Hapi.server({ port: 4000 }); -## Şemalar + await server.register(Cookie); -Bir şema (`scheme`) `function (server, options)` imzası taşıyan bir yöntemdir. Sunucu (`server`) değiştirgesi şemanın eklendiği sunucunun referansıyken, seçenekler (`options`) değiştirgesi bu şemayı kullanan stratejiyi kaydederken kullanılan yapılandırma nesnesidir. + server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, + redirectTo: '/login', + validate: async (request, session) => { + if (!session || !('id' in session)) { + return { isValid: false }; + } + + const account = users.find((user) => user.id === session.id); + if (!account) { + return { isValid: false }; + } + + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -Bu yöntem içerisinde _en az_ `authenticate` anahtarı olan bir nesne dönmelidir. Kullanılabilecek diğer isteğe bağlı yöntemler `payload` ve `response`tur. + server.auth.default('session'); -### `authenticate` + server.route([ + { + method: 'GET', + path: '/', + handler: function (request, h) { + return 'Welcome to the restricted home page!'; + }, + }, + { + method: 'GET', + path: '/login', + handler: function (request, h) { + return ` + + Login page + + +

Please Log In

+
+ Username:
+ Password:
+
+ + `; + }, + options: { + auth: false, + }, + }, + ]); -`authenticate` yönteminin imzası `function (request, h)`'dir ve bir şemada _gerekli_ olan tek yöntemdir. + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); -Bu kapsamda, istek (`request`) sunucu tarafından oluşturulan `request` nesnesidir. Yol işleyici içerisinde erişilebilir olan nesne ile aynı nesnedir ve [API Referansı](/api#request-object)nda dokümantasyonu bulunabilir. + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.redirect('/login'); + } -`h` standart hapi [yanıt alet takımı (response toolkit)](https://hapijs.com/api#response-toolkit)dır. + request.cookieAuth.set({ id: account.id }); -Kimlik doğrulama başarılı olduğunda `h.authenticated({ credentials, artifacts })` yöntemini çağırmalı ve dönmelisiniz. Kimlik bilgileri (`credentials`) özelliği doğrulanan kullanıcının bilgilerini (ya da doğrulama için kullandığı kimlik bilgilerini) içeren bir nesnedir. Ayrıca kullanıcı bilgileri haricinde üretilen kimlik doğrulama ile ilgili bilgileri içeren bir üretiler (`artifacts`) anahtarın da olabilir. + return h.redirect('/'); + }, + options: { + auth: { + mode: 'try', + }, + }, + }); -Kimlik bilgileri (`credentials`) ve üretiler (`artifacts`) özellikleri daha sonra (örneğin yol işleyicisinde) `request.auth` nesnesinden erişilebilir. + await server.start(); -Eğer giriş başarısızsa, bir hata fırlatabilir ya da `h.unauthenticated(error, [data])` yöntemini çağırıp dönebilirsin. Burada hata (`error`) bir kimlik doğrulama hatası (authentication error) ve veri (`data`) kullanıcı bilgilerini (`credentials`) ve üretileri ('artifacts`) içeren isteğe bağlı bir nesnedir. + console.log('server running at: ' + server.info.uri); +}; -Eğer hiç bir veri (`data`) sağlanmayacaksa hata fırlatmakla `return h.unauthenticated(error)` yöntemine çağrıda bulunmanın bir farkı olmaz. Hatanın detayları davranışı etkiler. [`server.auth.scheme(name, scheme)`](https://hapijs.com/api#-serverauthschemename-scheme) hakkında daha fazla bilgi API dokümantasyonunda bulunabilir. Hatalar için [boom](https://github.com/hapijs/boom) kullanılması önerilir. +start(); +``` -### Yuk (`payload`) +`@hapi/cookie` kendi tipleriyle gelmez. TypeScript kullanıyorsan tipleri `npm install --save-dev @types/hapi__cookie` ile kur. Bu tipler hapi'nin `Request` tipine `request.cookieAuth` ekler ve `cookie` stratejisinin seçeneklerini tipler. Oturum `validate` fonksiyonuna düz bir `object` olarak gelir; TypeScript'in `session.id` değerini okumasını sağlayan şey `'id' in session` denetimidir. `POST /login` route'u form alanlarını `server.route()` çağrısına verilen bir tip argümanıyla bildirir; bu yalnızca bir tiptir, çalışma zamanında zorlamak için [payload doğrulaması](/tutorials/tr_TR/validation) ekle. -`payload` yöntemi `function (request, h)` imzasına sahiptir. +Önce `@hapi/cookie` plugin'ini `server.register` ile kaydedersin; bu, `cookie` şemasını ekler. Ardından `server.auth.strategy` ile bu şemadan `session` stratejisini oluşturur ve üç seçenek verirsin: -Burada da yine standart hapi yanıt alet takımı kullanılıyor. Hatalar için yine [boom](https://github.com/hapijs/boom) kullanmak tavsiye edilir. +- `cookie`, cookie'nin kendisini yapılandırır. `name` cookie'nin adıdır: `sid-example`. `password` cookie'yi şifreler ve en az 32 karakter uzunluğunda olmalıdır. `isSecure: false`, cookie'nin düz HTTP üzerinden gitmesine izin verir; bu geliştirmede sorun değildir, production'da varsayılan değer olan `true` olarak bırak. +- `redirectTo`, korumalı bir route isteyen ve kimliği doğrulanmamış bir kullanıcının sunucu tarafından gönderileceği yerdir. +- `validate`, cookie taşıyan her istekte çalışır ve oturumun hâlâ geçerli olduğunu denetler. Burada hesabı cookie'de saklanan `id` ile arar; böylece silinmiş bir hesabın oturumu bir sonraki isteğinde kapanır. -Başarılı bir kimlik doğrulama sinyali için `h.continue` dön. +`server.auth.default('session')` her route'u `session` stratejisiyle korur. `GET /login`, `auth: false` ile bunun dışında kalır, böylece formu herkes görebilir. -### Yanit (`response`) +`POST /login`, `mode: 'try'` kullanır; böylece kullanıcının henüz bir oturumu olmasa da handler çalışır. Handler hesabı `username` ile bulur ve gönderilen parolayı `Bcrypt` ile saklanan hash'le karşılaştırır. Denetimlerden biri başarısız olursa kullanıcı giriş sayfasına geri döner. İkisi de geçerse `request.cookieAuth.set()`, `{ id }` değerini şifrelenmiş cookie'de saklar ve kullanıcı ana sayfaya yönlendirilir. -`response` yöntemi de `function (request, h)` imzasına sahip ve standart yanıt alet takımını kullanıyor. +## jwt -Bu yöntem yanıt nesnesini kullanıcıya göndermeden önce ek başlıklarla dekore etmek için oluşturuldu. +[@hapi/jwt](/module/jwt), `Authorization: Bearer ` header'ında JSON Web Token taşıyan isteklerin kimliğini doğrular. `@hapi/cookie` gibi sunucuda oturum saklamaz; fark, kimliğin nasıl taşındığındadır: -Dekorasyon işlemi bittikten sonra, `h.continue` dönmelisin. Böylece yanıt gönderilir. +| | `@hapi/cookie` | `@hapi/jwt` | +| ---------------- | ----------------------------------------- | ----------------------------------------------------------------------- | +| Gönderildiği yer | tarayıcının eklediği bir cookie | istemcinin ayarladığı `Authorization` header'ı | +| İçerik | şifrelenmiş: istemci okuyamaz | imzalı ama token'a sahip olan herkes okuyabilir | +| Uygun olduğu yer | kendi alan adındaki tarayıcı uygulamaları | mobil uygulamalardan, diğer sunuculardan ve SPA'lardan çağrılan API'ler | -Eğer bir hata meydana gelirse, bunun yerine [boom](https://github.com/hapijs/boom) olmasını tavsiye ettiğimiz bir hata fırlatmalısın. +JWT payload'ına asla gizli bilgi koyma; imzalama değiştirmeyi engeller ama okumayı engellemez. -### Kayit Etmek +İstemci bir kez oturum açar, sonra token'ı her istekle gönderir: -Bir şema (`scheme`) kaydetmek için, `server.auth.scheme(name, scheme)` yöntemini kullan. isim (`name` ) değiştirgesi bu özel şemayı tanımlamak için kullanılan bir metin, şema (`scheme`) değiştirgesi yukarıda açıklanan bir yöntem olmalı. +```mermaid +sequenceDiagram + participant C as İstemci + participant S as hapi sunucusu + C->>S: kullanıcı adı ve parolayla POST /login + Note over S: auth: false, parolayı Bcrypt ile denetler + S-->>C: 200 sunucunun gizli anahtarıyla imzalanmış { token } + C->>S: Authorization: Bearer token ile GET /me + Note over S: jwt stratejisi imzayı, aud, iss ve token yaşını denetler,
sonra validate(artifacts) çağırır + S-->>C: 200 { id, name } +``` -## Stratejiler +```ts +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; +import Jwt from '@hapi/jwt'; -Şemanı kaydettikten sonra, bunu kullanmak için bir yola ihtiyacın var. İşte tam da burada stratejiler devreye giriyor. +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; -Yukarıda söylendiği gibi, bir strateji; önceden yapılandırılmış bir şema kopyasıdır. +const secret = 'hR7#mX2!vQ9@kL4$wP8^zT1&nB6*cJ3%'; -Bir strateji kaydetmek için, önce bir şema kaydetmiş olmalıyız. Bunu yaptıktan sonra, stratejiyi kaydetmek için `server.auth.strategy(name, scheme, [options])` kullanabiliriz. +const start = async () => { + const server = Hapi.server({ port: 4000 }); -`name` daha sonra bu özel stratejiyi tanımlamak için kullanılacak bir metin olmalı. Yine bir metin olan `scheme` değiştirgesi ise bu stratejinin örneği olacağı şemanın adı olmalı. + await server.register(Jwt); -### Secenekler + server.auth.strategy('bearer', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:my-api', + iss: 'urn:issuer:my-api', + sub: false, + maxAgeSec: 4 * 60 * 60, + }, + validate: async (artifacts, request, h) => { + const account = users.find((user) => user.id === artifacts.decoded.payload.id); + if (!account) { + return { isValid: false }; + } -Son değiştirge ise adlandırılan şemaya direk olarak gönderilecek şeçenekler (`options`). + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -### Varsayilan stratejiyi ayarlamak + server.auth.default('bearer'); -`server.auth.default()` kullanarak varsayılan stratejiyi ayarlayabilirsin. + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + options: { auth: false }, + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); -Bu metod bir değiştirge kabul eder. Bu değiştirge ya varsayılan stratejinin adını taşıyan metin, ya da yol işleyicinin kullandığı gibi bir [kimlik doğrulama seçenekleri (auth options)](#yol-route-ayarları) nesnesi olmalıdır. + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.response({ error: 'Invalid username or password' }).code(401); + } -## Yol (Route) Ayarlari + const token = Jwt.token.generate( + { aud: 'urn:audience:my-api', iss: 'urn:issuer:my-api', id: account.id }, + secret, + ); -Kimlik doğrulama (authentication) aynı zamanda `options.auth` değiştirgesi vasıtasıyla yol (route) üzerine ayarlanabilir. Eğer `false` olarak ayarlanırsa bu yolda (route) kimlik doğrulama kapatılır. + return { token }; + }, + }); -Kullanılacak stratejinin adını taşıyan bir metin ile ayarlanabileceği gibi, `mode`, `strategies` ve `payload` değiştirgeleri ile de ayarlanabilir. + server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return request.auth.credentials; + }, + }); -`mode` değiştirgesi `'required'` (zorunlu), `'optional'` (seçime bağlı) ya da `'try'` (dene) olarak ayarlanabilir ve aynı bir stratejiyi (strategy) kaydederkenki gibi çalışır. + await server.start(); -Eğer `'required'` (gerekli) olarak ayarlanırsa, kullanıcıların yola (route) erişebilmek için kimliklerini doğrulamış olmaları ve doğrulamalarının geçerli olması gerekir. Yoksa bir hata ile karşılaşırlar. + console.log('server running at: ' + server.info.uri); +}; -Eğer `mode` `'optional'` (seçime bağlı) olarak ayarlanırsa, strateji (strategy) yola (route) yine uygulanır ancak bu kez kullanıcının kimliğini doğrulamış olmasına *gerek yok*tur. Kimlik doğrulama verisi seçime bağlıdır ve eğer bulunuyorsa geçerli olmalıdır. +start(); +``` -Son `mode` ayarı ise `'try'`dır (dene). `'try'` (dene) ile `'optional'` (seçime bağlı) arasındaki fark, `'try'` (dene) kullanıldığında geçersiz kimlik doğrulamasının kabul edilmesi ve kullanıcının yol işleyiciye yine de ulaşabilmesidir. +Plugin'i kaydedersin, bu `jwt` şemasını ekler; ardından bu şemadan `bearer` stratejisini oluşturursun: -Bir strateji (strategy) belirlerken, `strategy` değiştirgesini stratejinin adı olarak ayarlayabilirsin. Birden fazla strateji (strategy) tanımlarken, değiştirgenin adı `strategies` olmalı ve değeri denenecek her bir stratejinin adını taşıyan karakter katarlarından oluşan bir dizi olmalıdır. Her bir strateji biri biri başarılı olana ya da hepsi hata verene kadar denenir. +- `keys`, token'ların imzalandığı gizli anahtardır. Token'ları aynı sunucu hem üretip hem denetliyorsa paylaşılan bir gizli anahtar yeterlidir. Bunun yerine bir kimlik sağlayıcısından gelen token'ları kabul etmek için `keys` sağlayıcının açık anahtarlarını gösterebilir; bkz. [@hapi/jwt API](/module/jwt) içindeki `keys` seçenekleri. +- `verify`, bir token'ın taşıması gereken claim'leri listeler. `aud` (audience) ve `iss` (issuer), token üretirken kullandığın değerlerle eşleşmelidir. `maxAgeSec`, `iat` (issued at) değeri dört saatten eski olan token'ları reddeder. Bu örnekteki token'larda `exp` yoktur; seninkilerde varsa `exp` ve `nbf` varsayılan olarak denetlenir. +- `validate` yalnızca imza ve claim'ler geçtikten sonra çalışır. Çözülmüş token'ı `artifacts.decoded.payload` olarak alır ve `request.auth.credentials` içine neyin gireceğine karar verir. Burada hesabın hâlâ var olduğunu da denetler, çünkü bir token, hesap silinse bile süresi dolana kadar geçerli kalır. -Son olarak, `payload` (yararlı yük) değiştirgesi `false` olarak ayarlanarak kimlik doğrulama yapılmamış olmasını kontrol etmeye ayarlanabilir. `'required'` ya da `true` ise kimlik doğrulamasının _zorunlu olduğu_ olduğu, ya da `'optional'`, eğer kullanıcı bu bilgileri sağlıyorsa geçerli olması gerektiği anlamında kullanılabilir. +`POST /login`, `auth: false` ile kimlik doğrulamanın dışında kalır, parolayı denetler ve stratejinin doğruladığı `aud` ve `iss` değerleriyle `Jwt.token.generate()` ile üretilen bir token döndürür. İstemci ardından `Authorization: Bearer ` ile `GET /me` çağırır. `artifacts.decoded.payload` tiplenmemiştir, çünkü hapi bir token'a hangi claim'leri koyduğunu bilemez; bunları denetleyip tiplenmiş kimlik bilgileri oluşturduğun yer `validate`'tir. -`payload` değiştirgesi yalnızca `payload` yöntemini destekleyen şemalarda kullanılabilir. +Diğer hapi kimlik doğrulama plugin'leri hakkında daha fazla bilgi için [plugin bölümüne](/plugins) bak. diff --git a/docs/tutorials/zh_CN/auth.md b/docs/tutorials/zh_CN/auth.md index 414a1431..29cb20a5 100644 --- a/docs/tutorials/zh_CN/auth.md +++ b/docs/tutorials/zh_CN/auth.md @@ -1,36 +1,369 @@ --- -title: Authentication +title: 身份认证 --- -## 身份认证 +# 身份认证 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更新版本_ -hapi 的身份认证基于两个概念:`schemes` 和 `strategies`。 +## 概述 -可以将 scheme 视为一般类型的认证, 例如 "basic" 或 "digest"。而 strategy 是一个预先配置好并且命名好的 scheme 实例。 +大多数现代 Web 应用都会使用某种形式的身份认证。hapi 的身份认证由两部分组成:`schemes` 和 `strategies`: -首先我们来看一下如何使用 [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic): +- `scheme` 是一种通用的认证方式,例如 "HTTP basic auth" 或 "session cookie"。`@hapi/basic`、`@hapi/cookie` 这样的插件各自注册一个 scheme。 +- `strategy` 是带有名称和配置的 scheme,例如 "basic auth,并在我的 users 表中验证"。路由通过名称引用 strategy。 -```javascript -'use strict'; +以 `@hapi/basic` 为例:插件注册 scheme,你基于它配置一个 strategy,路由再使用这个 strategy: -const Bcrypt = require('bcrypt'); -const Hapi = require('@hapi/hapi'); +```ts +// Registers the 'basic' scheme. +await server.register(Basic); -const users = { - john: { - username: 'john', - password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // '密码: secret' - name: 'John Doe', - id: '2133d32a', +server.auth.strategy('simple', 'basic', { validate }); + +server.route({ + method: 'GET', + path: '/', + options: { auth: 'simple' }, + handler: (request, h) => 'welcome', +}); +``` + +## Schemes + +`scheme` 是一个签名为 `function (server, options)` 的函数。`server` 是添加该 scheme 的服务器,`options` 是基于此 scheme 创建 strategy 时传入的配置对象。这个函数返回一个 scheme 对象,hapi 会在[请求生命周期](/api#request-lifecycle)的三个时间点调用该对象上的方法: + +```mermaid +flowchart TD + A["onRequest、路由查找、
cookie 解析、onPreAuth"] --> D["authenticate(request, h)
必需"] + D --> E[payload 解析] + E --> F["payload(request, h)
可选"] + F --> G["onCredentials、授权
(auth.access scopes)、onPostAuth"] + G --> J["验证、handler、
onPreResponse"] + J --> L["response(request, h)
可选"] + L --> M[发送响应] + + class D,F,L highlight +``` + +只有 `authenticate` 接受的请求才会运行 `payload` 和 `response`。 + +你可以编写自己的 scheme,也可以使用 hapi 的认证插件,例如 `@hapi/basic` 或 `@hapi/cookie`。下面是一个完整的 scheme,它从请求头中读取 API token: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +import type { ServerAuthScheme } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface UserCredentials { + id: string; + name: string; + } +} + +interface TokenOptions { + tokens: Map; +} + +const tokenScheme: ServerAuthScheme = (server, options) => { + return { + authenticate: (request, h) => { + const token = request.headers['x-api-token']; + if (typeof token !== 'string') { + throw Boom.unauthorized(null, 'token'); + } + + const user = options?.tokens.get(token); + if (!user) { + throw Boom.unauthorized('Invalid token', 'token'); + } + + return h.authenticated({ credentials: { user }, artifacts: { token } }); + }, + response: (request, h) => { + if (!Boom.isBoom(request.response)) { + request.response.header('x-authenticated-user', request.auth.credentials.user?.id ?? ''); + } + + return h.continue; + }, + }; +}; + +const server = Hapi.server({ port: 4000 }); + +server.auth.scheme('token', tokenScheme); +server.auth.strategy('api', 'token', { + tokens: new Map([['a1b2c3', { id: '2133d32a', name: 'John Doe' }]]), +}); + +server.route({ + method: 'GET', + path: '/me', + options: { auth: 'api' }, + handler: (request, h) => { + return request.auth.credentials.user; + }, +}); + +await server.start(); +``` + +`ServerAuthScheme` 为 scheme 接收的 `options` 提供类型。token 特意存放在 `Map` 而不是普通对象中:在普通对象上查找客户端提供的键时,也会找到继承来的属性,这样 `constructor` 或 `toString` 这样的 token 也能通过认证。`declare module` 代码块告诉 TypeScript 凭证中包含什么:hapi 导出了一个空的 `UserCredentials` 接口供你扩展,扩展之后,每个 handler 中的 `request.auth.credentials.user` 都有了类型。 + +### authenticate + +`authenticate` 方法的签名是 `function (request, h)`,它是 scheme 中唯一 _必需_ 的方法。`request` 与路由 handler 收到的[请求对象](/api#request)相同,`h` 是标准的[响应工具包](/api#response-toolkit)。 + +`authenticate` 有三种结束方式,各自通向不同的结果: + +```mermaid +flowchart TD + A["authenticate(request, h)"] --> OK["成功
h.authenticated()"] + A --> MISS["缺少凭证
错误不带消息,
只带 scheme 名称"] + A --> BAD["凭证无效
其他任何错误"] + OK --> OK2["handler 可以读取
request.auth.credentials"] + MISS --> MISS2["尝试下一个 strategy;若已没有,
由路由的 mode 决定"] + BAD --> BAD2["不再尝试其他 strategy;
由路由的 mode 决定"] + + class OK,MISS,BAD highlight +``` + +- **成功:** 返回 `h.authenticated({ credentials, artifacts })`。`credentials` 表示已认证的用户。`artifacts` 是可选的,用于存放其他认证数据,例如原始 token。之后两者分别可以通过 `request.auth.credentials` 和 `request.auth.artifacts` 访问。 +- **缺少凭证:** 抛出一个不带消息、只带 scheme 名称的错误,例如 `Boom.unauthorized(null, 'token')`。hapi 会认为该请求没有携带此 scheme 的凭证,于是继续尝试路由的下一个 strategy,scheme 名称也会出现在 `WWW-Authenticate` 响应头中。 +- **凭证无效:** 抛出其他任何错误,例如 `Boom.unauthorized('Invalid token', 'token')`。hapi 会停止尝试其他 strategy。既不带消息 _也_ 不带 scheme 名称的 `Boom.unauthorized()` 同样视为无效。 + +失败之后会发生什么,取决于路由的 [`mode`](#mode)。 + +除了抛出错误,你也可以返回 `h.unauthenticated(error, data)`。它的效果与抛出 `error` 相同,但可以通过 `data` 为失败的请求附加 `credentials` 和 `artifacts`,处于 `'try'` 模式的路由随后可以读取它们。不传 `data` 时,`return h.unauthenticated(error)` 与 `throw error` 完全相同。完整规则参见 [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme)。推荐使用 [boom](/module/boom) 来创建这些错误。 + +### payload + +`payload` 方法的签名是 `function (request, h)`。它在 payload 解析之后运行,因此可以检查 payload 本身,例如基于 payload 计算的签名。 + +payload 验证通过时返回 `h.continue`,失败时抛出错误。路由通过 [`payload` 路由选项](#route)开启 payload 认证。如果某个 scheme 总是需要 payload 认证,可以在其方法旁边返回 `options: { payload: true }`,这样路由就无法将其关闭。 + +### response + +`response` 方法的签名是 `function (request, h)`。它在 `onPreResponse` 之后、写入响应头之前运行,因此可以向 `request.response` 添加认证相关的响应头。上面的 token scheme 就用它添加了 `x-authenticated-user` 响应头。 + +`response` 运行时,hapi 已经把所有错误转换成了响应对象,因此错误响应也会带上这个响应头。`Boom.isBoom()` 检查将 `request.response` 收窄为响应对象,这样 `.header()` 才能通过类型检查。完成后返回 `h.continue`。如需让请求失败,抛出错误即可。 + +## Strategies + +在通过 `server.auth.strategy(name, scheme, [options])` 基于 scheme 创建 strategy 之前,scheme 不会起任何作用: + +- `name` 是 strategy 的名称,路由用它来选择 strategy。 +- `scheme` 是已注册 scheme 的名称。 +- `options` 会作为 `options` 参数传给 scheme 函数。 + +一个 scheme 可以支撑多个 strategy,每个 strategy 有各自的配置: + +```ts +server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, +}); + +server.auth.strategy('admin-session', 'cookie', { + cookie: { + name: 'sid-admin', + password: 'Q7v!kW2#pL9@zR4$mT6^xB1&nD8*cF3%', + isSecure: false, + }, + redirectTo: '/admin/login', + requestDecoratorName: 'adminCookieAuth', +}); +``` + +每个 `@hapi/cookie` strategy 都会添加一个 `request.cookieAuth` 辅助对象,而 hapi 不允许重复添加同一个请求装饰,所以第二个 strategy 需要通过 `requestDecoratorName` 为其辅助对象换个名称。它的路由调用 `request.adminCookieAuth.set()`。`@types/hapi__cookie` 包(参见 [cookie](#cookie))只为 `cookieAuth` 提供了类型,所以在 TypeScript 中还要声明改名后的辅助对象: + +```ts +declare module '@hapi/hapi' { + interface Request { + adminCookieAuth: Request['cookieAuth']; + } +} +``` + +## 默认 Strategy + +`server.auth.default()` 设置所有路由默认使用的 strategy,除非路由另有指定。它接受一个 strategy 名称,或一个与路由[认证选项](#route)格式相同的对象。默认值适用于所有 `auth` 选项中没有指定 strategy 的路由,包括在调用 `server.auth.default()` _之前_ 添加的路由。例外情况是 `auth` 只设置了 `mode` 或 `access` 的路由:hapi 会在添加路由时将其与默认值合并,所以要先调用 `server.auth.default()`。 + +```ts +server.auth.default('session'); + +server.route({ + method: 'GET', + path: '/account', + handler: (request, h) => 'protected by the session strategy', +}); + +server.route({ + method: 'GET', + path: '/health', + options: { auth: false }, + handler: (request, h) => 'public', +}); +``` + +## 路由配置 + +路由通过 `options.auth` 属性设置身份认证: + +```ts +server.route({ + method: 'GET', + path: '/feed', + options: { + auth: { + mode: 'try', + strategies: ['session', 'api'], + }, + }, + handler: (request, h) => { + if (!request.auth.isAuthenticated) { + return 'public feed'; + } + + return `personal feed, signed in with ${request.auth.strategy}`; }, +}); +``` + +`options.auth` 可以是: + +- `false`,关闭该路由的身份认证。 +- 一个 strategy 名称,例如 `'session'`。 +- 一个包含 `mode`、`strategy` 或 `strategies`,以及 `payload` 的对象。 + +### mode + +`mode` 决定如何处理没有凭证或凭证错误的请求: + +| `mode` | 没有凭证 | 凭证无效 | 凭证有效 | +| -------------------- | ------------------------ | ------------------------ | ------------ | +| `'required'`(默认) | 401 | 401 | 运行 handler | +| `'optional'` | 运行 handler,未认证状态 | 401 | 运行 handler | +| `'try'` | 运行 handler,未认证状态 | 运行 handler,未认证状态 | 运行 handler | + +"没有凭证" 和 "凭证无效" 是 [`authenticate` 失败](#authenticate)的两种情况。失败后运行 handler 时,`request.auth.isAuthenticated` 为 `false`,`request.auth.error` 保存失败原因:如果没有任何 strategy 找到凭证,则为 `Missing authentication`;如果某个 strategy 拒绝了凭证,则为该 strategy 抛出的错误。 + +### strategy 与 strategies + +使用一个 strategy 时写 `strategy: 'session'`,使用多个时写 `strategies: ['session', 'api']`。hapi 按顺序尝试,只有当前一个报告缺少凭证时才会尝试下一个: + +```mermaid +flowchart TD + R[请求] --> S1{"strategy 1
session"} + S1 -->|已认证| H[handler] + S1 -->|缺少凭证| S2{"strategy 2
api"} + S1 -->|凭证无效| E["失败:
由路由的 mode 决定"] + S2 -->|已认证| H + S2 -->|凭证无效或缺少凭证| E +``` + +### payload + +`payload` 为该路由开启 scheme 的 [`payload` 方法](#payload): + +- `false`:不对 payload 进行认证。 +- `'required'` 或 `true`:payload 必须通过认证。 +- `'optional'`:只有当客户端发送了 payload 认证信息时才进行检查。 + +它只对 scheme 带有 `payload` 方法的 strategy 有效。 + +## Scopes + +身份认证决定 _谁_ 在发起请求,scope 决定他们 _可以做什么_。strategy 把一组 scope 字符串放进 `credentials.scope`,路由在 `options.auth.access.scope` 中列出它接受的 scope,hapi 在授权阶段比较两者,这一阶段位于 `onCredentials` 之后、handler 运行之前。没有匹配 scope 的请求会得到 `403 Insufficient scope`,handler 不会运行。 + +scope 通常来自 strategy 的 `validate` 函数。沿用 [basic](#basic) 示例中的用户,并为每个用户添加一个 `roles` 数组: + +```ts +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); + if (!user || !(await Bcrypt.compare(password, user.password))) { + return { isValid: false, credentials: null }; + } + + return { isValid: true, credentials: { id: user.id, name: user.name, scope: user.roles } }; }; +``` + +如果角色信息存放在别处,可以在认证之后通过 `onCredentials` 钩子添加;参见[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks#credentials)。 + +然后由路由声明它们接受的 scope: + +```ts +server.route({ + method: 'GET', + path: '/reports', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'analyst'] } } }, + handler: (request, h) => 'any admin or analyst', +}); + +server.route({ + method: 'DELETE', + path: '/users/{id}', + options: { auth: { strategy: 'simple', access: { scope: ['+admin', '!suspended'] } } }, + handler: (request, h) => 'an admin who is not suspended', +}); + +server.route({ + method: 'PUT', + path: '/users/{id}/profile', + options: { auth: { strategy: 'simple', access: { scope: ['admin', 'user-{params.id}'] } } }, + handler: (request, h) => 'an admin, or the user whose id is in the path', +}); +``` + +| 路由 scope | `credentials.scope` 必须 | +| ---------------------- | ----------------------------------------------------------------------------------------- | +| `'admin'` | 包含 `admin` | +| `['admin', 'analyst']` | 至少包含其中一个 | +| `'+admin'` | 包含 `admin`,无论列表中的其他项如何 | +| `'!suspended'` | 不包含 `suspended` | +| `'user-{params.id}'` | 对 `/users/42` 的请求须包含 `user-42`;`query`、`payload` 和 `credentials` 也可以这样使用 | + +容易忽略的 scope 规则: + +- 只有已认证的请求才会检查 scope。在 `'optional'` 或 `'try'` 模式的路由上,没有凭证的请求仍然会进入 handler,此时 `request.auth.isAuthorized` 为 `false`。在执行只允许部分用户进行的操作之前,先检查它。 +- `access.entity` 将路由限制为用户或应用:`'user'` 要求设置了 `credentials.user`,`'app'` 要求没有设置。当同一个 strategy 既认证人也认证机器客户端时使用它。 +- `access` 可以是一组规则,请求只需满足其中之一。 +- `server.auth.default()` 接受同样的 `access` 选项,因此可以让某个 scope 应用于所有路由。 + +完整规则参见 [`route.options.auth.access`](/api#route-options-auth-access)。 -const validate = async (request, username, password) => { - const user = users[username]; +## basic + +我们首先来看的 `scheme` 是 [@hapi/basic](/module/basic) 插件。顾名思义,`@hapi/basic` 插件使用 basic 认证来验证用户。下面是配置 `@hapi/basic` 的示例: + +```ts +import Basic from '@hapi/basic'; +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; + +const users = new Map([ + [ + 'john', + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, + ], +]); + +const validate: Basic.Validate = async (request, username, password) => { + const user = users.get(username); if (!user) { return { credentials: null, isValid: false }; } @@ -44,7 +377,7 @@ const validate = async (request, username, password) => { const start = async () => { const server = Hapi.server({ port: 4000 }); - await server.register(require('hapi-auth-basic')); + await server.register(Basic); server.auth.strategy('simple', 'basic', { validate }); @@ -67,91 +400,262 @@ const start = async () => { start(); ``` -首先定义我们的 `users` 数据库, 在这个示例中只是一个简单的对象。之后我们定一个验证函数, 这个函数是 [hapi-auth-basic](https://github.com/hapijs/hapi-auth-basic) 的一个特性,可以允许我们验证用户是否提供了有效的凭证, -之后我们注册这个插件。随后我们创建一个名为 `basic`的 scheme ,这个可以通过[server.auth.scheme()](/api#serverauthschemename-scheme)来完成。 +首先定义 `users` 数据库,本例中它是一个以用户名为键的 `Map`,原因见 [Schemes](#schemes)。然后定义一个验证函数,这是 [@hapi/basic](/module/basic) 特有的功能,用于确认用户提供的凭证有效。将其类型标注为 `Basic.Validate`,TypeScript 就能推断出 `request`、`username` 和 `password` 的类型,并检查你返回的对象。在这个验证函数中,使用 `Bcrypt` 将用户提供的密码与数据库中的哈希密码进行比较。 -当这个插件被注册后, 我们可以使用 [server.auth.strategy()](/api#serverauthstrategyname-scheme-mode-options) 去创建一个名为 `simple` 的 strategy。 这个代表的就是我们名为 `basic` 的 scheme。 同时也传递了一个选项对象,这样我们可以配置 scheme 的行为。 +接着注册插件,插件会创建一个名为 `basic` 的 scheme。这是插件内部通过 [server.auth.scheme()](/api#server-auth-scheme-name-scheme) 完成的。 -最后我们告诉路由使用名为 `simple` 的 strategy 来做身份验证。 +插件注册之后,使用 [server.auth.strategy()](/api#server-auth-strategy-name-scheme-options) 创建一个名为 `simple` 的 strategy,它引用名为 `basic` 的 scheme。同时传入一个选项对象,该对象会传给 scheme,用于配置其行为。 -## Schemes +最后,让路由使用名为 `simple` 的 strategy 进行身份认证。 -`scheme` 是一个拥有签名 `function (server, options)` 的方法。 `server` 参数是需要添加这个 scheme 的服务器对象的引用,`options` 参数是使用它注册 strategy 时的配置。 +## cookie -这个方法返回的对象 _至少_ 包含了 `authenticate` 方法。而其他可用的非必须返回的方法是 `payload` 和 `response`。 +[@hapi/cookie](/module/cookie) 插件会在用户认证之后,把会话存放在用户浏览器中的一个加密 cookie 里。如果没有设置 `cookie.ttl`,cookie 会在浏览器关闭时失效;设置 `ttl` 可以让用户在多次浏览器会话之间保持登录。 -### `authenticate` +在本例中,首页路由 "/" 受到保护,只有登录后才能访问。浏览器被重定向到登录表单,提交用户名和密码,然后携带会话 cookie 进入首页: -`authenticate` 方法的签名为 `function (request, h)`, 也是 scheme 中唯一 _必须_ 有的方法。 +```mermaid +sequenceDiagram + participant B as 浏览器 + participant S as hapi 服务器 + B->>S: GET / + Note over S: 没有会话 cookie,session strategy 失败 + S-->>B: 302 重定向到 /login (redirectTo) + B->>S: GET /login + S-->>B: 200 登录表单 (auth: false) + B->>S: POST /login,携带用户名和密码 + Note over S: mode 'try':handler 在没有会话的情况下运行
并用 Bcrypt 检查密码 + S-->>B: 302 重定向到 /,Set-Cookie sid-example(加密的 { id }) + B->>S: GET /,携带 Cookie sid-example + Note over S: validate(request, session) 通过 session.id 查找账户 + S-->>B: 200 Welcome to the restricted home page! +``` -在这里 `request` 为服务器创建的 `request` 对象。 这个对象与路由 handler 中的对象为同一个, 完整的文档可以参阅 [API reference](/api#request-object)。 +```ts +import Bcrypt from 'bcrypt'; +import Cookie from '@hapi/cookie'; +import Hapi from '@hapi/hapi'; -`h` 是 hapi 中的 [response toolkit](https://hapijs.com/api#response-toolkit). +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; -当身份认证成功时, 你必须调用并且返回 `h.authenticated({ credentials, artifacts })`。 `credentials` 是一个代表身份验证用户 (或者用户将要进行身份认证的凭证) 的对象。 另外 `artifacts` 包含了除用户凭证之外的其余验证信息。 +const start = async () => { + const server = Hapi.server({ port: 4000 }); -`credentials` 和 `artifacts` 作为 `request.auth`对象的一部分,也可以在之后的处理过程中访问 (例如:路由 handler)。 + await server.register(Cookie); -如果身份验证不成功, 你可以抛出一个错误或者调用并返回 `h.unauthenticated(error, [data])`。 这里的 `error` 是一个身份验证错误,`data` 是一个可选对象,它包含了 `credentials` 和 `artifacts`。 如果你抛出的错误没有 `data` 对象,那么将和你调用 `return h.unauthenticated(error)` 没有什么区别。 这些错误传递的细节将会对行为有一定的影响,更多细节可以 API 文档中找到 [`server.auth.scheme(name, scheme)`](https://hapijs.com/api#-serverauthschemename-scheme)。我们推荐你使用 [boom](https://github.com/hapijs/boom) 来处理错误。 + server.auth.strategy('session', 'cookie', { + cookie: { + name: 'sid-example', + password: '!wsYhFA*C2U6nz=Bu^%A@^F#SF3&kSR6', + isSecure: false, + }, + redirectTo: '/login', + validate: async (request, session) => { + if (!session || !('id' in session)) { + return { isValid: false }; + } + + const account = users.find((user) => user.id === session.id); + if (!account) { + return { isValid: false }; + } + + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -### `payload` + server.auth.default('session'); -`payload` 方法拥有签名 `function (request, h)`。 + server.route([ + { + method: 'GET', + path: '/', + handler: function (request, h) { + return 'Welcome to the restricted home page!'; + }, + }, + { + method: 'GET', + path: '/login', + handler: function (request, h) { + return ` + + Login page + + +

Please Log In

+
+ Username:
+ Password:
+
+ + `; + }, + options: { + auth: false, + }, + }, + ]); -同样,这里也可以获得到 hapi 的响应对象。 我们同样建议你使用 [boom](https://github.com/hapijs/boom) 进行错误处理。 + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); -要发出身份验证成功的信号请返回 `h.continue`。 + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.redirect('/login'); + } -### `response` + request.cookieAuth.set({ id: account.id }); -`response` 方法拥有签名 `function (request, h)` 也拥有 hapi标准的 response toolkit。 + return h.redirect('/'); + }, + options: { + auth: { + mode: 'try', + }, + }, + }); -在响应发送会给用户之前,此方法旨在装饰响应对象 (`request.response`),可以为其添加额外的头部信息。 + await server.start(); -当包装完毕后,你必须返回 `h.continue`, 这样响应才会被发送出去。 + console.log('server running at: ' + server.info.uri); +}; -当错误发生时,我们依然建议你使用 [boom](https://github.com/hapijs/boom) 进行错误处理。 +start(); +``` -### 注册 +`@hapi/cookie` 本身不带类型定义。如果使用 TypeScript,请通过 `npm install --save-dev @types/hapi__cookie` 安装。它会为 hapi 的 `Request` 类型添加 `request.cookieAuth`,并为 `cookie` strategy 的选项提供类型。会话以普通 `object` 的形式传入 `validate`,正是 `'id' in session` 这个检查让 TypeScript 可以读取 `session.id`。`POST /login` 路由通过 `server.route()` 的类型参数声明表单字段;这只是类型,要在运行时强制约束,请添加 [payload 验证](/tutorials/zh_CN/validation)。 -注册一个 scheme 使用 `server.auth.scheme(name, scheme)`。其中 `name` 是一个字符串,用于标明特定的 scheme, 而 `scheme` 就是上文提到的方法。 +首先用 `server.register` 注册 `@hapi/cookie` 插件,它会添加 `cookie` scheme。然后用 `server.auth.strategy` 基于它创建 `session` strategy,并传入三个选项: -## Strategies +- `cookie` 配置 cookie 本身。`name` 是 cookie 的名称 `sid-example`。`password` 用于加密 cookie,长度至少为 32 个字符。`isSecure: false` 允许 cookie 通过普通 HTTP 传输,在开发环境中没有问题;生产环境中请保留默认值 `true`。 +- `redirectTo` 是未认证用户访问受保护路由时,服务器将其重定向到的地址。 +- `validate` 在每个携带该 cookie 的请求上运行,检查会话是否仍然有效。这里它根据 cookie 中存储的 `id` 查找账户,因此被删除的账户会在下一次请求时被登出。 -当你注册了你的 scheme 后,你需要一种方式去使用它。这就是为什么引入了 strategies 。 +`server.auth.default('session')` 用 `session` strategy 保护所有路由。`GET /login` 通过 `auth: false` 退出认证,任何人都能看到登录表单。 -正如之前提到的,strategy 其实是一个预先配置好的 scheme 实例拷贝。 +`POST /login` 使用 `mode: 'try'`,因此即使用户还没有会话,handler 也会运行。它通过 `username` 查找账户,并使用 `Bcrypt` 将提交的密码与存储的哈希进行比较。任一检查失败,用户都会回到登录页。两项都通过时,`request.cookieAuth.set()` 将 `{ id }` 存入加密 cookie,用户被重定向到首页。 -使用 strategy 时, 我们必须保证有一个 scheme 已经被注册。准本好之后,你就可以使用 `server.auth.strategy(name, scheme, [options])` 去注册你的 strategy。 +## jwt -`name` 参数必须是一个字符串, 用于标明一个特定的 strategy。 `scheme` 其实也是一个字符串, 是实例化这个 strategy 的 scheme 的名称。 +[@hapi/jwt](/module/jwt) 对在 `Authorization: Bearer ` 请求头中携带 JSON Web Token 的请求进行认证。与 `@hapi/cookie` 一样,它不在服务器上存储会话;区别在于身份信息的传递方式: -### 选项 +| | `@hapi/cookie` | `@hapi/jwt` | +| -------- | ------------------------------ | --------------------------------------- | +| 发送方式 | 浏览器自动附加的 cookie | 客户端设置的 `Authorization` 请求头 | +| 内容 | 加密:客户端无法读取 | 已签名,但持有 token 的任何人都能读取 | +| 适用场景 | 运行在你自己域名下的浏览器应用 | 由移动应用、其他服务器和 SPA 调用的 API | -最后的可选参数是 `options`, 这个参数将直接传递给已经命名的 scheme 。 +不要在 JWT payload 中放入机密信息,因为签名只能防止篡改,不能防止读取。 -### 设置一个默认的 strategy +客户端登录一次,之后每个请求都携带 token: -你可以通过使用 `server.auth.default()` 设置一个默认的 strategy。 +```mermaid +sequenceDiagram + participant C as 客户端 + participant S as hapi 服务器 + C->>S: POST /login,携带用户名和密码 + Note over S: auth: false,用 Bcrypt 检查密码 + S-->>C: 200 { token },使用服务器的密钥签名 + C->>S: GET /me,携带 Authorization: Bearer token + Note over S: jwt strategy 检查签名、aud、iss 和 token 时长,
然后调用 validate(artifacts) + S-->>C: 200 { id, name } +``` -这个方法接受一个参数, 这个参数可以是一个默认 strategy 的名字,或者与路由handler [auth options](#route-configuration) 同样格式的对象。 +```ts +import Bcrypt from 'bcrypt'; +import Hapi from '@hapi/hapi'; +import Jwt from '@hapi/jwt'; -## 路由配置 +const users = [ + { + username: 'john', + password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' + name: 'John Doe', + id: '2133d32a', + }, +]; + +const secret = 'hR7#mX2!vQ9@kL4$wP8^zT1&nB6*cJ3%'; -身份认证也可以在一个路由上通过 `options.auth` 参数进行配置。如果设置为 `false` 将会在路由上关闭身份验证。 +const start = async () => { + const server = Hapi.server({ port: 4000 }); -它可以通过 strategy 将要使用的名字去配置,或者一个拥有 `mode`, `strategies`, 和`payload` 参数的对象去配置。 + await server.register(Jwt); -`mode` 参数可以被设置为 `'required'`, `'optional'`, 或者`'try'`。 这三种参数在注册 strategy 的时候方式相同。 + server.auth.strategy('bearer', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:my-api', + iss: 'urn:issuer:my-api', + sub: false, + maxAgeSec: 4 * 60 * 60, + }, + validate: async (artifacts, request, h) => { + const account = users.find((user) => user.id === artifacts.decoded.payload.id); + if (!account) { + return { isValid: false }; + } + + return { isValid: true, credentials: { id: account.id, name: account.name } }; + }, + }); -如果 `mode` 设置为 `'required'` ,在访问路由的时候必须对用户进行身份进行验证,并且验证必须有效,否则他们将收到错误。 + server.auth.default('bearer'); -如果 `mode` 设置为 `'optional'` , 路由也将会使用这个 strategy 。但是用户 _不必_ 进行身份验证。一旦如果验证信息提供了,那就必须是有效的。 + server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/login', + options: { auth: false }, + handler: async (request, h) => { + const { username, password } = request.payload; + const account = users.find((user) => user.username === username); + + if (!account || !(await Bcrypt.compare(password, account.password))) { + return h.response({ error: 'Invalid username or password' }).code(401); + } + + const token = Jwt.token.generate( + { aud: 'urn:audience:my-api', iss: 'urn:issuer:my-api', id: account.id }, + secret, + ); + + return { token }; + }, + }); + + server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return request.auth.credentials; + }, + }); + + await server.start(); + + console.log('server running at: ' + server.info.uri); +}; + +start(); +``` -最后 `mode` 可以被设置为 `'try'`。 `'try'` 与 `'optional'` 的区别在于:如果用 `'try'` ,不合法的身份验证是被接受的, 用户依然可以访问路由 handler。 +注册插件,它会添加 `jwt` scheme,然后基于它创建 `bearer` strategy: -当只需一个 strategy 时,你可以设置 `strategy` 属性为 strategy 的名字。如果指定多个 strategy 时, 参数名称必须为 `strategies`, 属性为 strategy 名称的数组。 这些 strategies 将会按照顺序执行一直到有第一个成功的, 否则的话将全部失败。 +- `keys` 是用于签名 token 的密钥。当签发和验证 token 的是同一台服务器时,使用共享密钥即可。如果要接受身份提供方签发的 token,`keys` 可以指向该提供方的公钥;参见 [@hapi/jwt API](/module/jwt) 中的 `keys` 选项。 +- `verify` 列出 token 必须携带的声明。`aud`(受众)和 `iss`(签发者)必须与签发 token 时使用的值一致。`maxAgeSec` 会拒绝 `iat`(签发时间)超过四小时的 token。本例中的 token 不带 `exp`;如果你的 token 带有它,默认会检查 `exp` 和 `nbf`。 +- `validate` 只在签名和声明都通过后运行。它以 `artifacts.decoded.payload` 的形式接收解码后的 token,并决定哪些内容放入 `request.auth.credentials`。这里它还会检查账户是否仍然存在,因为即使账户已被删除,token 在过期前依然有效。 -最后 `payload` 参数可以被设置为 `false` 表示内容信息不需要被认证。如果设置为 `'required'` 或者 `true` 则意味着内容 _必须_ 被验证。 如果设置为 `'optional'` 意味着如果客户端一旦包含了内容认证的信息,这些信息就必须是有效的。 +`POST /login` 通过 `auth: false` 退出认证,检查密码,然后返回由 `Jwt.token.generate()` 生成的 token,其 `aud` 和 `iss` 与 strategy 验证的值相同。之后客户端携带 `Authorization: Bearer ` 调用 `GET /me`。`artifacts.decoded.payload` 没有类型,因为 hapi 无法知道你在 token 中放了哪些声明;`validate` 正是检查这些声明并构建带类型凭证的地方。 -`payload` 只有在支持`payload` 方法的 strategy 中才会生效。 +更多 hapi 认证插件的信息,请参见[插件列表](/plugins)。 From 943a9b3a36dfe19d73221df1c4b587a415490332 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:56 -0400 Subject: [PATCH 07/22] docs: rework caching tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/caching.md | 255 ++++++++++++++------- docs/tutorials/es_ES/caching.md | 340 ++++++++++++++++++++++++++++ docs/tutorials/ko_KR/caching.md | 292 ++++++++++++++++-------- docs/tutorials/pt_BR/caching.md | 385 +++++++++++++++++++++----------- docs/tutorials/tr_TR/caching.md | 291 ++++++++++++++++-------- docs/tutorials/zh_CN/caching.md | 292 ++++++++++++++++-------- 6 files changed, 1341 insertions(+), 514 deletions(-) create mode 100644 docs/tutorials/es_ES/caching.md diff --git a/docs/tutorials/en_US/caching.md b/docs/tutorials/en_US/caching.md index b8d93844..cc6872ab 100644 --- a/docs/tutorials/en_US/caching.md +++ b/docs/tutorials/en_US/caching.md @@ -6,25 +6,26 @@ title: Caching # Caching -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -One of the best ways to improve website performance is to configure caching on your server. hapi makes it easy to configure both client-side and server-side caching. +hapi caches at two levels, and most applications use both: -## Client-side Caching +| Level | Where the copy lives | What it saves | How you configure it | +| --------------------------- | -------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | +| [Client-side](#client-side) | the browser, or a CDN or proxy in between | the whole request: the client never reaches hapi | HTTP headers: `Cache-Control`, `ETag`, `Last-Modified` | +| [Server-side](#server-side) | memory, Redis, or another [catbox](/module/catbox) store | the expensive work: a database query, an API call | `server.cache()` or a cached [server method](/tutorials/en_US/server-methods) | -The HTTP protocol defines several HTTP headers to instruct how clients, such as browsers, should cache resources. To learn more about these headers and to decide which are suitable for your use-case check out this useful [guide put together by Google](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching). +## Client-side Caching -The first part of this tutorial shows how to easily configure hapi to send these headers to clients. +HTTP headers tell clients whether, and for how long, they can reuse a response. MDN's [HTTP caching guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) explains the headers in depth. This section shows how hapi sets them. ### Cache-Control -The `Cache-Control` header tells the browser and any intermediate caches if a resource is cacheable and for what duration. For example, `Cache-Control:max-age=30, must-revalidate, private` means that the browser can cache the associated resource for thirty seconds and `private` means it should not be cached by intermediate caches, only by the browser. `must-revalidate` means that once it expires it has to request the resource again from the server. - -Let's see how you can set this header in hapi: +Set the route's `cache` option, and hapi writes the `Cache-Control` header for you: -```javascript +```ts server.route({ path: '/hapi/{ttl?}', method: 'GET', @@ -32,7 +33,7 @@ server.route({ const response = h.response({ be: 'hapi' }); if (request.params.ttl) { - response.ttl(request.params.ttl); + response.ttl(Number(request.params.ttl)); } return response; @@ -46,62 +47,85 @@ server.route({ }); ``` -The above example shows how you can set a `cache` options object on a route. Here you set `expiresIn` to 30 seconds and `privacy` to private. -The example also illustrates that the `expiresIn` value can be overridden with the `ttl(msec)` method provided by the [response object](/api#response-object) interface. +`response.ttl(msec)` overrides `expiresIn` for a single response. The header hapi sends depends on the response: -If you make a request to `/hapi` you'll receive the response header `cache-control: max-age=30, must-revalidate, private`. If you make a request to `/hapi/5000` you'll instead get the header `cache-control: max-age=5, must-revalidate, private`. +| Request | `Cache-Control` header | +| ----------------------------------------------------------- | ------------------------------------------ | +| `GET /hapi` | `max-age=30, must-revalidate, private` | +| `GET /hapi/5000` (calls `response.ttl(5000)`) | `max-age=5, must-revalidate, private` | +| response status other than 200 or 204, such as a 404 or 500 | `no-cache` (the route's `cache.otherwise`) | -See [route-options](/api#route-options) for more information about common `cache` configuration options. +What to know: -### Last-Modified +- If the request is authenticated, hapi always adds `private`, even when the route says `privacy: 'public'`. Shared caches such as CDNs never store `private` responses. +- Cookies set with `h.state()` do not change the header: hapi writes them after it builds `Cache-Control`. Set `privacy: 'private'` yourself on cached routes that set cookies. +- Only status codes listed in the route's `cache.statuses` (default `[200, 204]`) get `max-age`. Error responses are never cached by accident. -In some cases, the server can provide information about when a resource was last modified. When using the [inert](/module/inert) plugin for serving static content, a `Last-Modified` header is added automatically to every response. +See [route options](/api#route-options-cache) for every `cache` setting. -When the `Last-Modified` header is set on a response, hapi compares it with the `If-Modified-Since` header coming from the client to decide if the response status code should be `304 Not Modified`. This is also known as a conditional GET request. The advantage being that there's no need for the browser to download the file again for a `304` response. +### Conditional requests: ETag and Last-Modified -Assuming `lastModified` is a `Date` object you can set this header via the [response object](/api#response-object) as seen here: +A conditional request lets the client ask "has this changed since my copy?" and get an empty `304 Not Modified` instead of the full body: -```javascript -return h.response(result).header('Last-Modified', lastModified.toUTCString()); +```mermaid +sequenceDiagram + participant B as Browser + participant S as hapi server + B->>S: GET /report + S-->>B: 200 with body, ETag "v42" + Note over B: stores the body and the ETag + B->>S: GET /report with If-None-Match "v42" + Note over S: handler runs and sets ETag "v42" again,
hapi sees a match + S-->>B: 304 Not Modified, no body ``` -There is one more example using `Last-Modified` in the [last section](#clientandserver) of this tutorial. +Set the `ETag` with [`response.etag()`](/api#response-object), usually from a version number or a hash of the content: -### ETag - -The ETag header is an alternative to `Last-Modified` where the server provides a token (usually a checksum of the resource) instead of a last modified timestamp. The browser will use this token to set the `If-None-Match` header in the next request. The server compares this header value with the new `ETag` checksum and responds with `304` if they are the same. +```ts +return h.response(report).etag(String(report.version)); +``` -You only need to set `ETag` in your handler via `etag(tag, options)` function: +Or set `Last-Modified`, and hapi compares it with the client's `If-Modified-Since` header: -```javascript -return h.response(result).etag('xxxxxxxxx'); +```ts +return h.response(report).header('Last-Modified', report.updatedAt.toUTCString()); ``` -Check the documentation of `etag` under the [response object](/api#response-object) for more details about the arguments and available options. +What to know: + +- hapi only answers `304` on `GET` routes (and routes with method `*`). +- The handler still runs on every request. Conditional requests save bandwidth, not server work. Pair them with [server-side caching](#server-side) to save both. +- [inert](/module/inert) sets `ETag` and `Last-Modified` automatically for static files. ## Server-side Caching -hapi provides powerful, convenient server-side caching via [catbox](/module/catbox). This tutorial section will help you understand how to use catbox. +hapi caches values on the server through [catbox](/module/catbox), a key-value store with interchangeable storage adapters: memory (the default), [Redis](/module/catbox-redis), and [Memcached](/module/catbox-memcached). ### catbox -[catbox](/module/catbox) is a multi-strategy key-value object store. It comes with extensions supporting a memory cache, [Redis](https://redis.io), and [Memcached](http://memcached.org). +Cached items are organized in three levels: -In order to reduce module dependencies, catbox does not include the external caching strategies. To use other strategies, each service must be manually installed via npm or package dependencies manually. +```mermaid +flowchart TD + C["cache client
one storage connection, such as Redis"] --> S1["segment '#sum'
a server method's items"] + C --> S2["segment '!my-plugin'
a plugin's items"] + S1 --> I1["item id '1:5'"] + S1 --> I2["item id '2:3'"] + S2 --> I3["item id 'user:42'"] +``` -catbox has two interfaces; client and policy. +catbox has two interfaces to these items: -### Client +- **[Client](/module/catbox#client)**: the low-level storage connection. hapi creates a memory client for you, and you add others in the server's `cache` option. +- **[Policy](/module/catbox#policy)**: a segment of a client, with expiration rules and an optional function that generates missing values. Your code uses policies, created with [`server.cache()`](/api#server-cache-options) or by caching a [server method](#servermethods). -[Client](/module/catbox/api#client) is a low-level interface that allows you set/get key-value pairs. It is initialized with one of the available adapters: ([Memory](/module/catbox-memory), [Redis](/module/catbox-redis), or [Memcached](/module/catbox-memcached). - -hapi initializes a default [client](/module/catbox#client) using the [catbox memory](/module/catbox-memory) adapter. Let's see how you can define another client using the [redis](http://redis.io/) strategy. +### Client -```javascript -'use strict'; +Add a Redis client named `my_cache` next to the default memory client: -const Hapi = require('@hapi/hapi'); -const CatboxRedis = require('@hapi/catbox-redis'); +```ts +import Hapi from '@hapi/hapi'; +import { Engine as CatboxRedis } from '@hapi/catbox-redis'; const server = Hapi.server({ port: 8000, @@ -123,40 +147,46 @@ const server = Hapi.server({ }); ``` -In the above example, you defined a new catbox client, `my_cache`. Including the default memory cache created by hapi, there are now two available cache clients. You can replace the default client by omitting the `name` property when registering a new cache client. `partition` tells the adapter how cache should be named ('catbox' by default). In the case of [redis](http://redis.io/) it is used as key prefix. +- `name` is how policies pick this client, with `cache: 'my_cache'`. Leave `name` out to replace the default memory client instead. +- `partition` keeps this application's keys apart from others sharing the Redis database. In Redis it becomes a key prefix. It defaults to `'catbox'`. +- Cache clients connect when the server starts, in [`server.initialize()`](/api#await-server-initialize) or [`server.start()`](/api#await-server-start). Policies cannot read or write before then. ### Policy -[Policy](/module/catbox/api#policy) is a more high-level interface than Client. The following is a simple example of caching the result of adding two numbers together. The principles of this simple example can be applied to any situation where you want to cache the result of a function call, async or otherwise. [server.cache(options)]() creates a new [policy](/module/catbox/api#policy), which is then used in the route handler. +[`server.cache(options)`](/api#server-cache-options) creates a policy. This example caches the result of a slow addition for ten seconds: -```javascript -const start = async () => { - const server = Hapi.server(); +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; - const add = async (a, b) => { - await Hoek.wait(1000); // Simulate some slow I/O +const add = async (a: string, b: string) => { + await Hoek.wait(1000); // Simulates a slow database call - return Number(a) + Number(b); - }; + return Number(a) + Number(b); +}; + +const start = async () => { + const server = Hapi.server({ port: 8000 }); const sumCache = server.cache({ - cache: 'my_cache', + segment: 'sums', expiresIn: 10 * 1000, - segment: 'customSegment', generateFunc: async (id) => { - return await add(id.a, id.b); + const key = typeof id === 'string' ? id : id.id; + const [a, b] = key.split(':'); + + return await add(a, b); }, generateTimeout: 2000, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; - const id = `${a}:${b}`; - return await sumCache.get({ id, a, b }); + return await sumCache.get(`${a}:${b}`); }, }); @@ -168,45 +198,83 @@ const start = async () => { start(); ``` -If you make a request to `http://localhost:8000/add/1/5`, you should get the response `6` after about a second. If you hit that endpoint again the response should come immediately because it's being served from the cache. If you were to wait 10s and then call it again, you'd see that it took a while because the cached value has now been ejected from the cache. +`GET /add/1/5` answers `6` after about a second. Repeat it within ten seconds and it answers immediately, from the cache. + +| Option | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `segment` | Names this policy's items. Required at the server root. Inside a plugin it defaults to `'!' + plugin name`. Two policies cannot share a segment unless they set `shared: true`. | +| `cache` | The client to use, such as `'my_cache'`. Defaults to the memory client. | +| `expiresIn` | How long an item lives after it is stored, in milliseconds. `expiresAt: 'HH:MM'` expires everything at a time of day instead. | +| `generateFunc` | Creates the value when `get()` finds nothing. catbox stores the result. | +| `generateTimeout` | How long `get()` waits for `generateFunc` before failing with `503 Service Unavailable`. Required with `generateFunc`; `false` waits forever. | + +The id passed to `get()` is either a string or an object with an `id` string, and `generateFunc` receives it unchanged. catbox types it as `string | { id: string }`, so the example narrows it before splitting it back into `a` and `b`. -`server.cache(options)` provisions a cache segment within the server cache facility. In this case, your policy will be using `'my_cache'` which you created above with `server.cache`. +### Serving stale values while regenerating -`expiresIn` states the time, in milliseconds, the cache will expire in relation to the time the item was saved in the cache. In this case, our cache will expire 10 seconds after the item was saved. +An expired item makes the next caller wait for `generateFunc`. To avoid that wait, mark items stale before they expire with `staleIn`. A stale item is still served, while catbox regenerates it in the background: + +```mermaid +flowchart TD + G["sumCache.get(id)"] --> Q{item in cache?} + Q -->|"fresh: younger than staleIn"| F[return cached value] + Q -->|"stale: older than staleIn"| R["call generateFunc"] + R --> R1["new value within staleTimeout:
return and store it"] + R --> R2["slower than staleTimeout:
return the stale value now,
store the new one when it arrives"] + Q -->|missing or expired| M["call generateFunc"] + M --> M1["value within generateTimeout:
return and store it"] + M --> M2["slower than generateTimeout:
503 Service Unavailable"] +``` -`segment` that allow you to further isolate caches within one [client](/module/catbox/api#client) partition. If you want to cache results from two different methods, you usually don't want to mix the results together. In [redis](http://redis.io/), `segment` is an additional prefix along with the `partition` option. The default value for `segment` when [server.cache()](/api#-servercacheoptions) is called inside of a plugin will be `'!pluginName'`. When creating [server methods](/tutorials/servermethods), the `segment` value will be `'#methodName'`. If you have a use case for multiple policies sharing one segment there is a [shared](/api#-servercacheoptions) option available as well. +```ts +const reportCache = server.cache({ + segment: 'reports', + expiresIn: 60 * 60 * 1000, + staleIn: 10 * 60 * 1000, + staleTimeout: 100, + generateTimeout: 5000, + generateFunc: async (id) => await buildReport(id), +}); +``` -`generateFunc` is a function that will generate a new cache item if one is not found in the cache when calling `get()`. In this example, the generate function will generate a value of two numbers add together. The `generateFunc` function will also be called if an item in the cache exists, but is found to be stale. You can set the time when an item in the cache will be stale by configuring the `staleIn` option. `staleIn` is a number in milliseconds to mark an item stored in cache as stale and attempt to regenerate it when `generateFunc` is provided. `staleIn` must be less than `expiredIn`. +catbox enforces `staleIn < expiresIn`, requires `staleTimeout` whenever `staleIn` is set, and requires `staleTimeout < expiresIn - staleIn`. Concurrent `get()` calls for the same id share one `generateFunc` call, so a burst of requests for a missing item queries your database once. See the [policy options](/module/catbox#policy) for error handling settings such as `dropOnError` and `generateOnReadError`. -`generateTimeout` is the number of milliseconds to wait before returning a timeout error when the `generateFunc` function takes too long to return a value. +### Server methods -`get(id)` is used to retrieve an item from the cache. If the item is not found, and the `generateFunc` method was provided, a new value is generated, stored in the cache, and is returned. +Most of the time, a cached [server method](/tutorials/en_US/server-methods) is shorter than a hand-made policy. hapi creates the policy, names its segment `'#sum'`, and builds the item id from the arguments: -The first parameter of the `sumCache.get()` function is an id, which may either be a string or an object with a mandatory property `id`, which is a unique cache item identifier. +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -Look into catbox policy [options](/module/catbox/api#policy) and pay extra attention to `staleIn`, `staleTimeout`, `generateTimeout`, to leverage the full potential of catbox caching. +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise; + } +} -### Server methods +const add = async (a: string, b: string) => { + await Hoek.wait(1000); -But it can get better than that! In 95% cases you will use server methods for caching purposes, because it reduces boilerplate to minimum. Here's a rewrite of the previous example using a server method: + return Number(a) + Number(b); +}; -```javascript const start = async () => { - const server = Hapi.server(); + const server = Hapi.server({ port: 8000 }); server.method('sum', add, { cache: { - cache: 'my_cache', expiresIn: 10 * 1000, generateTimeout: 2000, }, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; + return await server.methods.sum(a, b); }, }); @@ -217,28 +285,41 @@ const start = async () => { start(); ``` -[server.method()]() created a new [policy](/module/catbox/api#policy) with `segment: '#sum'` automatically for us. Also the unique item `id` (cache key) was automatically generated from parameters. By default, it handles `string`, `number` and `boolean` parameters. For more complex parameters, you have to provide your own `generateKey` function to create unique ids based on the parameters - check out the server methods tutorial for more information. +- The `declare module` block adds `sum` to hapi's `ServerMethods` interface, so `server.methods.sum` has a type. +- hapi builds the item id from `string`, `number`, and `boolean` arguments, such as `'1:5'`. Other argument types need a [`generateKey`](/tutorials/en_US/server-methods#key) function. ## Client and Server caching -Optionally, [Catbox Policy](/module/catbox/api#policy) can provide more information about the value retrieved from the cache. To enable this set the `getDecoratedValue` option to `true` when creating the policy. Any value returned from the server method will then be an object `{ value, cached, report }`. `value` is just the item from the cache, `cached` and `report` provides some extra details about the cache state of the item. +Combine the two levels: the server cache records when it stored each value, and that time is the right `Last-Modified` for the response. Set `getDecoratedValue: true`, and the cached method returns `{ value, cached, report }` instead of the bare value. `cached` is `null` when the value was just generated: + +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -An example of server-side and client-side caching working together is using the `cached.stored` timestamp to set the `last-modified` header: +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise<{ value: number; cached: { stored: number } | null }>; + } +} + +const add = async (a: string, b: string) => { + await Hoek.wait(1000); + + return Number(a) + Number(b); +}; -```javascript const start = async () => { - const server = Hapi.server(); + const server = Hapi.server({ port: 8000 }); - server.method('sum', add, { - cache: { - cache: 'my_cache', - expiresIn: 10 * 1000, - generateTimeout: 2000, - getDecoratedValue: true, - }, - }); + const cache = { + expiresIn: 10 * 1000, + generateTimeout: 2000, + getDecoratedValue: true, + }; + + server.method('sum', add, { cache }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { @@ -246,12 +327,14 @@ const start = async () => { const { value, cached } = await server.methods.sum(a, b); const lastModified = cached ? new Date(cached.stored) : new Date(); - return h.response(value).header('Last-modified', lastModified.toUTCString()); + return h.response({ sum: value }).header('Last-Modified', lastModified.toUTCString()); }, }); await server.start(); }; + +start(); ``` -You can find more details about `cached` and `report` in the [Catbox Policy API docs](/module/catbox/api#api-1). +See the [policy API](/module/catbox#api-1) for what `cached` and `report` contain. diff --git a/docs/tutorials/es_ES/caching.md b/docs/tutorials/es_ES/caching.md new file mode 100644 index 00000000..0ff43766 --- /dev/null +++ b/docs/tutorials/es_ES/caching.md @@ -0,0 +1,340 @@ +--- +title: Caché +--- + + + +# Caché + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +hapi cachea en dos niveles, y la mayoría de las aplicaciones usan los dos: + +| Nivel | Dónde está la copia | Qué se ahorra | Cómo se configura | +| ------------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [En el cliente](#client-side) | el navegador, o una CDN o un proxy intermedio | la petición entera: el cliente nunca llega a hapi | cabeceras HTTP: `Cache-Control`, `ETag`, `Last-Modified` | +| [En el servidor](#server-side) | memoria, Redis u otro almacén de [catbox](/module/catbox) | el trabajo costoso: una consulta a la base de datos, una llamada a una API | `server.cache()` o un [método de servidor](/tutorials/es_ES/server-methods) cacheado | + +## Caché en el cliente + +Las cabeceras HTTP indican a los clientes si pueden reutilizar una respuesta y durante cuánto tiempo. La [guía de caché HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) de MDN explica las cabeceras en profundidad. Esta sección muestra cómo las fija hapi. + +### Cache-Control + +Configura la opción `cache` de la ruta y hapi escribe la cabecera `Cache-Control` por ti: + +```ts +server.route({ + path: '/hapi/{ttl?}', + method: 'GET', + handler: function (request, h) { + const response = h.response({ be: 'hapi' }); + + if (request.params.ttl) { + response.ttl(Number(request.params.ttl)); + } + + return response; + }, + options: { + cache: { + expiresIn: 30 * 1000, + privacy: 'private', + }, + }, +}); +``` + +`response.ttl(msec)` sobrescribe `expiresIn` para una sola respuesta. La cabecera que envía hapi depende de la respuesta: + +| Petición | Cabecera `Cache-Control` | +| ------------------------------------------------------------------- | -------------------------------------------- | +| `GET /hapi` | `max-age=30, must-revalidate, private` | +| `GET /hapi/5000` (llama a `response.ttl(5000)`) | `max-age=5, must-revalidate, private` | +| respuesta con un código distinto de 200 o 204, como un 404 o un 500 | `no-cache` (el `cache.otherwise` de la ruta) | + +Lo que conviene saber: + +- Si la petición está autenticada, hapi siempre añade `private`, aunque la ruta indique `privacy: 'public'`. Las cachés compartidas, como las CDN, nunca guardan respuestas `private`. +- Las cookies fijadas con `h.state()` no cambian la cabecera: hapi las escribe después de construir `Cache-Control`. Fija tú mismo `privacy: 'private'` en las rutas cacheadas que crean cookies. +- Solo los códigos de estado incluidos en el `cache.statuses` de la ruta (por defecto `[200, 204]`) reciben `max-age`. Las respuestas de error nunca se cachean por accidente. + +Consulta las [opciones de ruta](/api#route-options-cache) para ver todos los ajustes de `cache`. + +### Peticiones condicionales: ETag y Last-Modified + +Una petición condicional permite al cliente preguntar "¿ha cambiado esto desde mi copia?" y recibir un `304 Not Modified` vacío en lugar del cuerpo completo: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as Servidor hapi + B->>S: GET /report + S-->>B: 200 con cuerpo, ETag "v42" + Note over B: guarda el cuerpo y el ETag + B->>S: GET /report con If-None-Match "v42" + Note over S: el handler se ejecuta y vuelve a fijar ETag "v42",
hapi ve que coincide + S-->>B: 304 Not Modified, sin cuerpo +``` + +Fija el `ETag` con [`response.etag()`](/api#response-object), normalmente a partir de un número de versión o de un hash del contenido: + +```ts +return h.response(report).etag(String(report.version)); +``` + +O fija `Last-Modified`, y hapi lo compara con la cabecera `If-Modified-Since` del cliente: + +```ts +return h.response(report).header('Last-Modified', report.updatedAt.toUTCString()); +``` + +Lo que conviene saber: + +- hapi solo responde `304` en rutas `GET` (y en rutas con método `*`). +- El handler se sigue ejecutando en cada petición. Las peticiones condicionales ahorran ancho de banda, no trabajo del servidor. Combínalas con la [caché en el servidor](#server-side) para ahorrar las dos cosas. +- [inert](/module/inert) fija `ETag` y `Last-Modified` automáticamente para los ficheros estáticos. + +## Caché en el servidor + +hapi cachea valores en el servidor mediante [catbox](/module/catbox), un almacén clave-valor con adaptadores de almacenamiento intercambiables: memoria (el valor por defecto), [Redis](/module/catbox-redis) y [Memcached](/module/catbox-memcached). + +### catbox + +Los elementos cacheados se organizan en tres niveles: + +```mermaid +flowchart TD + C["cliente de caché
una conexión de almacenamiento, como Redis"] --> S1["segmento '#sum'
los elementos de un método de servidor"] + C --> S2["segmento '!my-plugin'
los elementos de un plugin"] + S1 --> I1["id de elemento '1:5'"] + S1 --> I2["id de elemento '2:3'"] + S2 --> I3["id de elemento 'user:42'"] +``` + +catbox tiene dos interfaces para acceder a estos elementos: + +- **[Client](/module/catbox#client)**: la conexión de almacenamiento de bajo nivel. hapi crea un cliente de memoria por ti, y tú añades otros en la opción `cache` del servidor. +- **[Policy](/module/catbox#policy)**: un segmento de un cliente, con reglas de caducidad y una función opcional que genera los valores que faltan. Tu código usa policies, creadas con [`server.cache()`](/api#server-cache-options) o cacheando un [método de servidor](#servermethods). + +### Client + +Añade un cliente de Redis llamado `my_cache` junto al cliente de memoria por defecto: + +```ts +import Hapi from '@hapi/hapi'; +import { Engine as CatboxRedis } from '@hapi/catbox-redis'; + +const server = Hapi.server({ + port: 8000, + cache: [ + { + name: 'my_cache', + provider: { + constructor: CatboxRedis, + options: { + partition: 'my_cached_data', + host: 'redis-cluster.domain.com', + port: 6379, + database: 0, + tls: {}, + }, + }, + }, + ], +}); +``` + +- `name` es como las policies eligen este cliente, con `cache: 'my_cache'`. Omite `name` para sustituir en su lugar el cliente de memoria por defecto. +- `partition` separa las claves de esta aplicación de las de otras que comparten la base de datos de Redis. En Redis se convierte en un prefijo de clave. Por defecto vale `'catbox'`. +- Los clientes de caché se conectan cuando arranca el servidor, en [`server.initialize()`](/api#await-server-initialize) o [`server.start()`](/api#await-server-start). Las policies no pueden leer ni escribir antes de ese momento. + +### Policy + +[`server.cache(options)`](/api#server-cache-options) crea una policy. Este ejemplo cachea durante diez segundos el resultado de una suma lenta: + +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; + +const add = async (a: string, b: string) => { + await Hoek.wait(1000); // Simulates a slow database call + + return Number(a) + Number(b); +}; + +const start = async () => { + const server = Hapi.server({ port: 8000 }); + + const sumCache = server.cache({ + segment: 'sums', + expiresIn: 10 * 1000, + generateFunc: async (id) => { + const key = typeof id === 'string' ? id : id.id; + const [a, b] = key.split(':'); + + return await add(a, b); + }, + generateTimeout: 2000, + }); + + server.route<{ Params: { a: string; b: string } }>({ + path: '/add/{a}/{b}', + method: 'GET', + handler: async function (request, h) { + const { a, b } = request.params; + + return await sumCache.get(`${a}:${b}`); + }, + }); + + await server.start(); + + console.log('Server running at:', server.info.uri); +}; + +start(); +``` + +`GET /add/1/5` responde `6` al cabo de un segundo, más o menos. Si lo repites antes de diez segundos, responde al instante, desde la caché. + +| Opción | Significado | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `segment` | Da nombre a los elementos de esta policy. Obligatoria en la raíz del servidor. Dentro de un plugin, por defecto vale `'!' + plugin name`. Dos policies no pueden compartir segmento salvo que fijen `shared: true`. | +| `cache` | El cliente que se usa, como `'my_cache'`. Por defecto, el cliente de memoria. | +| `expiresIn` | Cuánto vive un elemento después de guardarse, en milisegundos. `expiresAt: 'HH:MM'` hace que todo caduque a una hora del día en su lugar. | +| `generateFunc` | Crea el valor cuando `get()` no encuentra nada. catbox guarda el resultado. | +| `generateTimeout` | Cuánto espera `get()` a `generateFunc` antes de fallar con `503 Service Unavailable`. Obligatoria con `generateFunc`; `false` espera indefinidamente. | + +El id que se pasa a `get()` es una cadena o un objeto con una cadena `id`, y `generateFunc` lo recibe sin cambios. catbox lo tipa como `string | { id: string }`, así que el ejemplo lo acota antes de volver a dividirlo en `a` y `b`. + +### Servir valores obsoletos mientras se regeneran + +Un elemento caducado hace que el siguiente que lo pide espere a `generateFunc`. Para evitar esa espera, marca los elementos como obsoletos (stale) antes de que caduquen con `staleIn`. Un elemento obsoleto se sigue sirviendo mientras catbox lo regenera en segundo plano: + +```mermaid +flowchart TD + G["sumCache.get(id)"] --> Q{¿está en caché?} + Q -->|"fresco: más reciente que staleIn"| F[devuelve el valor cacheado] + Q -->|"obsoleto: más antiguo que staleIn"| R["llama a generateFunc"] + R --> R1["valor nuevo dentro de staleTimeout:
lo devuelve y lo guarda"] + R --> R2["más lento que staleTimeout:
devuelve ya el valor obsoleto,
guarda el nuevo cuando llega"] + Q -->|no está o ha caducado| M["llama a generateFunc"] + M --> M1["valor dentro de generateTimeout:
lo devuelve y lo guarda"] + M --> M2["más lento que generateTimeout:
503 Service Unavailable"] +``` + +```ts +const reportCache = server.cache({ + segment: 'reports', + expiresIn: 60 * 60 * 1000, + staleIn: 10 * 60 * 1000, + staleTimeout: 100, + generateTimeout: 5000, + generateFunc: async (id) => await buildReport(id), +}); +``` + +catbox exige `staleIn < expiresIn`, exige `staleTimeout` siempre que se fije `staleIn`, y exige `staleTimeout < expiresIn - staleIn`. Las llamadas concurrentes a `get()` con el mismo id comparten una única llamada a `generateFunc`, así que una ráfaga de peticiones de un elemento que falta consulta tu base de datos una sola vez. Consulta las [opciones de policy](/module/catbox#policy) para los ajustes de gestión de errores, como `dropOnError` y `generateOnReadError`. + +### Métodos de servidor + +La mayoría de las veces, un [método de servidor](/tutorials/es_ES/server-methods) cacheado es más corto que una policy hecha a mano. hapi crea la policy, llama a su segmento `'#sum'` y construye el id del elemento a partir de los argumentos: + +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; + +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise; + } +} + +const add = async (a: string, b: string) => { + await Hoek.wait(1000); + + return Number(a) + Number(b); +}; + +const start = async () => { + const server = Hapi.server({ port: 8000 }); + + server.method('sum', add, { + cache: { + expiresIn: 10 * 1000, + generateTimeout: 2000, + }, + }); + + server.route<{ Params: { a: string; b: string } }>({ + path: '/add/{a}/{b}', + method: 'GET', + handler: async function (request, h) { + const { a, b } = request.params; + + return await server.methods.sum(a, b); + }, + }); + + await server.start(); +}; + +start(); +``` + +- El bloque `declare module` añade `sum` a la interfaz `ServerMethods` de hapi, para que `server.methods.sum` tenga tipo. +- hapi construye el id del elemento a partir de argumentos `string`, `number` y `boolean`, como `'1:5'`. Otros tipos de argumento necesitan una función [`generateKey`](/tutorials/es_ES/server-methods#key). + +## Caché en el cliente y en el servidor + +Combina los dos niveles: la caché del servidor registra cuándo guardó cada valor, y ese momento es el `Last-Modified` adecuado para la respuesta. Fija `getDecoratedValue: true`, y el método cacheado devuelve `{ value, cached, report }` en lugar del valor sin más. `cached` es `null` cuando el valor se acaba de generar: + +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; + +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise<{ value: number; cached: { stored: number } | null }>; + } +} + +const add = async (a: string, b: string) => { + await Hoek.wait(1000); + + return Number(a) + Number(b); +}; + +const start = async () => { + const server = Hapi.server({ port: 8000 }); + + const cache = { + expiresIn: 10 * 1000, + generateTimeout: 2000, + getDecoratedValue: true, + }; + + server.method('sum', add, { cache }); + + server.route<{ Params: { a: string; b: string } }>({ + path: '/add/{a}/{b}', + method: 'GET', + handler: async function (request, h) { + const { a, b } = request.params; + const { value, cached } = await server.methods.sum(a, b); + const lastModified = cached ? new Date(cached.stored) : new Date(); + + return h.response({ sum: value }).header('Last-Modified', lastModified.toUTCString()); + }, + }); + + await server.start(); +}; + +start(); +``` + +Consulta la [API de policy](/module/catbox#api-1) para ver qué contienen `cached` y `report`. diff --git a/docs/tutorials/ko_KR/caching.md b/docs/tutorials/ko_KR/caching.md index fe65cde4..cb951371 100644 --- a/docs/tutorials/ko_KR/caching.md +++ b/docs/tutorials/ko_KR/caching.md @@ -1,26 +1,31 @@ --- -title: Caching +title: 캐싱 --- -## Caching +# 캐싱 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -### 클라이언트 쪽 캐싱 +## 개요 -HTTP 프로토콜은 브라우저 같은 클라이언트가 자원을 캐시하는 방법을 가르치는 몇 가지 HTTP 헤더를 정의합니다. 이 헤더에 대해 자세히 알아보고 사용 사례가 적합한 것인지 결정하려면 유용한 [구글에서 작성한 가이드](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching)를 확인하세요. +hapi는 두 단계에서 캐싱하며, 대부분의 애플리케이션은 둘 다 사용합니다. -이 튜터리얼의 첫 부분은 클라이어트에게 이 헤더들을 전송하기 위해 hapi를 쉽게 설정하는 방법을 보여줍니다. +| 단계 | 사본이 저장되는 곳 | 절약되는 것 | 설정 방법 | +| ----------------------------- | -------------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------- | +| [클라이언트 측](#client-side) | 브라우저, 또는 중간의 CDN이나 프록시 | 요청 전체: 클라이언트가 hapi에 도달하지 않음 | HTTP 헤더: `Cache-Control`, `ETag`, `Last-Modified` | +| [서버 측](#server-side) | 메모리, Redis, 또는 다른 [catbox](/module/catbox) 저장소 | 비용이 큰 작업: 데이터베이스 쿼리, API 호출 | `server.cache()` 또는 캐싱된 [서버 메서드](/tutorials/ko_KR/server-methods) | -#### Cache-Control +## 클라이언트 측 캐싱 -`Cache-Control` 헤더는 자원이 캐시 가능 여부와 캐시 기간을 브라우저와 중간 캐시에 알려줍니다. 예를 들어 `Cache-Control:max-age=30, must-revalidate, private`은 브라우저는 관련된 자원을 30초 동안 캐시 할 수 있음을 의미하고 `private`는 브라우저 외에 다른 중간 캐시에서는 캐시 될 수 없음을 의미합니다. `must-revalidate`는 시간이 만료되면 서버에 다시 자원을 요청해야 하는 것을 의미합니다. +HTTP 헤더는 클라이언트가 응답을 재사용해도 되는지, 그리고 얼마 동안 재사용할 수 있는지 알려 줍니다. 헤더에 대한 자세한 설명은 MDN의 [HTTP 캐싱 가이드](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching)를 참고하세요. 이 섹션에서는 hapi가 이 헤더를 설정하는 방법을 보여 줍니다. -hapi에서 이 헤더를 어떻게 설정하는지 보겠습니다.: +### Cache-Control -```javascript +라우트의 `cache` 옵션을 설정하면 hapi가 `Cache-Control` 헤더를 작성해 줍니다. + +```ts server.route({ path: '/hapi/{ttl?}', method: 'GET', @@ -28,7 +33,7 @@ server.route({ const response = h.response({ be: 'hapi' }); if (request.params.ttl) { - response.ttl(request.params.ttl); + response.ttl(Number(request.params.ttl)); } return response; @@ -42,107 +47,146 @@ server.route({ }); ``` -위 예제는 경로에서 `cache` 옵션 객체를 설정하는 방법을 보여줍니다. `expiresIn`을 30초로 설정하고 `privacy`를 private로 설정합니다. -이 예제는 또한 `expiresIn` 값이 [response 객체](/api#response-object) 인터페이스에서 제공하는 `ttl(msec)` 메소드로 덮여질 수 있음을 보여줍니다. +`response.ttl(msec)`는 응답 하나에 대해 `expiresIn`을 덮어씁니다. hapi가 보내는 헤더는 응답에 따라 달라집니다. -`/hapi`에 요청하면 응답 헤더 `cache-control: max-age=30, must-revalidate, private`를 받을 것입니다. `/hapi/5000`에 요청하면 대신 응답 헤더 `cache-control: max-age=5, must-revalidate, private`를 받을 것입니다. +| 요청 | `Cache-Control` 헤더 | +| -------------------------------------------------- | --------------------------------------- | +| `GET /hapi` | `max-age=30, must-revalidate, private` | +| `GET /hapi/5000` (`response.ttl(5000)` 호출) | `max-age=5, must-revalidate, private` | +| 404나 500처럼 상태 코드가 200 또는 204가 아닌 응답 | `no-cache` (라우트의 `cache.otherwise`) | -공통적인 `cache` 설정 옵션에 대한 자세한 정보는 [route-options](/api#route-options)을 참고하세. +알아 둘 점: -#### Last-Modified +- 인증된 요청이면 라우트에 `privacy: 'public'`이라고 되어 있어도 hapi는 항상 `private`을 추가합니다. CDN 같은 공유 캐시는 `private` 응답을 저장하지 않습니다. +- `h.state()`로 설정한 쿠키는 헤더를 바꾸지 않습니다. hapi는 `Cache-Control`을 만든 뒤에 쿠키를 기록하기 때문입니다. 쿠키를 설정하는 캐싱 라우트에는 직접 `privacy: 'private'`을 지정하세요. +- 라우트의 `cache.statuses`(기본값 `[200, 204]`)에 나열된 상태 코드만 `max-age`를 받습니다. 에러 응답이 실수로 캐싱되는 일은 없습니다. -때에 따라 서버가 자원이 마지막으로 수정된 시간 정보를 제공할 수 있습니다. 정적 콘텐츠를 제공하는 [inert](https://github.com/hapijs/inert) 플러그인을 사용할 때, 모든 응답 페이로드에 `Last-Modified` 헤더가 추가됩니다. +모든 `cache` 설정은 [라우트 옵션](/api#route-options-cache)을 참고하세요. -응답에 `Last-Modified` 헤더가 설정되었을 때, hapi는 응답의 `Last-Modified` 헤더에 설정된 시간과 클라이언트에게서 오는 `If-Modified-Since` 헤더의 시간을 비교하여 응답 상태 코드가 `304`가 될지 결정합니다. 이를 조건부 GET 요청이라고 합니다. 장점은 304응답에 대해 브라우저가 다시 파일을 내려받을 필요가 없습니다. +### 조건부 요청: ETag와 Last-Modified -`lastModifed`가 Date 객체라고 가정하고 다음처럼 [response 객체](/api#response-object)를 통해 헤더를 설정할 수 있습니다. +조건부 요청을 사용하면 클라이언트가 "내 사본 이후로 바뀐 게 있나?"라고 물어보고, 전체 본문 대신 빈 `304 Not Modified`를 받을 수 있습니다. -```javascript -return h.response(result).header('Last-Modified', lastModified.toUTCString()); +```mermaid +sequenceDiagram + participant B as 브라우저 + participant S as hapi 서버 + B->>S: GET /report + S-->>B: 200 본문과 ETag "v42" + Note over B: 본문과 ETag를 저장 + B->>S: If-None-Match "v42"와 함께 GET /report + Note over S: 핸들러가 실행되어 다시 ETag "v42"를 설정,
hapi가 일치를 확인 + S-->>B: 304 Not Modified, 본문 없음 ``` -이 튜터리얼의 [마지막 절](#client-and-server-caching)에 `Last-Modified`를 사용한 또 다른 예제가 있습니다. - -#### ETag +`ETag`는 [`response.etag()`](/api#response-object)로 설정하며, 보통 버전 번호나 콘텐츠의 해시를 사용합니다. -ETag 헤더는 마지막 수정된 시간 대신 서버가 제공하는 토큰(보통 자원의 체크섬)으로 `Last-Modified`의 대안입니다. 브라우저는 다음 요청의 `If-None-Match` 헤더를 설정할 때 이 토큰을 합니다. 서버는 이 헤더의 값과 새로운 `ETag` 체크섬을 비교하여 같은 값이면 `304`로 응답합니다. +```ts +return h.response(report).etag(String(report.version)); +``` -`etag(tag, options)` 함수로 `ETag`를 설정하기만 하면 됩니다.: +또는 `Last-Modified`를 설정하면, hapi가 이를 클라이언트의 `If-Modified-Since` 헤더와 비교합니다. -```javascript -return h.response(result).etag('xxxxxxxxx'); +```ts +return h.response(report).header('Last-Modified', report.updatedAt.toUTCString()); ``` -인자와 가능한 옵션에 대한 더 자세한 내용은 [response 객체](/api#response-object)에서 `etag` 문서를 확인하세요. +알아 둘 점: -### 서버 쪽 캐싱 +- hapi는 `GET` 라우트(그리고 method가 `*`인 라우트)에서만 `304`로 응답합니다. +- 핸들러는 여전히 매 요청마다 실행됩니다. 조건부 요청은 대역폭을 절약할 뿐 서버 작업을 줄이지는 않습니다. 둘 다 절약하려면 [서버 측 캐싱](#server-side)과 함께 사용하세요. +- [inert](/module/inert)는 정적 파일에 `ETag`와 `Last-Modified`를 자동으로 설정합니다. -hapi는 [catbox](https://www.github.com/hapijs/catbox)을 통해 강력한 서버 쪽 캐싱을 제공하고 캐시 사용을 매우 편리하게 만듭니다. 튜터리얼의 이 절은 catbox가 hapi 내부에서 어떻게 활용되고 어떻게 활용할 것인지 이해하는 데 도움이 될 것입니다. +## 서버 측 캐싱 -Catbox는 두 개의 인터페이스를 가지고 있습니다; client와 policy. +hapi는 [catbox](/module/catbox)를 통해 서버에 값을 캐싱합니다. catbox는 저장소 어댑터를 바꿔 끼울 수 있는 키-값 저장소로, 메모리(기본값), [Redis](/module/catbox-redis), [Memcached](/module/catbox-memcached) 어댑터가 있습니다. -#### Client +### catbox + +캐싱된 항목은 세 단계로 구성됩니다. + +```mermaid +flowchart TD + C["캐시 클라이언트
Redis 같은 저장소 연결 하나"] --> S1["세그먼트 '#sum'
서버 메서드의 항목"] + C --> S2["세그먼트 '!my-plugin'
플러그인의 항목"] + S1 --> I1["항목 id '1:5'"] + S1 --> I2["항목 id '2:3'"] + S2 --> I3["항목 id 'user:42'"] +``` -[Client](https://github.com/hapijs/catbox#client)는 키-값 쌍을 설정 / 가져올 수 있는 저수준 인터페이스입니다. 사용가능한 어댑터로 초기화 됩니다: ([Memory](https://github.com/hapijs/catbox-memory), [Redis](https://github.com/hapijs/catbox-redis), [mongoDB](https://github.com/hapijs/catbox-mongodb), [Memcached](https://github.com/hapijs/catbox-memcached), or [Riak](https://github.com/DanielBarnes/catbox-riak)) +catbox는 이 항목들에 접근하는 두 가지 인터페이스를 제공합니다. -hapi는 [catbox_memory](https://github.com/hapijs/catbox-memory) 어댑터를 사용하여 기본으로 [client](https://github.com/hapijs/catbox#client)를 초기화합니다. 여러 클라이언트를 정의하는 방법을 보겠습니다. +- **[클라이언트](/module/catbox#client)**: 저수준 저장소 연결입니다. hapi가 메모리 클라이언트를 만들어 주며, 다른 클라이언트는 서버의 `cache` 옵션에 추가합니다. +- **[정책](/module/catbox#policy)**: 클라이언트의 세그먼트 하나로, 만료 규칙과 없는 값을 생성하는 함수(선택)를 가집니다. 여러분의 코드는 정책을 사용하며, 정책은 [`server.cache()`](/api#server-cache-options)로 만들거나 [서버 메서드](#servermethods)를 캐싱해서 만듭니다. -```javascript -'use strict'; +### 클라이언트 -const Hapi = require('@hapi/hapi'); +기본 메모리 클라이언트 옆에 `my_cache`라는 Redis 클라이언트를 추가합니다. + +```ts +import Hapi from '@hapi/hapi'; +import { Engine as CatboxRedis } from '@hapi/catbox-redis'; const server = Hapi.server({ port: 8000, cache: [ { - name: 'mongoCache', - engine: require('catbox-mongodb'), - host: '127.0.0.1', - partition: 'cache', - }, - { - name: 'redisCache', - engine: require('catbox-redis'), - host: '127.0.0.1', - partition: 'cache', + name: 'my_cache', + provider: { + constructor: CatboxRedis, + options: { + partition: 'my_cached_data', + host: 'redis-cluster.domain.com', + port: 6379, + database: 0, + tls: {}, + }, + }, }, ], }); ``` -위 예제에서 두 개의 catbox client를 정의했습니다.; mongoCache와 redisCache. hapi가 생성한 기본 메모리 캐시를 포함하여 총 3개의 가능한 캐시 client가 있습니다. 새로운 캐시 client를 등록할 때 `name` 속성을 생략하면 기본 client를 교체할 수 있습니다. `partition`은 어댑터에 캐시 이름을 알려줍니다. (기본은 `catbox`) [mongoDB](http://www.mongodb.org/)의 경우 데이터베이스 이름이고 [redis](http://redis.io/)의 경우 키의 접두사로 사용됩니다. +- 정책은 `cache: 'my_cache'`처럼 `name`으로 이 클라이언트를 선택합니다. `name`을 생략하면 기본 메모리 클라이언트를 대체합니다. +- `partition`은 같은 Redis 데이터베이스를 공유하는 다른 애플리케이션과 이 애플리케이션의 키를 분리합니다. Redis에서는 키 접두사가 됩니다. 기본값은 `'catbox'`입니다. +- 캐시 클라이언트는 서버가 시작될 때, 즉 [`server.initialize()`](/api#await-server-initialize)나 [`server.start()`](/api#await-server-start)에서 연결됩니다. 그 전에는 정책이 읽거나 쓸 수 없습니다. -#### Policy +### 정책 -[Policy](https://github.com/hapijs/catbox#policy)는 client 보다 고수준 인터페이스입니다. 다음은 두 숫자를 더한 결과를 캐싱하는 간단한 예제입니다. 이 간단한 예제의 원리는 함수, async 또는 다른 결과를 캐시하는 다른 상황에도 적용될 수 있습니다. [server.cache(options)](/api#servercacheoptions)는 새로운 [policy](https://github.com/hapijs/catbox#policy)를 생성하고 경로 처리기에서 사용됩니다. +[`server.cache(options)`](/api#server-cache-options)는 정책을 만듭니다. 이 예제는 느린 덧셈의 결과를 10초 동안 캐싱합니다. -```javascript -const start = async () => { - const add = async (a, b) => { - await Hoek.wait(1000); // Simulate some slow I/O +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; - return Number(a) + Number(b); - }; +const add = async (a: string, b: string) => { + await Hoek.wait(1000); // Simulates a slow database call + + return Number(a) + Number(b); +}; + +const start = async () => { + const server = Hapi.server({ port: 8000 }); const sumCache = server.cache({ - cache: 'mongoCache', + segment: 'sums', expiresIn: 10 * 1000, - segment: 'customSegment', generateFunc: async (id) => { - return await add(id.a, id.b); + const key = typeof id === 'string' ? id : id.id; + const [a, b] = key.split(':'); + + return await add(a, b); }, generateTimeout: 2000, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; - const id = `${a}:${b}`; - return await sumCache.get({ id, a, b }); + return await sumCache.get(`${a}:${b}`); }, }); @@ -154,76 +198,128 @@ const start = async () => { start(); ``` -http://localhost:8000/add/1/5 에 요청하면 1초 뒤에 응답 `6`을 받을 것입니다. 다시 요청하면 캐시에서 제공되기 때문에 응답을 즉시 받을 것입니다. 10초를 기다린 후, 다시 요청하면 캐시에서 제거되었기 때문에 시간이 좀더 걸리는 것을 볼 수 있습니다. +`GET /add/1/5`는 약 1초 뒤에 `6`을 응답합니다. 10초 안에 다시 요청하면 캐시에서 즉시 응답합니다. + +| 옵션 | 의미 | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `segment` | 이 정책의 항목에 붙이는 이름입니다. 서버 루트에서는 필수입니다. 플러그인 안에서는 기본값이 `'!' + plugin name`입니다. `shared: true`를 설정하지 않으면 두 정책이 세그먼트를 공유할 수 없습니다. | +| `cache` | 사용할 클라이언트입니다(예: `'my_cache'`). 기본값은 메모리 클라이언트입니다. | +| `expiresIn` | 항목이 저장된 뒤 유지되는 시간(밀리초)입니다. 대신 `expiresAt: 'HH:MM'`을 쓰면 하루 중 특정 시각에 모든 항목이 만료됩니다. | +| `generateFunc` | `get()`이 아무것도 찾지 못했을 때 값을 만듭니다. catbox가 그 결과를 저장합니다. | +| `generateTimeout` | `get()`이 `generateFunc`를 기다리는 시간으로, 이 시간이 지나면 `503 Service Unavailable`로 실패합니다. `generateFunc`를 쓸 때 필수이며, `false`면 무한정 기다립니다. | + +`get()`에 전달하는 id는 문자열이거나 `id` 문자열을 가진 객체이며, `generateFunc`는 이를 그대로 받습니다. catbox는 이 타입을 `string | { id: string }`으로 지정하므로, 예제에서는 다시 `a`와 `b`로 나누기 전에 타입을 좁힙니다. + +### 재생성하는 동안 오래된 값 제공하기 + +만료된 항목이 있으면 다음 호출자는 `generateFunc`를 기다려야 합니다. 이 대기를 피하려면 `staleIn`으로 만료 전에 항목을 오래된(stale) 상태로 표시합니다. 오래된 항목은 계속 제공되고, 그동안 catbox가 백그라운드에서 값을 다시 생성합니다. + +```mermaid +flowchart TD + G["sumCache.get(id)"] --> Q{캐시에 항목이 있는가?} + Q -->|"신선함: staleIn보다 최근"| F[캐싱된 값 반환] + Q -->|"오래됨: staleIn보다 오래됨"| R["generateFunc 호출"] + R --> R1["staleTimeout 안에 새 값:
반환하고 저장"] + R --> R2["staleTimeout보다 느림:
지금은 오래된 값을 반환하고,
새 값이 도착하면 저장"] + Q -->|없거나 만료됨| M["generateFunc 호출"] + M --> M1["generateTimeout 안에 값:
반환하고 저장"] + M --> M2["generateTimeout보다 느림:
503 Service Unavailable"] +``` + +```ts +const reportCache = server.cache({ + segment: 'reports', + expiresIn: 60 * 60 * 1000, + staleIn: 10 * 60 * 1000, + staleTimeout: 100, + generateTimeout: 5000, + generateFunc: async (id) => await buildReport(id), +}); +``` + +catbox는 `staleIn < expiresIn`을 강제하고, `staleIn`을 설정하면 `staleTimeout`도 필수이며, `staleTimeout < expiresIn - staleIn`이어야 합니다. 같은 id에 대한 동시 `get()` 호출은 `generateFunc` 호출 하나를 공유하므로, 없는 항목에 요청이 몰려도 데이터베이스 쿼리는 한 번만 실행됩니다. `dropOnError`, `generateOnReadError` 같은 에러 처리 설정은 [정책 옵션](/module/catbox#policy)을 참고하세요. -`cache` 옵션은 hapi에 사용할 [client](https://github.com/hapijs/catbox#client)를 알려줍니다. +### 서버 메서드 -`sumCache.get` 함수의 첫 번째 인자는 고유한 항목 식별자인 문자열 또는 필수 속성 `id`를 가진 객체이다. +대부분의 경우 캐싱된 [서버 메서드](/tutorials/ko_KR/server-methods)가 직접 만든 정책보다 짧습니다. hapi가 정책을 만들고, 세그먼트 이름을 `'#sum'`으로 정하고, 인자로부터 항목 id를 만들어 줍니다. -**partitions**외에도 하나의 [client](https://github.com/hapijs/catbox#client)에서 캐시를 격리할 수 있는 **segments**가 있습니다. 두 개의 다른 메소드로부터의 결과를 캐시 하려고 할 때 보통 그 결과를 같이 섞이는 것을 원하지 않을 것입니다. [mongoDB](http://www.mongodb.org/) 어댑터에서 `segment`는 collection을 의미하고 [redis](http://redis.io/)에서는 `partition` 옵션과 함께 추가 접두사입니다 +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -플러그인 안에서 [server.cache()](/api#servercacheoptions)가 호출될 때 `segment`의 기본값은 `'!pluginName'`입니다. [server methods](http://hapijs.com/tutorials/servermethods)를 만들 때 `segment`의 값은 `'#mothodName'`입니다. 여러 policy에서 하나의 segment를 공유하려는 여러 policy가 있는 경우라면 [shared](http://hapijs.com/api#servercacheoptions) 옵션을 사용할 수 있습니다. +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise; + } +} -#### 서버 메소드 +const add = async (a: string, b: string) => { + await Hoek.wait(1000); -더 좋아질 수 있습니다! 보일러 플레이트가 최소로 줄어들기 때문에 95% 경우 캐싱 목적으로 [server methods](/tutorials/servermethods)을 사용할 것입니다. 서버 메소드를 사용하여 앞의 예제를 다시 작성합니다.: + return Number(a) + Number(b); +}; -```javascript const start = async () => { - // ... + const server = Hapi.server({ port: 8000 }); server.method('sum', add, { cache: { - cache: 'mongoCache', expiresIn: 10 * 1000, generateTimeout: 2000, }, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; + return await server.methods.sum(a, b); }, }); await server.start(); - - // ... }; start(); ``` -[server.method()](/api#servermethodname-method-options)는 `segment: '#sum'`과 함께 자동으로 새로운 [policy](https://github.com/hapijs/catbox#policy)를 만듭니다. 또한, 고유 항목 `id`(캐시 키)는 인자로부터 자동으로 생성됩니다. 기본으로 `string`, `number`, `boolean` 인자를 받습니다. 더 복잡한 인자들은 인자를 기반으로 고유한 식별자를 만드는 `generateKey` 함수를 제공해야 합니다. - 더 자세한 내용은 서버 메소드 [tutorial](/tutorials/servermethods)에서 확인하세요. +- `declare module` 블록은 hapi의 `ServerMethods` 인터페이스에 `sum`을 추가하므로, `server.methods.sum`에 타입이 생깁니다. +- hapi는 `string`, `number`, `boolean` 인자로 `'1:5'` 같은 항목 id를 만듭니다. 다른 타입의 인자에는 [`generateKey`](/tutorials/ko_KR/server-methods#key) 함수가 필요합니다. + +## 클라이언트와 서버 캐싱 -#### 다음은? +두 단계를 함께 사용해 봅시다. 서버 캐시는 각 값을 저장한 시각을 기록하는데, 이 시각이 응답의 `Last-Modified`로 쓰기에 알맞습니다. `getDecoratedValue: true`를 설정하면 캐싱된 method가 값 자체 대신 `{ value, cached, report }`를 반환합니다. 값이 방금 생성되었다면 `cached`는 `null`입니다. -- catbox policy [options](https://github.com/hapijs/catbox#policy)을 살펴보고 catbox 캐싱의 모든 잠재력을 활용하기 위해 `staleIn`, `staleTimeout`, `generateTimeout`에 각별한 주의를 기울여주세요. -- 많은 예제는 서버 메소드 [tutorial](http://hapijs.com/tutorials/servermethods)을 확인하세요. +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -### 클라이언트와 서버 캐싱 +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise<{ value: number; cached: { stored: number } | null }>; + } +} -선택적으로 [Catbox Policy](https://github.com/hapijs/catbox#policy)는 캐시로부터 받은 값에 대해 자세한 정보를 제공할 수 있습니다. 이 기능을 사용하려면 policy를 생성할 때 `getDecoratedValue` 옵션을 true로 설정하세요. 서버 메소드가 반환한 값은 `{ value, cached, report }` 객체가 될 것입니다. `value`는 캐시에서 꺼낸 항목이고, `cached`와 `report`는 이 항목의 캐시 상태에 대한 몇 가지 추가 정보를 제공합니다. +const add = async (a: string, b: string) => { + await Hoek.wait(1000); -서버 쪽과 클라이언트 쪽 캐싱이 같이 동작하는 예제에서 `cached.stored` 시간으로 `last-modified` 헤더를 설정합니다. + return Number(a) + Number(b); +}; -```javascript const start = async () => { - //... + const server = Hapi.server({ port: 8000 }); - server.method('sum', add, { - cache: { - cache: 'mongoCache', - expiresIn: 10 * 1000, - generateTimeout: 2000, - getDecoratedValue: true, - }, - }); + const cache = { + expiresIn: 10 * 1000, + generateTimeout: 2000, + getDecoratedValue: true, + }; + + server.method('sum', add, { cache }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { @@ -231,14 +327,14 @@ const start = async () => { const { value, cached } = await server.methods.sum(a, b); const lastModified = cached ? new Date(cached.stored) : new Date(); - return h.response(value).header('Last-modified', lastModified.toUTCString()); + return h.response({ sum: value }).header('Last-Modified', lastModified.toUTCString()); }, }); await server.start(); - - // ... }; + +start(); ``` -`cached`와 `report`에 대한 자세한 정보는 [Catbox Policy API docs](https://github.com/hapijs/catbox#api-1)에서 확인할 수 있습니다. +`cached`와 `report`에 무엇이 들어 있는지는 [정책 API](/module/catbox#api-1)를 참고하세요. diff --git a/docs/tutorials/pt_BR/caching.md b/docs/tutorials/pt_BR/caching.md index a82a4f3a..bcc3d454 100644 --- a/docs/tutorials/pt_BR/caching.md +++ b/docs/tutorials/pt_BR/caching.md @@ -1,30 +1,42 @@ --- -title: Caching +title: Cache --- -## Aramazenamento em cache no lado do cliente +# Cache -_Este tutorial é compatível com hapi v16_ +_Este tutorial é compatível com hapi v21 e versões mais recentes_ -O protocolo HTTP fornece várias cebeçalhos diferentes para controlar os recursos de cache do navegador. Para decidir quais cabeçalhos são validos para o seu caso de uso, varifique no Google developers [guide](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching) ou neste [resources](https://www.google.com/search?q=HTTP+caching). Este tutorial oferece uma visão geral de como usar estas técnicas com hapi. +## Visão geral -### Cache-Control +O hapi faz cache em dois níveis, e a maioria das aplicações usa os dois: -O cabeçalho `Cache-Control` diz ao browser e a qualquer cache intermediário se um recurso é cacheavel e por quanto tempo. Por exemplo, `Cache-Control:max-age=30, must-revalidate, private` significa que o browser pode cachear os recursos associados por trinta segundos e `private` significa que não deve ser cacheado por caches intermediarios, somente pelo browser. `must-revalidate` significa que uma vez que expire tem de solicitar o recurso novamente para o servidor. +| Nível | Onde a cópia fica | O que ela economiza | Como configurar | +| --------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [No cliente](#client-side) | no navegador, ou em uma CDN ou proxy no meio do caminho | a requisição inteira: o cliente nem chega ao hapi | headers HTTP: `Cache-Control`, `ETag`, `Last-Modified` | +| [No servidor](#server-side) | em memória, no Redis ou em outro store do [catbox](/module/catbox) | o trabalho caro: uma consulta ao banco, uma chamada de API | `server.cache()` ou um [método de servidor](/tutorials/pt_BR/server-methods) com cache | -Vamos ver como nos podemos colocar estes cabeçalhos no hapi: +## Cache no cliente -```javascript +Os headers HTTP dizem aos clientes se, e por quanto tempo, eles podem reutilizar uma resposta. O [guia de cache HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) da MDN explica os headers em detalhes. Esta seção mostra como o hapi os define. + +### Cache-Control + +Defina a opção `cache` da rota, e o hapi escreve o header `Cache-Control` por você: + +```ts server.route({ path: '/hapi/{ttl?}', method: 'GET', - handler: function (request, reply) { - const response = reply({ be: 'hapi' }); + handler: function (request, h) { + const response = h.response({ be: 'hapi' }); + if (request.params.ttl) { - response.ttl(request.params.ttl); + response.ttl(Number(request.params.ttl)); } + + return response; }, options: { cache: { @@ -35,189 +47,294 @@ server.route({ }); ``` -**Listagem 1** Setar Cache-Control no cabeçalho +`response.ttl(msec)` sobrescreve `expiresIn` para uma única resposta. O header que o hapi envia depende da resposta: -Na Listagem 1 ilustra que o valor `expiresIn` pode ser sobrescrito com o método `ttl(msec)` pelo [objeto de resposta](http://hapijs.com/api#response-object). +| Requisição | Header `Cache-Control` | +| ----------------------------------------------------------------- | ---------------------------------------- | +| `GET /hapi` | `max-age=30, must-revalidate, private` | +| `GET /hapi/5000` (chama `response.ttl(5000)`) | `max-age=5, must-revalidate, private` | +| status de resposta diferente de 200 ou 204, como um 404 ou um 500 | `no-cache` (o `cache.otherwise` da rota) | -Veja [route-options](http://hapijs.com/api#route-options) por mais informações sobre as opções de configuração comuns do `cache`. +O que é bom saber: -### Last-Modified +- Se a requisição for autenticada, o hapi sempre adiciona `private`, mesmo quando a rota diz `privacy: 'public'`. Caches compartilhados, como CDNs, nunca guardam respostas `private`. +- Cookies definidos com `h.state()` não alteram o header: o hapi os escreve depois de montar o `Cache-Control`. Defina você mesmo `privacy: 'private'` em rotas com cache que definem cookies. +- Só os status codes listados em `cache.statuses` da rota (padrão `[200, 204]`) recebem `max-age`. Respostas de erro nunca entram em cache por acidente. -Em alguns casos o servidor pode fornecer informações de quando os recursos foram modificados pela última vez. Quando usar o plugin [inert](https://github.com/hapijs/inert) pase servir conteúdo estáticos, um cabeçalho `Last-Modified` é adicionado em toda carga de resposta. +Veja as [opções de rota](/api#route-options-cache) para todas as configurações de `cache`. -Quando o cebeçalho `Last-Modified` é setado em um resposta, o hapi compare ele com o cabeçalho `If-Modified-Since` vindo pelo cliente para decidir se o código do status da resposta deve ser `304`. +### Requisições condicionais: ETag e Last-Modified -Assumindo que `lastModified` é um objeto do tipo Date, é possivel definir no cabeçalho via interface [objeto de resposta](http://hapijs.com/api#response-object) como pode ser visto abaixo, na Listagem 2. +Uma requisição condicional permite ao cliente perguntar "isto mudou desde a minha cópia?" e receber um `304 Not Modified` vazio em vez do corpo inteiro: -```javascript -reply(result).header('Last-Modified', lastModified.toUTCString()); +```mermaid +sequenceDiagram + participant B as Navegador + participant S as servidor hapi + B->>S: GET /report + S-->>B: 200 com corpo, ETag "v42" + Note over B: guarda o corpo e o ETag + B->>S: GET /report com If-None-Match "v42" + Note over S: o handler roda e define ETag "v42" de novo,
o hapi vê que bate + S-->>B: 304 Not Modified, sem corpo ``` -**Listagem 2** Configurar Last-Modified no cabeçalho +Defina o `ETag` com [`response.etag()`](/api#response-object), normalmente a partir de um número de versão ou de um hash do conteúdo: -Este é mais um exemplo usando `Last-Modified` na [última seção](#client-and-server-caching) deste tutorial. +```ts +return h.response(report).etag(String(report.version)); +``` -### ETag +Ou defina `Last-Modified`, e o hapi o compara com o header `If-Modified-Since` do cliente: -O cabeçalho ETag é uma alternativa para o `Last-Modified` qunado o servidor fornece um token (geralmente o checksum deste recurso) ao invés da últma timestamp de qunado foi modificação. O browser irá usar este token para setar o cabeçalho `If-None-Match` na próxima requisição. O servidor compara os valores do cebeçalhos com a nova `ETag` checksum e responde com `304` se eles são iguais. +```ts +return h.response(report).header('Last-Modified', report.updatedAt.toUTCString()); +``` -Você só precisa definir `ETag` no seu manipulador com a função `etag(tag, options)`: +O que é bom saber: -```javascript -reply(result).etag('xxxxxxxxx'); -``` +- O hapi só responde `304` em rotas `GET` (e em rotas com método `*`). +- O handler continua rodando em toda requisição. Requisições condicionais economizam banda, não trabalho do servidor. Combine-as com [cache no servidor](#server-side) para economizar os dois. +- O [inert](/module/inert) define `ETag` e `Last-Modified` automaticamente para arquivos estáticos. -**Listagem 3** Configurar ETag no cabeçalho +## Cache no servidor -Verificar o documento da `etag` no [objeto de resposta](http://hapijs.com/api#response-object) para mais detalhes sobre os parâmetros e as opções desponíveis. +O hapi guarda valores em cache no servidor por meio do [catbox](/module/catbox), um store de chave-valor com adaptadores de armazenamento intercambiáveis: memória (o padrão), [Redis](/module/catbox-redis) e [Memcached](/module/catbox-memcached). -## Cacheando no lado do servidor +### catbox -hapi fornece um sistema de cache poderoso do lado do servidor via [catbox](https://www.github.com/hapijs/catbox) e torna o uso do cache bem mais confiavel. Este seção do tutorial vai ajudar você a entender como o catbox é utilizado dentro do hapi e como você pode influênciar nele. +Os itens em cache são organizados em três níveis: -Catbox tem duas interfaces; client e policy. +```mermaid +flowchart TD + C["cache client
uma conexão de armazenamento, como o Redis"] --> S1["segment '#sum'
os itens de um server method"] + C --> S2["segment '!my-plugin'
os itens de um plugin"] + S1 --> I1["id do item '1:5'"] + S1 --> I2["id do item '2:3'"] + S2 --> I3["id do item 'user:42'"] +``` -### Cliente +O catbox tem duas interfaces para esses itens: -[Client](https://github.com/hapijs/catbox#client) é um interface de baixo nível que permite você setar/pegar pares de chave-valor. Este é inicializado com um dos adaptadores disponíveis: ([Memory](https://github.com/hapijs/catbox-memory), [Redis](https://github.com/hapijs/catbox-redis), [mongoDB](https://github.com/hapijs/catbox-mongodb), [Memcached](https://github.com/hapijs/catbox-memcached), ou [Riak](https://github.com/DanielBarnes/catbox-riak)). +- **[Client](/module/catbox#client)**: a conexão de armazenamento de baixo nível. O hapi cria um client em memória para você, e você adiciona outros na opção `cache` do servidor. +- **[Policy](/module/catbox#policy)**: um segment de um client, com regras de expiração e uma função opcional que gera os valores ausentes. Seu código usa policies, criadas com [`server.cache()`](/api#server-cache-options) ou colocando um [método de servidor](#servermethods) em cache. -hapi sempre incializa com um [client](http://github.com/hapijs/catbox#client) padrão com o adaptador [memory](https://github.com/hapijs/catbox-memory). Vamos ver como nos podemos definir mais clients. +### Client -```javascript -'use strict'; +Adicione um client Redis chamado `my_cache` ao lado do client padrão em memória: -const Hapi = require('hapi'); +```ts +import Hapi from '@hapi/hapi'; +import { Engine as CatboxRedis } from '@hapi/catbox-redis'; -const server = new Hapi.Server({ +const server = Hapi.server({ + port: 8000, cache: [ { - name: 'mongoCache', - engine: require('catbox-mongodb'), - host: '127.0.0.1', - partition: 'cache', - }, - { - name: 'redisCache', - engine: require('catbox-redis'), - host: '127.0.0.1', - partition: 'cache', + name: 'my_cache', + provider: { + constructor: CatboxRedis, + options: { + partition: 'my_cached_data', + host: 'redis-cluster.domain.com', + port: 6379, + database: 0, + tls: {}, + }, + }, }, ], }); - -server.connection({ - port: 8000, -}); ``` -**Listagem 4** Definindo catbox clients +- `name` é como as policies escolhem esse client, com `cache: 'my_cache'`. Omita `name` para substituir o client padrão em memória. +- `partition` separa as chaves desta aplicação das de outras que compartilham o mesmo banco Redis. No Redis, vira um prefixo de chave. O padrão é `'catbox'`. +- Os cache clients se conectam quando o servidor inicia, em [`server.initialize()`](/api#await-server-initialize) ou [`server.start()`](/api#await-server-start). Antes disso, as policies não conseguem ler nem escrever. -Na Listagem 4, nós definimos 2 catbox clients; mongoCache e redisCache. Incluindo o memory cache padrão criada pelo hapi, existem três cliente de cache disponivel. Você pode replicar o cliente padrão por omitindo a propriedade `name` quando estiver registrando o novo cliente de cache. `partition` diz ao adaptador como o cache deve ser nomeado ('catbox' por padrão). Em caso de [mongoDB](http://www.mongodb.org) é o nome do banco de dados, em caso de [redis](http://redis.io/) é usado o prefixo da chave. +### Policy -### Policy +[`server.cache(options)`](/api#server-cache-options) cria uma policy. Este exemplo guarda em cache, por dez segundos, o resultado de uma soma lenta: -[Policy](https://github.com/hapijs/catbox#policy) é um inteface de mais baixo nível do que o Cliente. Vamos fingir que nós estemos lidando com algo mais complicado do que somar dois números e queremos cachear os resultados. [server.cache](http://hapijs.com/api#servercacheoptions) cria uma nova [policy](https://gihub.com/hapijs/catbox#policy), que irá ser usada na manipulação de rota. +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -```javascript -const add = function (a, b, next) { - return next(null, Number(a) + Number(b)); +const add = async (a: string, b: string) => { + await Hoek.wait(1000); // Simulates a slow database call + + return Number(a) + Number(b); }; -const sumCache = server.cache({ - cache: 'mongoCache', - expiresIn: 20 * 1000, - segment: 'customSegment', - generateFunc: function (id, next) { - add(id.a, id.b, next); - }, - generateTimeout: 100, -}); +const start = async () => { + const server = Hapi.server({ port: 8000 }); -server.route({ - path: '/add/{a}/{b}', - method: 'GET', - handler: function (request, reply) { - const id = request.params.a + ':' + request.params.b; - sumCache.get({ id: id, a: request.params.a, b: request.params.b }, (err, result) => { - if (err) { - return reply(err); - } - reply(result); - }); - }, -}); + const sumCache = server.cache({ + segment: 'sums', + expiresIn: 10 * 1000, + generateFunc: async (id) => { + const key = typeof id === 'string' ? id : id.id; + const [a, b] = key.split(':'); + + return await add(a, b); + }, + generateTimeout: 2000, + }); + + server.route<{ Params: { a: string; b: string } }>({ + path: '/add/{a}/{b}', + method: 'GET', + handler: async function (request, h) { + const { a, b } = request.params; + + return await sumCache.get(`${a}:${b}`); + }, + }); + + await server.start(); + + console.log('Server running at:', server.info.uri); +}; + +start(); ``` -**Listagem 5** Usando `server.cache` +`GET /add/1/5` responde `6` depois de cerca de um segundo. Repita dentro de dez segundos e a resposta vem na hora, do cache. -`cache` diz ao hapi como user o [client](https://github.com/hapijs/catbox#client) +| Opção | Significado | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `segment` | Dá nome aos itens desta policy. Obrigatório na raiz do servidor. Dentro de um plugin, o padrão é `'!' + plugin name`. Duas policies não podem compartilhar um segment, a menos que definam `shared: true`. | +| `cache` | O client a usar, como `'my_cache'`. O padrão é o client em memória. | +| `expiresIn` | Por quanto tempo um item vive depois de ser guardado, em milissegundos. `expiresAt: 'HH:MM'` faz tudo expirar em um horário do dia. | +| `generateFunc` | Cria o valor quando `get()` não encontra nada. O catbox guarda o resultado. | +| `generateTimeout` | Quanto tempo `get()` espera por `generateFunc` antes de falhar com `503 Service Unavailable`. Obrigatório com `generateFunc`; `false` espera para sempre. | -O primeiro parâmeto da função `sumCache.get` é um objeto com uma chave obrigatória `id`, que é uma string única de identificação do item. +O id passado para `get()` é uma string ou um objeto com uma string `id`, e `generateFunc` o recebe sem alterações. O catbox o tipa como `string | { id: string }`, então o exemplo restringe o tipo antes de dividi-lo de volta em `a` e `b`. -Além de **partições**, tem **segmentos** que permite você isolar os caches sem um [client](https://github.com/hapids/catbox#client). Se você deseja armazernar o resultado em cache por dois firentes métodos, você geralmente não vai querer misturar os resultados juntos. No adaptador [mongoDB](http://www.mongodb.org), `segment` representa uma colcação e no [redis](http://redis.io/) isto é um prefixo adiciona, juntamento com a opção `partition`. +### Servindo valores stale enquanto regenera -O valor padrão para `segment` quando [server.cache](http://hapijs.com/api#servercacheoptions) é chamado dentro de um plugin será `'!pluginName'`. Ao criar [server methods](http://hapijs.com/tutorials/servermethods), o valor do `segment` será `'#methodName'`. Se você tiver usando em um caso várias policies que compartilham um segment exite uma opção [shared](http://hapijs.com/api#servercacheoptions) disponível também. +Um item expirado faz o próximo chamador esperar por `generateFunc`. Para evitar essa espera, marque os itens como stale antes de expirarem, com `staleIn`. Um item stale continua sendo servido enquanto o catbox o regenera em segundo plano: -### Métodos de servidor +```mermaid +flowchart TD + G["sumCache.get(id)"] --> Q{item no cache?} + Q -->|"fresco: mais novo que staleIn"| F[retorna o valor em cache] + Q -->|"stale: mais velho que staleIn"| R["chama generateFunc"] + R --> R1["valor novo dentro de staleTimeout:
retorna e guarda"] + R --> R2["mais lento que staleTimeout:
retorna o valor stale agora,
guarda o novo quando chegar"] + Q -->|ausente ou expirado| M["chama generateFunc"] + M --> M1["valor dentro de generateTimeout:
retorna e guarda"] + M --> M2["mais lento que generateTimeout:
503 Service Unavailable"] +``` -Mas ele pode ficar melhor que isto! Em 95% dos casos você pode user [server methods](http://hapijs.com/tutorials/servermethods) para fins de armazenamento em cache, porque isso reduz a padronização para o minimo. Vamos reescrever a Listagem 5 usando os métodos do servidor: +```ts +const reportCache = server.cache({ + segment: 'reports', + expiresIn: 60 * 60 * 1000, + staleIn: 10 * 60 * 1000, + staleTimeout: 100, + generateTimeout: 5000, + generateFunc: async (id) => await buildReport(id), +}); +``` -```javascript -const add = function (a, b, next) { - return next(null, Number(a) + Number(b)); +O catbox exige `staleIn < expiresIn`, exige `staleTimeout` sempre que `staleIn` é definido e exige `staleTimeout < expiresIn - staleIn`. Chamadas concorrentes a `get()` para o mesmo id compartilham uma única chamada a `generateFunc`, então uma rajada de requisições por um item ausente consulta seu banco uma vez só. Veja as [opções de policy](/module/catbox#policy) para configurações de tratamento de erro como `dropOnError` e `generateOnReadError`. + +### Métodos de servidor + +Na maior parte das vezes, um [método de servidor](/tutorials/pt_BR/server-methods) com cache é mais curto que uma policy feita à mão. O hapi cria a policy, chama o segment de `'#sum'` e monta o id do item a partir dos argumentos: + +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; + +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise; + } +} + +const add = async (a: string, b: string) => { + await Hoek.wait(1000); + + return Number(a) + Number(b); }; -server.method('sum', add, { - cache: { - cache: 'mongoCache', - expiresIn: 30 * 1000, - generateTimeout: 100, - }, -}); +const start = async () => { + const server = Hapi.server({ port: 8000 }); -server.route({ - path: '/add/{a}/{b}', - method: 'GET', - handler: function (request, reply) { - server.methods.sum(request.params.a, request.params.b, (err, result) => { - if (err) { - return reply(err); - } - reply(result); - }); - }, -}); + server.method('sum', add, { + cache: { + expiresIn: 10 * 1000, + generateTimeout: 2000, + }, + }); + + server.route<{ Params: { a: string; b: string } }>({ + path: '/add/{a}/{b}', + method: 'GET', + handler: async function (request, h) { + const { a, b } = request.params; + + return await server.methods.sum(a, b); + }, + }); + + await server.start(); +}; + +start(); ``` -**Listagem 6** Usando cache via métodos do servidor +- O bloco `declare module` adiciona `sum` à interface `ServerMethods` do hapi, para que `server.methods.sum` tenha um tipo. +- O hapi monta o id do item a partir de argumentos `string`, `number` e `boolean`, como `'1:5'`. Outros tipos de argumento precisam de uma função [`generateKey`](/tutorials/pt_BR/server-methods#key). + +## Cache no cliente e no servidor -[server.method()](http://hapijs.com/api#servermethodname-method-options) cria uma nova [policy](https://github.com/hapijs/catbox#policy) com `segment: '#sum'` automaticamente para nós. Além disso, o único item `id` (chave do cache) esta gerda automaticamente a partir dos parâmetros. Por padrão, ele lida com parâmetros do tipo `string`, `number` e `boolean`. Para parâmetros mais complexos, você tem que fornecer sua próprio função `generateKey` para criar os ids único baseado nos parêmetros - confira os métodos do servidor [tutorial](http://hapijs.com/tutorials/servermethods) para mais informações. +Combine os dois níveis: o cache do servidor registra quando guardou cada valor, e esse momento é o `Last-Modified` certo para a resposta. Defina `getDecoratedValue: true`, e o method com cache retorna `{ value, cached, report }` em vez do valor puro. `cached` é `null` quando o valor acabou de ser gerado: -### Qual a próxima? +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -- De uma olhada melhor em catbox policy [options](https://github.com/hapijs/catbox#policy) e preste bastante atenção em `staleIn`, `staleTimeout`, `generateTimeout`, que aproveita todo o potencial do armazenamento em cache do catbox. -- Varifique os métodos do servidor [tutorial](http://hapijs.com/tutorials/servermethods) para mais exemplos. +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise<{ value: number; cached: { stored: number } | null }>; + } +} -## Caheando no lado do cliente e servidor. +const add = async (a: string, b: string) => { + await Hoek.wait(1000); -[Catbox Policy](https://github.com/hapijs/catbox#policy) fornece mais dois parâmetros `cached` e `report` que fornece alguns detalhes quando um item é armazenado + return Number(a) + Number(b); +}; -Neste caso nós usamos `cached.stored` timestamp para adicionar no header `last-modified`. +const start = async () => { + const server = Hapi.server({ port: 8000 }); -```javascript -server.route({ - path: '/add/{a}/{b}', - method: 'GET', - handler: function (request, reply) { - server.methods.sum(request.params.a, request.params.b, (err, result, cached, report) => { - if (err) { - return reply(err); - } + const cache = { + expiresIn: 10 * 1000, + generateTimeout: 2000, + getDecoratedValue: true, + }; + + server.method('sum', add, { cache }); + + server.route<{ Params: { a: string; b: string } }>({ + path: '/add/{a}/{b}', + method: 'GET', + handler: async function (request, h) { + const { a, b } = request.params; + const { value, cached } = await server.methods.sum(a, b); const lastModified = cached ? new Date(cached.stored) : new Date(); - return reply(result).header('last-modified', lastModified.toUTCString()); - }); - }, -}); + + return h.response({ sum: value }).header('Last-Modified', lastModified.toUTCString()); + }, + }); + + await server.start(); +}; + +start(); ``` -**Listagem 7** Cacheando no lado do servidor e cliente juntos +Veja a [API de policy](/module/catbox#api-1) para o que `cached` e `report` contêm. diff --git a/docs/tutorials/tr_TR/caching.md b/docs/tutorials/tr_TR/caching.md index 35d41719..329ac82d 100644 --- a/docs/tutorials/tr_TR/caching.md +++ b/docs/tutorials/tr_TR/caching.md @@ -1,26 +1,31 @@ --- -title: Caching +title: Önbellekleme --- -## Önbellekleme +# Önbellekleme -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -### İstemci taraflı önbellekleme +## Genel Bakış -HTTP protokolü, tarayıcı ve benzeri istemcilerin kaynakları önbelleklemeleri için bazı HTTP başlıkları tayin eder. Bu başlıklar hakkında daha fazla bilgi edinmek ve kullanım senaryonuza hangisinin yatkın olduğuna karar vermek için [Google tarafından oluşturulan bu rehbere](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching) bir göz atabilirsiniz. +hapi iki düzeyde önbellekleme yapar ve çoğu uygulama ikisini de kullanır: -Bu öğreticinin ilk bölümü, hapinin istemcilere bu başlıkları göndermek üzere nasıl yapılandırılacağını gösterir. +| Düzey | Kopyanın tutulduğu yer | Neyi kazandırır | Nasıl yapılandırılır | +| ------------------------------ | ------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| [İstemci tarafı](#client-side) | tarayıcı ya da aradaki bir CDN veya proxy | isteğin tamamını: istemci hapi'ye hiç ulaşmaz | HTTP header'ları: `Cache-Control`, `ETag`, `Last-Modified` | +| [Sunucu tarafı](#server-side) | bellek, Redis ya da başka bir [catbox](/module/catbox) deposu | pahalı işi: bir veritabanı sorgusu, bir API çağrısı | `server.cache()` ya da önbelleğe alınmış bir [server method](/tutorials/tr_TR/server-methods) | -#### Cache-Control +## İstemci Tarafında Önbellekleme -`Cache-Control` başlığı tarayıcıya ve tüm ara önbelleklere bir kaynağın önbelleklenebilir olup olmadığını ve eğer öyleyse ne kadar süre önbelleklenmesi gerektiğini söyler. Örneğin, `Cache-Control:max-age=30, must-revalidate, private` ilgili kaynağın tarayıcı tarafından otuz saniye boyunca önbelleklenebileceğini ve `private` ara önbellekler tarafından değil yalnızca tarayıcı tarafından önbelleklenmesi gerektiği söyler. `must-revalidate` önbellek süresi dolduğunda sunucudan kaynağın yenisinin istenmesi gerektiği anlamına gelir. +HTTP header'ları, istemcilere bir yanıtı yeniden kullanıp kullanamayacaklarını ve ne kadar süreyle kullanabileceklerini söyler. MDN'nin [HTTP önbellekleme rehberi](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) bu header'ları ayrıntılı olarak açıklar. Bu bölüm hapi'nin onları nasıl ayarladığını gösterir. -Hadi bu başlığı hapi ile nasıl ayarlayabileceğimize bakalım: +### Cache-Control -```javascript +Route'un `cache` seçeneğini ayarla, `Cache-Control` header'ını hapi senin için yazsın: + +```ts server.route({ path: '/hapi/{ttl?}', method: 'GET', @@ -28,7 +33,7 @@ server.route({ const response = h.response({ be: 'hapi' }); if (request.params.ttl) { - response.ttl(request.params.ttl); + response.ttl(Number(request.params.ttl)); } return response; @@ -42,108 +47,146 @@ server.route({ }); ``` -Yukarıdaki örnek bir yol (route) üzerinde nasıl `cache` seçeneklerini ayarlayabileceğini gösteriyor. Burada `expiresIn` değerini 30 saniye ve `privacy` değerini `private` olarak ayarladık. +`response.ttl(msec)`, tek bir yanıt için `expiresIn` değerini geçersiz kılar. hapi'nin gönderdiği header yanıta bağlıdır: + +| İstek | `Cache-Control` header'ı | +| ------------------------------------------------------------ | ---------------------------------------------- | +| `GET /hapi` | `max-age=30, must-revalidate, private` | +| `GET /hapi/5000` (`response.ttl(5000)` çağırır) | `max-age=5, must-revalidate, private` | +| 200 ya da 204 dışında bir yanıt durumu, örneğin 404 veya 500 | `no-cache` (route'un `cache.otherwise` değeri) | -Bu örnek aynı zamanda `expiresIn` değerinin üzerine [yanıt nesnesi](/api#response-object) arayüzünde bulunan `ttl(msec)` yöntemi ile yazılabileceğini gösteriyor. +Bilmen gerekenler: -`/hapi`ye bir istekte bulunursak yanıt başlığı olarak `cache-control: max-age=30, must-revalidate, private` alırız. Eper isteği `/hapi/5000`e yaparsak bunun yerine şu başlık gelir: `cache-control: max-age=5, must-revalidate, private`. +- İstek kimliği doğrulanmış bir istekse, route `privacy: 'public'` dese bile hapi her zaman `private` ekler. CDN gibi paylaşılan önbellekler `private` yanıtları asla saklamaz. +- `h.state()` ile ayarlanan cookie'ler header'ı değiştirmez: hapi bunları `Cache-Control` oluşturulduktan sonra yazar. Cookie ayarlayan ve önbelleğe alınan route'larda `privacy: 'private'` değerini kendin ayarla. +- Yalnızca route'un `cache.statuses` listesindeki durum kodları (varsayılan `[200, 204]`) `max-age` alır. Hata yanıtları yanlışlıkla önbelleğe alınmaz. -[yol-secenekleri (route options)](/api#route-options) bağlantısından daha fazla genel `cache` yapılandırma seçeneği bulabilirsiniz. +Tüm `cache` ayarları için [route seçeneklerine](/api#route-options-cache) bak. -#### Last-Modified +### Koşullu istekler: ETag ve Last-Modified -Bazı durumlarda, sunucu bir kaynağın son düzenlendiği tarihi sağlayabilir. Durağan içerikler sunmak için [inert](https://github.com/hapijs/inert) eklentisini kullanırken otomatik olarak her yanıta `Last-Modified` başlığı eklenir. +Koşullu bir istek, istemcinin "bu, benim kopyamdan bu yana değişti mi?" diye sormasını ve tam gövde yerine boş bir `304 Not Modified` almasını sağlar: -Bir yanıtta `Last-Modified` başlığı ayarlandığında hapi bu bilgiyi istemciden gelen `If-Modified-Since` başlığıyla karşılaştırarak yanıt durum kodunun `304 Not Modified` olup olmayacağına karar verir. Bu olay anı zamanda koşullu GET isteği olarak da bilinir. Bu yanıtın güzel tarafı, `304` yanıtı alan tarayıcının aynı içeriği tekrar indirmesine gerek kalmamasıdır. +```mermaid +sequenceDiagram + participant B as Tarayıcı + participant S as hapi sunucusu + B->>S: GET /report + S-->>B: 200 gövdeyle, ETag "v42" + Note over B: gövdeyi ve ETag değerini saklar + B->>S: If-None-Match "v42" ile GET /report + Note over S: handler çalışır ve ETag "v42" değerini yeniden ayarlar,
hapi eşleşmeyi görür + S-->>B: 304 Not Modified, gövde yok +``` -`lastModified` bilgsinin bir `Date` nesnesi olduğunu varsayarsak bu başlığı [yanıt nesnesi (response object)](/api#response-object) kullanarak şu şekilde ayarlayabilirsiniz: +`ETag` değerini [`response.etag()`](/api#response-object) ile ayarla; genellikle bir sürüm numarasından ya da içeriğin hash'inden: -```javascript -return h.response(result).header('Last-Modified', lastModified.toUTCString()); +```ts +return h.response(report).etag(String(report.version)); ``` -Bu yazının [son bölümünde](#istemci-ve-sunucu-önbellekleme) `Last-Modified` kullanan bir örnek daha var. +Ya da `Last-Modified` ayarla; hapi bunu istemcinin `If-Modified-Since` header'ıyla karşılaştırır: -#### ETag +```ts +return h.response(report).header('Last-Modified', report.updatedAt.toUTCString()); +``` -ETag, zaman damgası paylaşan `Last-Modified` başlığına alternatif olarak sunucunun bir jeton (genellikle kaynağın sağlama toplamı (checksum)) paylaştığı başlıktır. Tarayıcı bu jetonu bir sonraki isteğinde `If-None-Match` başlığını ayarlamak için kullanır. Sunucu bu başlığın değerini güncel `ETag` sağlama toplamıyla karşılaştırarak aynı ise `304` döner. +Bilmen gerekenler: -Başlığınızda `ETag` kullanmak için yalnızca `etag(tag, options)` işlevini kullanmanız yeterli: +- hapi `304` yanıtını yalnızca `GET` route'larında (ve metodu `*` olan route'larda) verir. +- Handler yine her istekte çalışır. Koşullu istekler bant genişliğinden tasarruf sağlar, sunucu işinden değil. İkisinden de tasarruf etmek için bunları [sunucu tarafı önbelleklemeyle](#server-side) birlikte kullan. +- [inert](/module/inert), statik dosyalar için `ETag` ve `Last-Modified` değerlerini otomatik olarak ayarlar. -```javascript -return h.response(result).etag('xxxxxxxxx'); -``` +## Sunucu Tarafında Önbellekleme -Kullanılabilecek daha fazla argüman ve seçenek için [yanıt nesnesi (response object)](/api#response-object) altında bulunan `etag` dokümantasyonuna bir göz atabilirsin. +hapi değerleri sunucuda [catbox](/module/catbox) aracılığıyla önbelleğe alır. catbox, birbirinin yerine kullanılabilen depolama adaptörlerine sahip bir anahtar-değer deposudur: bellek (varsayılan), [Redis](/module/catbox-redis) ve [Memcached](/module/catbox-memcached). -### Sunucu taraflı önbellekleme +### catbox -hapi [catbox](https://www.github.com/hapijs/catbox) ile güçlü ve pratik sunucu taraflı önbellekleme sunar. Bu öğretici bölümü catbox'ı nasıl kullanman gerektiğini anlamana yardımcı olacaktır. +Önbelleğe alınan öğeler üç düzeyde düzenlenir: -Catbox'ın iki arayüzü bulunur: istemci (client) ve politika (policy). +```mermaid +flowchart TD + C["cache client
Redis gibi tek bir depolama bağlantısı"] --> S1["segment '#sum'
bir server method'un öğeleri"] + C --> S2["segment '!my-plugin'
bir plugin'in öğeleri"] + S1 --> I1["öğe id '1:5'"] + S1 --> I2["öğe id '2:3'"] + S2 --> I3["öğe id 'user:42'"] +``` -#### Client (Istemci) +catbox bu öğelere iki arayüz sunar: -[İstemci (client)](https://github.com/hapijs/catbox#client) anahtar-değer eşleri alıp-verebileceğin alt seviye bir arayüzdür. Şu adaptörlerden birisi ile başlatılır: ([Hafıza (memory)](https://github.com/hapijs/catbox-memory), [Redis](https://github.com/hapijs/catbox-redis), [mongoDB](https://github.com/hapijs/catbox-mongodb), [Memcached](https://github.com/hapijs/catbox-memcached), ya da [Riak](https://github.com/DanielBarnes/catbox-riak)). +- **[Client](/module/catbox#client)**: düşük seviyeli depolama bağlantısı. hapi senin için bir bellek client'ı oluşturur; diğerlerini sunucunun `cache` seçeneğinde eklersin. +- **[Policy](/module/catbox#policy)**: bir client'ın, süre dolma kuralları ve eksik değerleri üreten isteğe bağlı bir fonksiyonu olan bir segment'i. Kodun policy'leri kullanır; bunlar [`server.cache()`](/api#server-cache-options) ile ya da bir [server method](#servermethods) önbelleğe alınarak oluşturulur. -hapi varsayılan olarak [catbox memory](https://github.com/hapijs/catbox-memory) adaptörünü kullanarak bir [İstemci (client)](https://github.com/hapijs/catbox#client) başlatır. Nasıl daha fazla istemci başlatacağına bir bakalım: +### Client -```javascript -'use strict'; +Varsayılan bellek client'ının yanına `my_cache` adında bir Redis client'ı ekle: -const Hapi = require('@hapi/hapi'); +```ts +import Hapi from '@hapi/hapi'; +import { Engine as CatboxRedis } from '@hapi/catbox-redis'; const server = Hapi.server({ port: 8000, cache: [ { - name: 'mongoCache', - engine: require('catbox-mongodb'), - host: '127.0.0.1', - partition: 'cache', - }, - { - name: 'redisCache', - engine: require('catbox-redis'), - host: '127.0.0.1', - partition: 'cache', + name: 'my_cache', + provider: { + constructor: CatboxRedis, + options: { + partition: 'my_cached_data', + host: 'redis-cluster.domain.com', + port: 6379, + database: 0, + tls: {}, + }, + }, }, ], }); ``` -Yukarıdaki örnekte, iki tane catbox istemcisi tanımladık: mongoCache ve redisCache. hapi tarafından oluşturulan hafıza önbelleğini de sayarsak üç tane önbellek istemcisi oldu. Yeni bir önbellek istemcisi tanımlarken `name` özelliğini es geçerek varsayılan istemciyi değiştirebilirsiniz. `partition` adaptöre önbelleğin nasıl adlandırılacağını söyler (varsayılan olarak 'catbox'). [mongoDB](http://www.mongodb.org/)'de bu veri tabanı adı ve [redis](http://redis.io/)'te anahtar ön eki olarak karşımıza çıkar. +- `name`, policy'lerin bu client'ı `cache: 'my_cache'` ile seçmesini sağlar. Bunun yerine varsayılan bellek client'ını değiştirmek için `name` değerini belirtme. +- `partition`, bu uygulamanın anahtarlarını aynı Redis veritabanını paylaşan diğerlerinden ayrı tutar. Redis'te bir anahtar önekine dönüşür. Varsayılanı `'catbox'` değeridir. +- Önbellek client'ları sunucu başlarken, [`server.initialize()`](/api#await-server-initialize) ya da [`server.start()`](/api#await-server-start) içinde bağlanır. Policy'ler bundan önce okuma veya yazma yapamaz. -#### Policy (Politika) +### Policy -[Politika (policy)](https://github.com/hapijs/catbox#policy) istemciye nazaran daha üst seviye bir arayüzdür. Aşağıda iki sayıyı birbirine ekleyip sonucu önbellekleyen basit bir örnek bulunuyor. Bu basit örneğin ortaya koyduğu prensipleri kullanarak bir fonksiyon çağrısının sonucunu, asenkron olsun olmasın önbellekleyebilirsin. [server.cache(options)](/api#-servercacheoptions) daha sonra yol işleyici (route handler) tarafından kullanılmak üzere yeni bir [politika (policy)](https://github.com/hapijs/catbox#policy) oluşturur. +[`server.cache(options)`](/api#server-cache-options) bir policy oluşturur. Bu örnek yavaş bir toplama işleminin sonucunu on saniye boyunca önbellekte tutar: -```javascript -const start = async () => { - const add = async (a, b) => { - await Hoek.wait(1000); // Yavaş I/O simülasyonu +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; - return Number(a) + Number(b); - }; +const add = async (a: string, b: string) => { + await Hoek.wait(1000); // Simulates a slow database call + + return Number(a) + Number(b); +}; + +const start = async () => { + const server = Hapi.server({ port: 8000 }); const sumCache = server.cache({ - cache: 'mongoCache', + segment: 'sums', expiresIn: 10 * 1000, - segment: 'customSegment', generateFunc: async (id) => { - return await add(id.a, id.b); + const key = typeof id === 'string' ? id : id.id; + const [a, b] = key.split(':'); + + return await add(a, b); }, generateTimeout: 2000, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; - const id = `${a}:${b}`; - return await sumCache.get({ id, a, b }); + return await sumCache.get(`${a}:${b}`); }, }); @@ -155,76 +198,128 @@ const start = async () => { start(); ``` -Eğer http://localhost:8000/add/1/5 adresine bir istekte bulunursan bir saniye sonra `6` yanıtı almalısın. İsteği tekrarlarsan sonuç anında gelir çünkü bu kez sonuç önbellek tarafından sunulmaktadır. Eğer on saniye bekleyip isteği tekrar etseydin yine biraz beklediğini görecektin çünkü önbellek değeri önbellekten silinmiş olacaktı. +`GET /add/1/5` yaklaşık bir saniye sonra `6` yanıtını verir. On saniye içinde tekrarlarsan yanıt önbellekten anında gelir. + +| Seçenek | Anlamı | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `segment` | Bu policy'nin öğelerini adlandırır. Sunucu kökünde zorunludur. Bir plugin içinde varsayılanı `'!' + plugin name` (plugin adı) olur. İki policy, `shared: true` ayarlamadıkça aynı segment'i paylaşamaz. | +| `cache` | Kullanılacak client; örneğin `'my_cache'`. Varsayılanı bellek client'ıdır. | +| `expiresIn` | Bir öğenin saklandıktan sonra ne kadar yaşayacağı, milisaniye cinsinden. Bunun yerine `expiresAt: 'HH:MM'` her şeyin günün belirli bir saatinde dolmasını sağlar. | +| `generateFunc` | `get()` hiçbir şey bulamadığında değeri oluşturur. catbox sonucu saklar. | +| `generateTimeout` | `get()` fonksiyonunun `503 Service Unavailable` ile başarısız olmadan önce `generateFunc` için ne kadar bekleyeceği. `generateFunc` ile birlikte zorunludur; `false` sonsuza kadar bekler. | + +`get()` fonksiyonuna verilen id ya bir string ya da `id` string'i içeren bir nesnedir ve `generateFunc` onu değiştirilmeden alır. catbox bunun tipini `string | { id: string }` olarak belirler, bu yüzden örnek, onu yeniden `a` ve `b` olarak bölmeden önce tipini daraltır. -`cache` seçeneği hapiye hangi [istemci (client)](https://github.com/hapijs/catbox#client)yi kullanacağını söyler. +### Yeniden üretilirken bayat değerleri sunmak -`sumCache.get()` işlevinin ilk değiştirgesi `id` olarak kullanılmak üzere bir metin ya da içerisinde tekil tanımlayıcı olarak kullanılmak üzere `id` özelliği olan bir nesne olabilir. +Süresi dolmuş bir öğe, bir sonraki çağıranı `generateFunc` için bekletir. Bu beklemeden kaçınmak için öğeleri süreleri dolmadan önce `staleIn` ile bayat olarak işaretle. Bayat bir öğe sunulmaya devam ederken catbox onu arka planda yeniden üretir: + +```mermaid +flowchart TD + G["sumCache.get(id)"] --> Q{öğe önbellekte mi?} + Q -->|"taze: staleIn süresinden genç"| F[önbellekteki değeri döndür] + Q -->|"bayat: staleIn süresinden eski"| R["generateFunc çağır"] + R --> R1["staleTimeout içinde yeni değer:
döndür ve sakla"] + R --> R2["staleTimeout süresinden yavaş:
bayat değeri hemen döndür,
yenisini geldiğinde sakla"] + Q -->|eksik ya da süresi dolmuş| M["generateFunc çağır"] + M --> M1["generateTimeout içinde değer:
döndür ve sakla"] + M --> M2["generateTimeout süresinden yavaş:
503 Service Unavailable"] +``` + +```ts +const reportCache = server.cache({ + segment: 'reports', + expiresIn: 60 * 60 * 1000, + staleIn: 10 * 60 * 1000, + staleTimeout: 100, + generateTimeout: 5000, + generateFunc: async (id) => await buildReport(id), +}); +``` -**partitionlar**a ek olarak, bir [istemci (client)](https://github.com/hapijs/catbox#client) bölmelendirmesi içerisinde daha fazla izolasyon sağlayacak **segmentler** var. İki farklı yöntem sonucunu önbelleklemek isterseniz genelde bunları birbirine karıştırmak istemezsiniz. [mongoDB](http://www.mongodb.org/) adaptörlerinde `segment` bir koleksiyonu temsil ederken, [redis](http://redis.io/)'te `partition` için eklenen öneke ek olarak eklenen bir önektir. +catbox `staleIn < expiresIn` koşulunu zorunlu kılar, `staleIn` ayarlandığında `staleTimeout` ister ve `staleTimeout < expiresIn - staleIn` koşulunu gerektirir. Aynı id için eşzamanlı `get()` çağrıları tek bir `generateFunc` çağrısını paylaşır; böylece eksik bir öğe için gelen bir istek yığını veritabanını bir kez sorgular. `dropOnError` ve `generateOnReadError` gibi hata yönetimi ayarları için [policy seçeneklerine](/module/catbox#policy) bak. -Bir eklenti içerisinden [server.cache()](/api#-servercacheoptions) çağırıldığında `segment`in varsayılan değeri `'!pluginName'` olur. [Sunucu yöntemleri (server methods)](/tutorials/servermethods) oluştururken `segment` değeri `'#methodName'` olur. Eğer bir segmenti paylaşan birden fazla politikanın olduğu bir durum varsa, bunun için de [paylasılan (shared)](/api#-servercacheoptions) seçeneği mevcut. +### Server method'lar -#### Sunucu Yöntemi +Çoğu zaman önbelleğe alınmış bir [server method](/tutorials/tr_TR/server-methods), elle oluşturulmuş bir policy'den daha kısadır. hapi policy'yi oluşturur, segment'ini `'#sum'` olarak adlandırır ve öğe id'sini argümanlardan üretir: -Daha iyi olabilir! Yüzde doksan beş ihtimal önbellekleme için [sunucu yöntemleri (server methods)](/tutorials/servermethods)ni kullanacaksınız çünkü daha az basmakalıp kullanmak gerekiyor. Hadi önceki örneği sunucu yöntemi ile tekrar yazalım: +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; + +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise; + } +} + +const add = async (a: string, b: string) => { + await Hoek.wait(1000); + + return Number(a) + Number(b); +}; -```javascript const start = async () => { - // ... + const server = Hapi.server({ port: 8000 }); server.method('sum', add, { cache: { - cache: 'mongoCache', expiresIn: 10 * 1000, generateTimeout: 2000, }, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; + return await server.methods.sum(a, b); }, }); await server.start(); - - // ... }; start(); ``` -[server.method()](/api#-servermethodname-method-options) otomatik olarak `segment: '#sum'` kesitiyle yeni bir [politika (policy)](https://github.com/hapijs/catbox#policy) oluşturdu. Ayrıca değiştirgelerle tekil bir `id`(önbellek anahtarı)'de oluşturuldu. Varsayılan olarak, `string`, `number` ve `boolean` değiştirgelerle çalışabilir. Daha kompleks değiştirgeler için değiştirgeleri kullanarak nasıl tekil tanımlayıcılar üretileceğini belirleyen bir `generateKey` işlevi tanımlamalısın. - [Sunucu yöntemleri](/tutorials/servermethods) öğreticisinden daha fazla bilgi alabilirsin. +- `declare module` bloğu hapi'nin `ServerMethods` arayüzüne `sum` ekler, böylece `server.methods.sum` bir tipe sahip olur. +- hapi öğe id'sini `string`, `number` ve `boolean` argümanlarından üretir; örneğin `'1:5'`. Diğer argüman tipleri bir [`generateKey`](/tutorials/tr_TR/server-methods#key) fonksiyonu gerektirir. -#### Sırada ne var? +## İstemci ve Sunucu Önbellekleme -- catbox politikasının [seçenekler](https://github.com/hapijs/catbox#policy)inin içine bakarak ve catbox önbelleklemenin tüm potansiyelinden faydalanmak için `staleIn`, `staleTimeout`, `generateTimeout` kısımlarına özellikle dikkat etmelisiniz. -- Daha fazla örnek için [sunucu yöntemleri (server methods)](http://hapijs.com/tutorials/servermethods) öğreticisine göz atabilirsin. +İki düzeyi birleştir: sunucu önbelleği her değeri ne zaman sakladığını kaydeder ve bu zaman, yanıt için doğru `Last-Modified` değeridir. `getDecoratedValue: true` ayarla; önbelleğe alınan metot çıplak değer yerine `{ value, cached, report }` döndürür. Değer yeni üretildiyse `cached` değeri `null` olur: -### İstemci ve Sunucu önbellekleme +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -İsteğe bağlı olarak, [Catbox Politikası (Catbox Policy)](https://github.com/hapijs/catbox#policy) önbellekten döndürülen değer hakkında daha fazla bilgi verebilir. Bunun için politikayı yaratırken `getDecoratedValue` seçeneği true olarak ayarlanır. Bu şekilde sunucu yönteminden dönen tüm değerler `{ value, cached, report }` şeklinde birer nesne olur. `value` önbellekten dönen değeri barındırırken, `cached` ve `report` değeri önbellek durumu hakkında daha fazla bilgi sunar. +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise<{ value: number; cached: { stored: number } | null }>; + } +} -`cached.stored` zaman damgasının `last-modified` başlığını ayarlamak için beraber çalışan kullanışı güzel bir sunucu taraflı-istemci taraflı önbellekleme örneğidir. +const add = async (a: string, b: string) => { + await Hoek.wait(1000); + + return Number(a) + Number(b); +}; -```javascript const start = async () => { - //... + const server = Hapi.server({ port: 8000 }); - server.method('sum', add, { - cache: { - cache: 'mongoCache', - expiresIn: 10 * 1000, - generateTimeout: 2000, - getDecoratedValue: true, - }, - }); + const cache = { + expiresIn: 10 * 1000, + generateTimeout: 2000, + getDecoratedValue: true, + }; + + server.method('sum', add, { cache }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { @@ -232,14 +327,14 @@ const start = async () => { const { value, cached } = await server.methods.sum(a, b); const lastModified = cached ? new Date(cached.stored) : new Date(); - return h.response(value).header('Last-modified', lastModified.toUTCString()); + return h.response({ sum: value }).header('Last-Modified', lastModified.toUTCString()); }, }); await server.start(); - - // ... }; + +start(); ``` -`cached` ve `report` hakkında daha fazlasını [Catbox Politika API dokümanları (Catbox Policy API docs)](https://github.com/hapijs/catbox#api-1)nda bulabilirsin. +`cached` ve `report` değerlerinin neler içerdiğini görmek için [policy API](/module/catbox#api-1) sayfasına bak. diff --git a/docs/tutorials/zh_CN/caching.md b/docs/tutorials/zh_CN/caching.md index 8e3126bd..bcfdaff7 100644 --- a/docs/tutorials/zh_CN/caching.md +++ b/docs/tutorials/zh_CN/caching.md @@ -1,26 +1,31 @@ --- -title: Caching +title: 缓存 --- -## 缓存 +# 缓存 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更新版本_ -### 客户端缓存 +## 概述 -HTTP 协议定义了许多 HTTP 头部(headers)信息,方便如浏览器等客户端使用缓存资源。想要更多的了解这些信息,并决定哪些适用与你的用例,可以访问这里 [Google 指南](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching)。 +hapi 在两个层面进行缓存,大多数应用两者都会用到: -这份教程的第一部分展示了 hapi 如何简单的为客户端配置头部信息。 +| 层面 | 副本存放位置 | 节省了什么 | 配置方式 | +| ------------------------ | ------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------ | +| [客户端](#client-side) | 浏览器,或位于中间的 CDN、代理 | 整个请求:客户端根本不会访问 hapi | HTTP 响应头:`Cache-Control`、`ETag`、`Last-Modified` | +| [服务器端](#server-side) | 内存、Redis 或其他 [catbox](/module/catbox) 存储 | 开销大的工作:一次数据库查询、一次 API 调用 | `server.cache()` 或带缓存的[服务器方法](/tutorials/zh_CN/server-methods) | -#### Cache-Control +## 客户端缓存 -`Cache-Control` header 控制缓存的条件以及可以缓存多久。 例如, `Cache-Control:max-age=30, must-revalidate, private` 意味这浏览器可以缓存相关的资源 30 秒,`private` 意味这资源只能被浏览器缓存而不是任何中间缓存,`must-revalidate` 意思是一旦过期客户端就必须向服务器重新请求资源。 +HTTP 响应头告诉客户端能否复用某个响应,以及可以复用多久。MDN 的 [HTTP 缓存指南](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching)对这些响应头有深入的讲解。本节介绍 hapi 如何设置它们。 -让我们看一下 hapi 如何设置这个头部信息: +### Cache-Control -```javascript +设置路由的 `cache` 选项,hapi 就会为你写入 `Cache-Control` 响应头: + +```ts server.route({ path: '/hapi/{ttl?}', method: 'GET', @@ -28,7 +33,7 @@ server.route({ const response = h.response({ be: 'hapi' }); if (request.params.ttl) { - response.ttl(request.params.ttl); + response.ttl(Number(request.params.ttl)); } return response; @@ -42,107 +47,146 @@ server.route({ }); ``` -以上的例子显示了如何为路由设置 `cache` 选项。 这里我们设置 `expiresIn` 为 30 秒,以及 `privacy` 为私有。 -该示例还说明了 `expiresIn` 值可以使用 [response 对象](/api#response-object) 接口提供的 `ttl(毫秒)`值改写。 +`response.ttl(msec)` 会为单个响应覆盖 `expiresIn`。hapi 发送的响应头取决于具体响应: -如果我们向 `/hapi` 发出请求,我们将收到响应头信息 `cache-control: max-age=30, must-revalidate, private`。 如果我们发送请求 `/hapi/5000` 我们则收到响应头信息 `cache-control: max-age=5, must-revalidate, private`。 +| 请求 | `Cache-Control` 响应头 | +| ----------------------------------------------- | -------------------------------------- | +| `GET /hapi` | `max-age=30, must-revalidate, private` | +| `GET /hapi/5000`(调用了 `response.ttl(5000)`) | `max-age=5, must-revalidate, private` | +| 状态码不是 200 或 204 的响应,例如 404 或 500 | `no-cache`(路由的 `cache.otherwise`) | -参考 [route-options](/api#route-options) 可以获得更多关于 `cache` 配置的信息。 +需要注意: -#### Last-Modified +- 如果请求已通过认证,hapi 总会加上 `private`,即使路由设置了 `privacy: 'public'`。CDN 这类共享缓存永远不会存储 `private` 响应。 +- 用 `h.state()` 设置的 cookie 不会改变这个响应头:hapi 在构建 `Cache-Control` 之后才写入 cookie。对于设置 cookie 的缓存路由,请自行设置 `privacy: 'private'`。 +- 只有路由的 `cache.statuses`(默认为 `[200, 204]`)中列出的状态码才会带上 `max-age`。错误响应永远不会被意外缓存。 -在一些情况中,服务器可以提供关于一个资源最后被修改的信息。当使用 [inert](https://github.com/hapijs/inert) 插件来处理静态内容时, 一个 `Last-Modified` header 将在每个响应中自动添加。 +所有 `cache` 设置请参见[路由选项](/api#route-options-cache)。 -当响应中设置了 `Last-Modified` header 时, hapi 通过与客户端发来的 `If-Modified-Since` header 比较之后才来决定响应的返回码是否该为 `304 Not Modified`。 这个做法通常也被称之为条件 GET 请求,它的好处就是告知浏览器当收到一个 `304` 响应时,不需要重新去下载资源。 +### 条件请求:ETag 与 Last-Modified -假设 `lastModified` 是一个 `Date` 对象,你可以通过 [response 对象](/api#response-object) 设置这个头部信息。 +条件请求让客户端可以询问 "自从我拿到副本以来,它变过吗?",并得到一个空的 `304 Not Modified`,而不是完整的响应体: -```javascript -return h.response(result).header('Last-Modified', lastModified.toUTCString()); +```mermaid +sequenceDiagram + participant B as 浏览器 + participant S as hapi 服务器 + B->>S: GET /report + S-->>B: 200 带响应体,ETag "v42" + Note over B: 保存响应体和 ETag + B->>S: GET /report,携带 If-None-Match "v42" + Note over S: handler 运行并再次设置 ETag "v42",
hapi 发现匹配 + S-->>B: 304 Not Modified,无响应体 ``` -这个示例展示了使用 `Last-Modified` 在本教程的 [最后一节](#客户端和服务器端缓存) 中。 - -#### ETag +使用 [`response.etag()`](/api#response-object) 设置 `ETag`,其值通常来自版本号或内容的哈希: -ETag header 是除 `Last-Modified` 之外的另一种选择。这里服务器将会提供一个令牌(token) (通常是资源的校验和) 来代替上次改动的时间戳。浏览器将会在下个请求中使用这个令牌去设置 `If-None-Match` header 信息。服务器将会拿这个头部的值与新 `ETag` 的校验和进行比较来决定要不要返回 `304` 响应。 +```ts +return h.response(report).etag(String(report.version)); +``` -在你的 handler 中你只需通过 `etag(tag, options)` 函数来设置 `ETag`: +或者设置 `Last-Modified`,hapi 会将它与客户端的 `If-Modified-Since` 请求头进行比较: -```javascript -return h.response(result).etag('xxxxxxxxx'); +```ts +return h.response(report).header('Last-Modified', report.updatedAt.toUTCString()); ``` -关于 `etag` 的更多细节如参数和选项等信息,可以在[response 对象](/api#response-object) 中找到。 +需要注意: -### 服务端缓存 +- hapi 只在 `GET` 路由(以及方法为 `*` 的路由)上返回 `304`。 +- handler 在每个请求上仍然会运行。条件请求节省的是带宽,而不是服务器的工作量。将它与[服务器端缓存](#server-side)结合使用,两者都能节省。 +- [inert](/module/inert) 会为静态文件自动设置 `ETag` 和 `Last-Modified`。 -hapi 通过 [catbox](https://www.github.com/hapijs/catbox) 提供强健易用的服务端缓存技术,这份教程接下来的部分将会告诉你如何使用 catbox。 +## 服务器端缓存 -Catbox有两个接口: client 和 policy。 +hapi 通过 [catbox](/module/catbox) 在服务器上缓存值。catbox 是一个键值存储,存储适配器可以互换:内存(默认)、[Redis](/module/catbox-redis) 和 [Memcached](/module/catbox-memcached)。 -#### Client +### catbox + +缓存项按三个层级组织: + +```mermaid +flowchart TD + C["cache client
一个存储连接,例如 Redis"] --> S1["segment '#sum'
某个服务器方法的缓存项"] + C --> S2["segment '!my-plugin'
某个插件的缓存项"] + S1 --> I1["item id '1:5'"] + S1 --> I2["item id '2:3'"] + S2 --> I3["item id 'user:42'"] +``` -[Client](https://github.com/hapijs/catbox#client) 是一个低级别的接口,允许你设置和获取键值对。它通过以下可用的适配器来初始化: ([Memory](https://github.com/hapijs/catbox-memory), [Redis](https://github.com/hapijs/catbox-redis), [mongoDB](https://github.com/hapijs/catbox-mongodb), [Memcached](https://github.com/hapijs/catbox-memcached), or [Riak](https://github.com/DanielBarnes/catbox-riak))。 +catbox 通过两种接口访问这些缓存项: -hapi 通过 [catbox memory](https://github.com/hapijs/catbox-memory) 适配器来初始化一个默认的 [client](https://github.com/hapijs/catbox#client)。让我们看一下如何定义更多的 client。 +- **[Client](/module/catbox#client)**:底层的存储连接。hapi 会为你创建一个内存 client,其他 client 可以在服务器的 `cache` 选项中添加。 +- **[Policy](/module/catbox#policy)**:client 中的一个 segment,带有过期规则和一个可选的函数,用于生成缺失的值。你的代码使用 policy,它通过 [`server.cache()`](/api#server-cache-options) 创建,或者通过缓存[服务器方法](#servermethods)创建。 -```javascript -'use strict'; +### Client -const Hapi = require('@hapi/hapi'); +在默认的内存 client 之外,添加一个名为 `my_cache` 的 Redis client: + +```ts +import Hapi from '@hapi/hapi'; +import { Engine as CatboxRedis } from '@hapi/catbox-redis'; const server = Hapi.server({ port: 8000, cache: [ { - name: 'mongoCache', - engine: require('catbox-mongodb'), - host: '127.0.0.1', - partition: 'cache', - }, - { - name: 'redisCache', - engine: require('catbox-redis'), - host: '127.0.0.1', - partition: 'cache', + name: 'my_cache', + provider: { + constructor: CatboxRedis, + options: { + partition: 'my_cached_data', + host: 'redis-cluster.domain.com', + port: 6379, + database: 0, + tls: {}, + }, + }, }, ], }); ``` -在上面的例子中我们定义了两个 catbox client: mongoCache 与 redisCache。包括 hapi 默认创建的 memory catbox 在内,一共有三个可用的缓存 client。注册新的缓存 client时,可以通过省略 `name` 属性来替换默认的 client 。 `partition` 告诉适配器如何命名缓存( 默认为 'catbox')。在 [mongoDB](http://www.mongodb.org/) 这个值将变成数据库的名称,而在 [redis](http://redis.io/) 中它将会变成键的前缀。 +- policy 通过 `cache: 'my_cache'` 按 `name` 选择这个 client。省略 `name` 则会替换默认的内存 client。 +- `partition` 让本应用的键与共用同一 Redis 数据库的其他应用分开。在 Redis 中它会成为键的前缀,默认值为 `'catbox'`。 +- 缓存 client 在服务器启动时连接,即在 [`server.initialize()`](/api#await-server-initialize) 或 [`server.start()`](/api#await-server-start) 中。在此之前,policy 无法读写。 -#### Policy +### Policy -[Policy](https://github.com/hapijs/catbox#policy) 是比 Client 更高一级别的接口。接下来展示了如何缓存两个数之和的例子,这个示例的原理也可以应用于其余的缓存情况。如函数的回调结果,async或者是别的内容。[server.cache(options)](/api#-servercacheoptions) 创建一个新的[policy](https://github.com/hapijs/catbox#policy), 这个可以在路由 handler 中使用。 +[`server.cache(options)`](/api#server-cache-options) 创建一个 policy。下面的示例将一次缓慢加法运算的结果缓存十秒: -```javascript -const start = async () => { - const add = async (a, b) => { - await Hoek.wait(1000); // 模拟一些慢的 I/O 操作 +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; - return Number(a) + Number(b); - }; +const add = async (a: string, b: string) => { + await Hoek.wait(1000); // Simulates a slow database call + + return Number(a) + Number(b); +}; + +const start = async () => { + const server = Hapi.server({ port: 8000 }); const sumCache = server.cache({ - cache: 'mongoCache', + segment: 'sums', expiresIn: 10 * 1000, - segment: 'customSegment', generateFunc: async (id) => { - return await add(id.a, id.b); + const key = typeof id === 'string' ? id : id.id; + const [a, b] = key.split(':'); + + return await add(a, b); }, generateTimeout: 2000, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; - const id = `${a}:${b}`; - return await sumCache.get({ id, a, b }); + return await sumCache.get(`${a}:${b}`); }, }); @@ -154,76 +198,128 @@ const start = async () => { start(); ``` -如果发送一个请求到 http://localhost:8000/add/1/5, 你将在一秒后得到一个响应 `6`。 如果你再次请求的时候,因为被缓存了,将会立刻得到结果。如果你等待10秒后再次请求, 你会发现它需要等待一段时间,因为缓存的值现在已从缓存中移除了。 +`GET /add/1/5` 大约一秒后返回 `6`。十秒内再次请求,会立即从缓存中返回结果。 + +| 选项 | 含义 | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `segment` | 为该 policy 的缓存项命名。在服务器根级别为必填项。在插件内部默认为 `'!' + plugin name`。除非设置 `shared: true`,两个 policy 不能共用一个 segment。 | +| `cache` | 要使用的 client,例如 `'my_cache'`。默认为内存 client。 | +| `expiresIn` | 缓存项存入后的存活时间,单位为毫秒。也可以改用 `expiresAt: 'HH:MM'`,在每天的某个时刻让所有缓存项过期。 | +| `generateFunc` | 当 `get()` 没有找到值时生成该值。catbox 会存储结果。 | +| `generateTimeout` | `get()` 等待 `generateFunc` 的时长,超时后以 `503 Service Unavailable` 失败。使用 `generateFunc` 时必填;设为 `false` 则无限等待。 | + +传给 `get()` 的 id 可以是字符串,也可以是带有 `id` 字符串的对象,`generateFunc` 会原样收到它。catbox 将其类型定义为 `string | { id: string }`,所以示例在将其拆分回 `a` 和 `b` 之前先收窄了类型。 + +### 重新生成期间提供过期值 + +缓存项过期后,下一个调用者需要等待 `generateFunc`。为了避免等待,可以用 `staleIn` 在缓存项过期之前将其标记为陈旧。陈旧的缓存项仍然会被返回,同时 catbox 在后台重新生成它: + +```mermaid +flowchart TD + G["sumCache.get(id)"] --> Q{缓存中有该项?} + Q -->|"新鲜:存在时间短于 staleIn"| F[返回缓存值] + Q -->|"陈旧:存在时间超过 staleIn"| R["调用 generateFunc"] + R --> R1["在 staleTimeout 内得到新值:
返回并存储"] + R --> R2["慢于 staleTimeout:
立即返回陈旧值,
新值到达后再存储"] + Q -->|缺失或已过期| M["调用 generateFunc"] + M --> M1["在 generateTimeout 内得到值:
返回并存储"] + M --> M2["慢于 generateTimeout:
503 Service Unavailable"] +``` + +```ts +const reportCache = server.cache({ + segment: 'reports', + expiresIn: 60 * 60 * 1000, + staleIn: 10 * 60 * 1000, + staleTimeout: 100, + generateTimeout: 5000, + generateFunc: async (id) => await buildReport(id), +}); +``` + +catbox 要求 `staleIn < expiresIn`,设置了 `staleIn` 时必须设置 `staleTimeout`,并且要求 `staleTimeout < expiresIn - staleIn`。针对同一 id 的并发 `get()` 调用会共享同一次 `generateFunc` 调用,因此对某个缺失缓存项的一大波请求只会查询一次数据库。`dropOnError`、`generateOnReadError` 等错误处理设置,请参见 [policy 选项](/module/catbox#policy)。 -`cache` 选项告诉 hapi 哪个 [client](https://github.com/hapijs/catbox#client) 该被使用。 +### 服务器方法 -`sumCache.get()` 函数的第一个参数是一个 id, 这可以为一个字符串或者一个拥有 `id` 属性的对象,这个属性是用来在缓存中标识对象。 +大多数情况下,带缓存的[服务器方法](/tutorials/zh_CN/server-methods)比手写 policy 更简洁。hapi 会创建 policy,将其 segment 命名为 `'#sum'`,并根据参数构建缓存项 id: -除了 **分区(partitions)** 之外, 还有 **分段(segments)** ,它允许你在一个 [client](https://github.com/hapijs/catbox#client) 分区中进一步隔离缓存。如果需要缓存来自两种不同方法的结果,你通常不希望结果混在一起。在 [mongoDB](http://www.mongodb.org/) 适配器中, `segment` 代表一个集合,而在 [redis](http://redis.io/) 中它则是一个额外的拥有 `partition` 选项的前缀。 +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -当在插件内部调用 [server.cache()](/api#-servercacheoptions) 时,`segment` 的默认值为 `'!pluginName'`。当创建[server methods](/tutorials/servermethods) 时, `segment` 值将会是 `'#methodName'`。 如果你需要一个用于共享一个分段或者多个分段的用例,可以使用 [shared](/api#-servercacheoptions) 选项。 +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise; + } +} -#### 服务器方法 +const add = async (a: string, b: string) => { + await Hoek.wait(1000); -除此之外我们可以做的更好!在 95% 的情况下,可以通过使用 [server methods](/tutorials/servermethods) 进行缓存, 可以将样本代码减少到最小。让我们使用服务器方法重写前面的示例: + return Number(a) + Number(b); +}; -```javascript const start = async () => { - // ... + const server = Hapi.server({ port: 8000 }); server.method('sum', add, { cache: { - cache: 'mongoCache', expiresIn: 10 * 1000, generateTimeout: 2000, }, }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { const { a, b } = request.params; + return await server.methods.sum(a, b); }, }); await server.start(); - - // ... }; start(); ``` -[server.method()](/api#-servermethodname-method-options) 创建一个新的包含 `segment: '#sum'` 的 [policy](https://github.com/hapijs/catbox#policy),以及通过参数自动生成的唯一的 `id` (缓存的键)。 默认来说它只处理 `string`, `number` 以及 `boolean` 参数。对于更复杂的参数,需要提供 `generateKey` 函数去创建一个基于参数的唯一id。 - 请参阅教程的这部分内容 [服务器方法](/tutorials/servermethods) 。 +- `declare module` 代码块将 `sum` 添加到 hapi 的 `ServerMethods` 接口中,这样 `server.methods.sum` 就有了类型。 +- hapi 根据 `string`、`number` 和 `boolean` 类型的参数构建缓存项 id,例如 `'1:5'`。其他类型的参数需要提供 [`generateKey`](/tutorials/zh_CN/server-methods#key) 函数。 + +## 客户端与服务器端缓存 -#### 接下来呢? +将两个层面结合起来:服务器缓存会记录每个值的存储时间,这个时间正适合作为响应的 `Last-Modified`。设置 `getDecoratedValue: true` 后,带缓存的方法会返回 `{ value, cached, report }`,而不是单纯的值。如果值是刚生成的,`cached` 为 `null`: -- 更深入的了解 catbox policy [options](https://github.com/hapijs/catbox#policy) ,了解更多关于 `staleIn`, `staleTimeout`, `generateTimeout`的信息, 以充分利用 catbox 缓存的潜力。 -- 阅读服务器方法的教程 [服务器方法](http://hapijs.com/tutorials/servermethods) 了解更多示例。 +```ts +import Hapi from '@hapi/hapi'; +import * as Hoek from '@hapi/hoek'; -### 客户端和服务器端缓存 +declare module '@hapi/hapi' { + interface ServerMethods { + sum: (a: string, b: string) => Promise<{ value: number; cached: { stored: number } | null }>; + } +} -通常作为可选项,[Catbox Policy](https://github.com/hapijs/catbox#policy) 可以提供从缓存中检索的值的更多信息。若要开启此选项,需要在创建 policy 时将 `getDecoratedValue` 的值设置为 true 。这样,从服务器方法返回的任何值都将是一个对象 `{ value, cached, report }`。 `value` 只是缓存中的项目, `cached` 和 `report` 提供了有关项目缓存状态的一些额外细节。 +const add = async (a: string, b: string) => { + await Hoek.wait(1000); -服务器端和客户端缓存协同工作的一个例子是使用 `cached.stored` 时间戳设置 `last-modified` 头信息来实现的: + return Number(a) + Number(b); +}; -```javascript const start = async () => { - //... + const server = Hapi.server({ port: 8000 }); - server.method('sum', add, { - cache: { - cache: 'mongoCache', - expiresIn: 10 * 1000, - generateTimeout: 2000, - getDecoratedValue: true, - }, - }); + const cache = { + expiresIn: 10 * 1000, + generateTimeout: 2000, + getDecoratedValue: true, + }; + + server.method('sum', add, { cache }); - server.route({ + server.route<{ Params: { a: string; b: string } }>({ path: '/add/{a}/{b}', method: 'GET', handler: async function (request, h) { @@ -231,14 +327,14 @@ const start = async () => { const { value, cached } = await server.methods.sum(a, b); const lastModified = cached ? new Date(cached.stored) : new Date(); - return h.response(value).header('Last-modified', lastModified.toUTCString()); + return h.response({ sum: value }).header('Last-Modified', lastModified.toUTCString()); }, }); await server.start(); - - // ... }; + +start(); ``` -关于`cached` 和 `report` 的更多信息可以在 [Catbox Policy API docs](https://github.com/hapijs/catbox#api-1) 的文档中找到。 +`cached` 和 `report` 的内容请参见 [policy API](/module/catbox#api-1)。 From 45e4daede494efb2c66cd6711a5a58ba6df85d92 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:56 -0400 Subject: [PATCH 08/22] docs: rework cookies tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/cookies.md | 184 +++++++++++++++++++------------- docs/tutorials/es_ES/cookies.md | 165 ++++++++++++++++++++++++++++ docs/tutorials/ko_KR/cookies.md | 178 +++++++++++++++++++++--------- docs/tutorials/pt_BR/cookies.md | 172 ++++++++++++++++++++++------- docs/tutorials/tr_TR/cookies.md | 178 +++++++++++++++++++++--------- docs/tutorials/zh_CN/cookies.md | 178 +++++++++++++++++++++--------- 6 files changed, 788 insertions(+), 267 deletions(-) create mode 100644 docs/tutorials/es_ES/cookies.md diff --git a/docs/tutorials/en_US/cookies.md b/docs/tutorials/en_US/cookies.md index a1a24212..4e1b4275 100644 --- a/docs/tutorials/en_US/cookies.md +++ b/docs/tutorials/en_US/cookies.md @@ -6,122 +6,160 @@ title: Cookies # Cookies -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -When writing a web application, cookies are often used to keep state about a user between requests. With hapi, cookies are flexible, secure, and simple to use. +hapi reads the request's `Cookie` header into `request.state`, and writes `Set-Cookie` headers from `h.state()`. Each cookie is declared once with `server.state()`, which sets its encoding and security flags, so handlers only deal with values: + +```mermaid +sequenceDiagram + participant B as Browser + participant S as hapi server + B->>S: GET /theme/dark + Note over S: h.state('prefs', { theme: 'dark' }) + S-->>B: 200, Set-Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + B->>S: GET / with Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + Note over S: request.state.prefs is { theme: 'dark' } + S-->>B: 200 "Theme: dark" + B->>S: GET /reset + Note over S: h.unstate('prefs') + S-->>B: 200, Set-Cookie prefs with Max-Age=0 +``` -## Configuring the server +The complete server behind this exchange: -hapi has several configurable options when dealing with cookies. The defaults are probably good for most cases, but can be changed when needed. To prepare a cookie, you first need to name it and configure a list of options. You do this by calling `server.state(name, [options])`. +```ts +import Hapi from '@hapi/hapi'; -### server.state() +const server = Hapi.server({ port: 3000 }); -To use a cookie, you first need to configure it by calling [`server.state(name, [options])`]() where `name` is the name of the cookie, and `options` is an object used to configure the cookie. +server.state('prefs', { + encoding: 'base64json', + ttl: 365 * 24 * 60 * 60 * 1000, + isSecure: process.env.NODE_ENV === 'production', + path: '/', +}); -Please note that the default settings for `options` is good for most cases and don't need to be configured. For learning purposes, you will configure them in this tutorial: +server.route<{ Params: { theme: string } }>({ + method: 'GET', + path: '/theme/{theme}', + handler: (request, h) => { + h.state('prefs', { theme: request.params.theme }); + return 'saved'; + }, +}); -```javascript -server.state('data', { - ttl: null, - isSecure: true, - isHttpOnly: true, - encoding: 'base64json', - clearInvalid: true, - strictHeader: true, +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + const prefs = request.state.prefs; + const theme = typeof prefs === 'object' && prefs !== null && 'theme' in prefs ? prefs.theme : 'light'; + + return `Theme: ${theme}`; + }, }); -``` -In the example above, you first name the cookie `data`. You then configure the options to customize the cookie. +server.route({ + method: 'GET', + path: '/reset', + handler: (request, h) => { + return h.response('reset').unstate('prefs'); + }, +}); -The first option is `ttl`. This represents the cookies time to live in milliseconds. The default setting is `null`, which will delete the cookie once the browser is closed. +await server.start(); +``` -Next we have two flags, `isSecure` and `isHttpOnly`. For more info on these flags, see ([RFC 6265](http://tools.ietf.org/html/rfc6265), specifically sections [4.1.2.5](http://tools.ietf.org/html/rfc6265#section-4.1.2.5) and [4.1.2.6](http://tools.ietf.org/html/rfc6265#section-4.1.2.6). +`request.state` is typed `Record`, because a cookie holds whatever the client sends. The `/` handler checks the value's shape before reading `theme`, and falls back to a default. + +## Configuring the server -You then tell hapi that the value is a base64 encoded JSON string. +### server.state() -By setting `clearInvalid` to `true`, it instructs the client to remove invalid cookies. +[`server.state(name, [options])`](/api#server-state-name-options) declares a cookie. The defaults are secure, so most applications only set `encoding`, `ttl`, and `path`: -Last, you set `strictHeader` to `true` so that there are no violations of [RFC 6265](https://tools.ietf.org/html/rfc6265). +| Option | Default | Meaning | +| -------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ttl` | `null` | Lifetime in milliseconds. `null` makes a session cookie, deleted when the browser closes. | +| `encoding` | `'none'` | How the value is stored: `'none'` (a string), `'base64'`, `'base64json'` (any JSON value), `'form'`, or `'iron'` (encrypted, see [below](#iron)). | +| `path` | `null` | The URL path the browser sends the cookie for. `null` leaves the attribute out, and browsers then use the directory of the request that set it. Set `'/'` for site-wide cookies. | +| `isSecure` | `true` | Sent only over HTTPS. Set `false` for plain HTTP during development. | +| `isHttpOnly` | `true` | Hidden from browser JavaScript, which protects it from cross-site scripting. | +| `isSameSite` | `'Strict'` | Withheld on requests from other sites. `'Lax'` allows top-level navigation from other sites; `false` omits the attribute. | +| `clearInvalid` | `false` | Tells the browser to delete the cookie when its value fails to parse. | +| `strictHeader` | `true` | Rejects names and values that break [RFC 6265](https://tools.ietf.org/html/rfc6265). | -### route.options.state() +See [`server.state()`](/api#server-state-name-options) for the rest, such as `domain`, `autoValue`, and signing. To change the defaults for every cookie, pass the same options as the server's [`state`](/api#server-options-state) option. -In addition to this, you can further configure cookie behavior at a route-level by specifying two properties at the route's `options.state` object. +### Encrypted cookies -Please note that configurations to cookies on the route-level are in addition to those configurations made by `server.state`. +`encoding: 'iron'` encrypts and signs the value with [iron](/module/iron). The client cannot read it, and hapi rejects it if it was changed. Use it for anything you would not show the user, such as a session id: -```json5 -{ - options: { - state: { - parse: true, - failAction: 'error', - }, - }, +```ts +const password = process.env.COOKIE_PASSWORD; +if (!password || password.length < 32) { + throw new Error('COOKIE_PASSWORD must be set to at least 32 characters'); } + +server.state('session', { + encoding: 'iron', + password, + ttl: 24 * 60 * 60 * 1000, + path: '/', +}); ``` -The `parse` option determines if cookies are parsed and stored in `request.state`. +For login sessions, [@hapi/cookie](/module/cookie) builds on this: it manages an encrypted session cookie as an [authentication strategy](/tutorials/en_US/auth#cookie). -The `failAction` options determines how cookie parsing errors will be handled. +### route.options.state -## Setting a cookie +Routes control parsing with [`options.state`](/api#route-options-state): -Setting a cookie is done via the [response toolkit](/api#response-toolkit) in a request handler, pre-requisite, or request lifecycle extension point. +| Option | Default | Meaning | +| ------------ | --------- | ------------------------------------------------------------------------------- | +| `parse` | `true` | Parse cookies into `request.state`. Set `false` on routes that never read them. | +| `failAction` | `'error'` | What happens when a cookie fails to parse or decrypt. | -### h.state() +A cookie fails when its value doesn't match its encoding, or an iron cookie was tampered with or encrypted with another password: -You set a cookie by calling [`h.state(name, value, [options]`](). In the following example, you set a cookie in a route handler: +| `failAction` | Result | +| ------------ | ------------------------------------------------------------------ | +| `'error'` | the request fails with `400 Bad Request` | +| `'log'` | the request continues without that cookie, and hapi logs the error | +| `'ignore'` | the request continues without that cookie | -```javascript -server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - h.state('data', { firstVisit: false }); - return h.response('Hello'); - }, -}); -``` +With `clearInvalid: true` on the cookie, the response also deletes the bad cookie from the browser. Combine it with `'log'` or `'ignore'` so the request carrying a stale cookie, for example one encrypted before a password change, succeeds instead of failing once with 400. + +## Setting a cookie -In this example, you use the cookie we configured in the section above. Here, hapi will reply with the string `Hello` as well as set a cookie named `data` to a base64 encoded JSON string representation of `{ firstVisit: false }`. +### h.state() + +[`h.state(name, value, [options])`](/api#h-state-name-value-options) sets a cookie from a handler, a [pre-handler](/api#route-options-pre), or a [lifecycle hook](/tutorials/en_US/lifecycle-hooks). The same method exists on the response object, for chaining: -The `state()` method is also available on the [response object](/api#response-object) which allows for convenient chaining. The above example can therefore also be written: +```ts +h.state('prefs', { theme: 'dark' }); -```javascript -return h.response('Hello').state('data', { firstVisit: false }); +return h.response('Hello').state('prefs', { theme: 'dark' }); ``` ## Overriding options -When setting a cookie, you may also pass the same options available to `server.state()` as a third parameter, such as: +The third argument overrides the cookie's declared options for this response only: -```javascript -return h.response('Hello').state('data', 'test', { encoding: 'none' }); +```ts +return h.response('Hello').state('prefs', 'dark', { encoding: 'none' }); ``` -In this example the cookie will simply be set to the string `"test"` with no encoding. - ## Getting a cookie value -Access a cookie’s value via `request.state` in a route handler, pre-requisite, or request lifecycle extension point. - -The `request.state` object contains the parsed HTTP state. Each key represents the cookie name and its value is the defined content. If multiple cookies with the same name are present on the request, the values are exposed as an array rather than a single value. - -The sample code uses the `data` cookie key from above where the related value is set to `{ firstVisit: false }`: - -```javascript -const value = request.state.data; -// console.log(value) will give you { firstVisit : false } -``` +`request.state` holds every parsed cookie, keyed by name. If the browser sends two cookies with the same name, the value is an array. Read values the way the overview example does: check the shape, then use it. ## Clearing a cookie -The cookie can be cleared by calling the `unstate()` method on the [response toolkit](/api#response-toolkit) or [response object](/api#response-object): +[`h.unstate(name)`](/api#h-unstate-name-options), or `unstate()` on the response, tells the browser to delete the cookie: -```javascript -return h.response('Bye').unstate('data'); +```ts +return h.response('Bye').unstate('prefs'); ``` - -Here, you just pass the name of the cookie into `unstate()` to clear the cookie. diff --git a/docs/tutorials/es_ES/cookies.md b/docs/tutorials/es_ES/cookies.md new file mode 100644 index 00000000..b9b6a84d --- /dev/null +++ b/docs/tutorials/es_ES/cookies.md @@ -0,0 +1,165 @@ +--- +title: Cookies +--- + + + +# Cookies + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +hapi lee la cabecera `Cookie` de la petición en `request.state` y escribe cabeceras `Set-Cookie` a partir de `h.state()`. Cada cookie se declara una sola vez con `server.state()`, que fija su codificación y sus flags de seguridad, de modo que los handlers solo trabajan con valores: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as Servidor hapi + B->>S: GET /theme/dark + Note over S: h.state('prefs', { theme: 'dark' }) + S-->>B: 200, Set-Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + B->>S: GET / con Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + Note over S: request.state.prefs es { theme: 'dark' } + S-->>B: 200 "Theme: dark" + B->>S: GET /reset + Note over S: h.unstate('prefs') + S-->>B: 200, Set-Cookie prefs con Max-Age=0 +``` + +El servidor completo detrás de este intercambio: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 3000 }); + +server.state('prefs', { + encoding: 'base64json', + ttl: 365 * 24 * 60 * 60 * 1000, + isSecure: process.env.NODE_ENV === 'production', + path: '/', +}); + +server.route<{ Params: { theme: string } }>({ + method: 'GET', + path: '/theme/{theme}', + handler: (request, h) => { + h.state('prefs', { theme: request.params.theme }); + return 'saved'; + }, +}); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + const prefs = request.state.prefs; + const theme = typeof prefs === 'object' && prefs !== null && 'theme' in prefs ? prefs.theme : 'light'; + + return `Theme: ${theme}`; + }, +}); + +server.route({ + method: 'GET', + path: '/reset', + handler: (request, h) => { + return h.response('reset').unstate('prefs'); + }, +}); + +await server.start(); +``` + +`request.state` tiene el tipo `Record`, porque una cookie contiene lo que el cliente envíe. El handler de `/` comprueba la forma del valor antes de leer `theme` y, si no encaja, usa un valor por defecto. + +## Configurar el servidor + +### server.state() + +[`server.state(name, [options])`](/api#server-state-name-options) declara una cookie. Los valores por defecto son seguros, así que la mayoría de las aplicaciones solo fijan `encoding`, `ttl` y `path`: + +| Opción | Por defecto | Significado | +| -------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ttl` | `null` | Duración en milisegundos. `null` crea una cookie de sesión, que se borra al cerrar el navegador. | +| `encoding` | `'none'` | Cómo se guarda el valor: `'none'` (una cadena), `'base64'`, `'base64json'` (cualquier valor JSON), `'form'` o `'iron'` (cifrado, ver [más abajo](#iron)). | +| `path` | `null` | La ruta de URL para la que el navegador envía la cookie. `null` omite el atributo, y entonces los navegadores usan el directorio de la petición que la creó. Usa `'/'` para cookies de todo el sitio. | +| `isSecure` | `true` | Solo se envía por HTTPS. Usa `false` para HTTP sin cifrar durante el desarrollo. | +| `isHttpOnly` | `true` | Oculta al JavaScript del navegador, lo que la protege frente a cross-site scripting. | +| `isSameSite` | `'Strict'` | No se envía en peticiones procedentes de otros sitios. `'Lax'` permite la navegación de primer nivel desde otros sitios; `false` omite el atributo. | +| `clearInvalid` | `false` | Indica al navegador que borre la cookie cuando su valor no se puede parsear. | +| `strictHeader` | `true` | Rechaza nombres y valores que incumplen el [RFC 6265](https://tools.ietf.org/html/rfc6265). | + +Consulta [`server.state()`](/api#server-state-name-options) para el resto, como `domain`, `autoValue` y la firma. Para cambiar los valores por defecto de todas las cookies, pasa esas mismas opciones en la opción [`state`](/api#server-options-state) del servidor. + +### Cookies cifradas + +`encoding: 'iron'` cifra y firma el valor con [iron](/module/iron). El cliente no puede leerlo, y hapi lo rechaza si se ha modificado. Úsalo para todo lo que no mostrarías al usuario, como un id de sesión: + +```ts +const password = process.env.COOKIE_PASSWORD; +if (!password || password.length < 32) { + throw new Error('COOKIE_PASSWORD must be set to at least 32 characters'); +} + +server.state('session', { + encoding: 'iron', + password, + ttl: 24 * 60 * 60 * 1000, + path: '/', +}); +``` + +Para sesiones de login, [@hapi/cookie](/module/cookie) se apoya en esto: gestiona una cookie de sesión cifrada como [strategy de autenticación](/tutorials/es_ES/auth#cookie). + +### route.options.state + +Las rutas controlan el análisis con [`options.state`](/api#route-options-state): + +| Opción | Por defecto | Significado | +| ------------ | ----------- | ----------------------------------------------------------------------------------- | +| `parse` | `true` | Parsea las cookies en `request.state`. Usa `false` en las rutas que nunca las leen. | +| `failAction` | `'error'` | Qué ocurre cuando una cookie no se puede parsear o descifrar. | + +Una cookie falla cuando su valor no corresponde a su codificación, o cuando una cookie iron se ha manipulado o se cifró con otra contraseña: + +| `failAction` | Resultado | +| ------------ | ------------------------------------------------------------- | +| `'error'` | la petición falla con `400 Bad Request` | +| `'log'` | la petición continúa sin esa cookie, y hapi registra el error | +| `'ignore'` | la petición continúa sin esa cookie | + +Con `clearInvalid: true` en la cookie, la respuesta además borra la cookie defectuosa del navegador. Combínalo con `'log'` o `'ignore'` para que la petición que lleva una cookie caducada, por ejemplo una cifrada antes de un cambio de contraseña, tenga éxito en lugar de fallar una vez con 400. + +## Crear una cookie + +### h.state() + +[`h.state(name, value, [options])`](/api#h-state-name-value-options) crea una cookie desde un handler, un [pre-handler](/api#route-options-pre) o un [hook del ciclo de vida](/tutorials/es_ES/lifecycle-hooks). El mismo método existe en el objeto response, para encadenar llamadas: + +```ts +h.state('prefs', { theme: 'dark' }); + +return h.response('Hello').state('prefs', { theme: 'dark' }); +``` + +## Sobrescribir opciones + +El tercer argumento sobrescribe las opciones declaradas de la cookie solo para esta respuesta: + +```ts +return h.response('Hello').state('prefs', 'dark', { encoding: 'none' }); +``` + +## Leer el valor de una cookie + +`request.state` contiene todas las cookies parseadas, indexadas por nombre. Si el navegador envía dos cookies con el mismo nombre, el valor es un array. Lee los valores como hace el ejemplo de la introducción: comprueba la forma y después úsalo. + +## Borrar una cookie + +[`h.unstate(name)`](/api#h-unstate-name-options), o `unstate()` en la respuesta, indica al navegador que borre la cookie: + +```ts +return h.response('Bye').unstate('prefs'); +``` diff --git a/docs/tutorials/ko_KR/cookies.md b/docs/tutorials/ko_KR/cookies.md index f1e34c74..2385e39d 100644 --- a/docs/tutorials/ko_KR/cookies.md +++ b/docs/tutorials/ko_KR/cookies.md @@ -1,91 +1,165 @@ --- -title: Cookies +title: 쿠키 --- -## Cookies +# 쿠키 -_이 튜터리얼은 hapi v17에 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -웹 응용프로그램을 작성할 때 쿠키는 요청들 사이에서 사용자에 대한 상태를 보관하려고 할 때 종종 사용됩니다. hapi를 사용한다면 쿠키는 유연하며 안전하고 간단합니다. +## 개요 -## 서버 설정하기 +hapi는 요청의 `Cookie` 헤더를 읽어 `request.state`에 담고, `h.state()`로 `Set-Cookie` 헤더를 작성합니다. 각 쿠키는 `server.state()`로 한 번만 선언하며, 이때 인코딩과 보안 플래그를 정하므로 핸들러에서는 값만 다루면 됩니다. -hapi는 쿠키를 다룰 때 몇 가지 설정 가능한 옵션을 가지고 있습니다. 대부분의 경우 기본값이 적절한 선택이지만 필요할 때 변경할 수 있습니다. +```mermaid +sequenceDiagram + participant B as 브라우저 + participant S as hapi 서버 + B->>S: GET /theme/dark + Note over S: h.state('prefs', { theme: 'dark' }) + S-->>B: 200, Set-Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + B->>S: Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ==와 함께 GET / + Note over S: request.state.prefs는 { theme: 'dark' } + S-->>B: 200 "Theme: dark" + B->>S: GET /reset + Note over S: h.unstate('prefs') + S-->>B: 200, Max-Age=0인 Set-Cookie prefs +``` + +이 주고받기를 처리하는 전체 서버 코드입니다. -쿠키를 사용하려면 우선 [`server.state(name, [options])`](/api#-serverstatename-options)를 호출해서 설정해야 합니다. `name`은 쿠키의 이름이고, `options`는 쿠키를 설정하는 데 사용하는 객체입니다. +```ts +import Hapi from '@hapi/hapi'; -```javascript -server.state('data', { - ttl: null, - isSecure: true, - isHttpOnly: true, +const server = Hapi.server({ port: 3000 }); + +server.state('prefs', { encoding: 'base64json', - clearInvalid: false, // remove invalid cookies - strictHeader: true, // don't allow violations of RFC 6265 + ttl: 365 * 24 * 60 * 60 * 1000, + isSecure: process.env.NODE_ENV === 'production', + path: '/', }); -``` -이 설정은 세션 수명을(브라우저가 닫힐 때 삭제) 가지고 있고, secure와 HTTP only 두 플래그가 설정되었으며(이 플래그에 대해서 더욱 자세한 정보는 [RFC 6265](http://tools.ietf.org/html/rfc6265), 특히 [4.1.2.5](http://tools.ietf.org/html/rfc6265#section-4.1.2.5) 장과 and [4.1.2.6](http://tools.ietf.org/html/rfc6265#section-4.1.2.6) 장을 봐주세요) hapi에게 값은 base64로 인코딩된 JSON 문자열이라는 것을 알려주는 `data`라는 쿠키를 만듭니다. `server.state()` 옵션 관련 전체 문서는 [the API reference](/api#serverstatename-options)에서 찾을 수 있습니다. +server.route<{ Params: { theme: string } }>({ + method: 'GET', + path: '/theme/{theme}', + handler: (request, h) => { + h.state('prefs', { theme: request.params.theme }); + return 'saved'; + }, +}); -이 외에도 경로의 `options.state` 객체에서 두 속성을 지정하여 경로 수준에서 쿠키의 행동을 설정할 수 있습니다. +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + const prefs = request.state.prefs; + const theme = typeof prefs === 'object' && prefs !== null && 'theme' in prefs ? prefs.theme : 'light'; -```json5 -{ - options: { - state: { - parse: true, // parse cookies and store in request.state - failAction: 'error', // may also be 'ignore' or 'log' - }, + return `Theme: ${theme}`; }, -} +}); + +server.route({ + method: 'GET', + path: '/reset', + handler: (request, h) => { + return h.response('reset').unstate('prefs'); + }, +}); + +await server.start(); ``` -## 쿠키 설정하기 +쿠키에는 클라이언트가 보낸 무엇이든 들어 있을 수 있으므로 `request.state`의 타입은 `Record`입니다. `/` 핸들러는 `theme`을 읽기 전에 값의 형태를 확인하고, 맞지 않으면 기본값을 사용합니다. -쿠키 설정은 요청 처리기, 필요조건(pre-requiste) 또는 요청 생애 주기 확장 지점에서 [response toolkit](/api#response-toolkit)를 통해 다음과 같은 모습으로 수행됩니다. +## 서버 설정 -```javascript -h.state('data', { firstVisit: false }); -return h.response('Hello'); -``` +### server.state() -이 예제에서 hapi는 `data`라는 쿠키를 `{ firstVisit: false }`의 base64로 인코딩된 문자열 표현으로 설정하는 것뿐만 아니라 문자열 `Hello`을 응답합니다. +[`server.state(name, [options])`](/api#server-state-name-options)는 쿠키를 선언합니다. 기본값이 안전하게 설정되어 있으므로, 대부분의 애플리케이션은 `encoding`, `ttl`, `path`만 지정합니다. -`state()` 메소드는 편리하게 연속 호출이(chaining) [response object](/api#response-object)로도 가능합니다. 앞의 예제는 다음과 같이 작성될 수도 있습니다.: +| 옵션 | 기본값 | 의미 | +| -------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ttl` | `null` | 수명(밀리초). `null`이면 브라우저를 닫을 때 삭제되는 세션 쿠키가 됩니다. | +| `encoding` | `'none'` | 값을 저장하는 방식: `'none'`(문자열), `'base64'`, `'base64json'`(모든 JSON 값), `'form'`, `'iron'`(암호화, [아래](#iron) 참고). | +| `path` | `null` | 브라우저가 쿠키를 보낼 URL 경로. `null`이면 속성을 생략하며, 이 경우 브라우저는 쿠키를 설정한 요청의 디렉터리를 사용합니다. 사이트 전체 쿠키라면 `'/'`로 설정하세요. | +| `isSecure` | `true` | HTTPS로만 전송합니다. 개발 중 일반 HTTP를 쓴다면 `false`로 설정하세요. | +| `isHttpOnly` | `true` | 브라우저 JavaScript에서 보이지 않게 하여 크로스 사이트 스크립팅(XSS)으로부터 보호합니다. | +| `isSameSite` | `'Strict'` | 다른 사이트에서 온 요청에는 보내지 않습니다. `'Lax'`는 다른 사이트에서의 최상위 탐색을 허용하고, `false`는 속성을 생략합니다. | +| `clearInvalid` | `false` | 값을 파싱하지 못하면 브라우저에 쿠키를 삭제하라고 알립니다. | +| `strictHeader` | `true` | [RFC 6265](https://tools.ietf.org/html/rfc6265)를 위반하는 이름과 값을 거부합니다. | -```javascript -return h.response('Hello').state('data', { firstVisit: false }); -``` +`domain`, `autoValue`, 서명 등 나머지 옵션은 [`server.state()`](/api#server-state-name-options)를 참고하세요. 모든 쿠키의 기본값을 바꾸려면 서버의 [`state`](/api#server-options-state) 옵션에 같은 옵션을 전달하세요. -### 옵션 덮어쓰기 +### 암호화된 쿠키 -쿠키를 설정할 때 `server.state()` 함수의 3번째 인자로 같은 옵션을 다음처럼 전달할 수도 있습니다.: +`encoding: 'iron'`은 [iron](/module/iron)으로 값을 암호화하고 서명합니다. 클라이언트는 값을 읽을 수 없고, 값이 변경되면 hapi가 거부합니다. 세션 id처럼 사용자에게 보여 주지 않을 값에 사용하세요. -```javascript -return h.response('Hello').state('data', 'test', { encoding: 'none' }); +```ts +const password = process.env.COOKIE_PASSWORD; +if (!password || password.length < 32) { + throw new Error('COOKIE_PASSWORD must be set to at least 32 characters'); +} + +server.state('session', { + encoding: 'iron', + password, + ttl: 24 * 60 * 60 * 1000, + path: '/', +}); ``` -이 예제에서 쿠키는 인코딩 없이 `"test"` 문자열로 설정될 것입니다. +로그인 세션이라면 이를 기반으로 만든 [@hapi/cookie](/module/cookie)를 사용하세요. 암호화된 세션 쿠키를 [인증 전략](/tutorials/ko_KR/auth#cookie)으로 관리해 줍니다. + +### route.options.state + +라우트는 [`options.state`](/api#route-options-state)로 파싱을 제어합니다. + +| 옵션 | 기본값 | 의미 | +| ------------ | --------- | --------------------------------------------------------------------------------------------- | +| `parse` | `true` | 쿠키를 파싱해 `request.state`에 담습니다. 쿠키를 읽지 않는 라우트에서는 `false`로 설정하세요. | +| `failAction` | `'error'` | 쿠키를 파싱하거나 복호화하지 못했을 때의 동작입니다. | + +쿠키 값이 인코딩과 맞지 않거나, iron 쿠키가 변조되었거나 다른 비밀번호로 암호화되었으면 실패합니다. + +| `failAction` | 결과 | +| ------------ | --------------------------------------------------------- | +| `'error'` | 요청이 `400 Bad Request`로 실패 | +| `'log'` | 해당 쿠키 없이 요청이 계속되고, hapi가 에러를 로그로 남김 | +| `'ignore'` | 해당 쿠키 없이 요청이 계속됨 | -## 쿠키 값 가져오기 +쿠키에 `clearInvalid: true`를 설정하면 응답이 브라우저에서 잘못된 쿠키도 삭제합니다. `'log'`나 `'ignore'`와 함께 쓰면, 비밀번호를 바꾸기 전에 암호화된 쿠키처럼 오래된 쿠키를 가진 요청이 400으로 한 번 실패하는 대신 성공합니다. -쿠키 설정은 경로 처리기, 필요조건(pre-requiste) 또는 요청 생애 주기 확장 지점에서 `request.state`를 통해 접근가능합니다. +## 쿠키 설정하기 -`request.state` 객체는 파싱된 HTTP 상태를 포함하고 있습니다. 각 키는 쿠키의 이름을 나타내고, 그 값은 정의된 내용입니다. +### h.state() -```javascript -const value = request.state.data; -// console.log(value) will give you { firstVisit : false } +[`h.state(name, value, [options])`](/api#h-state-name-value-options)는 핸들러, [pre-handler](/api#route-options-pre), [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks)에서 쿠키를 설정합니다. 체이닝할 수 있도록 응답 객체에도 같은 메서드가 있습니다. + +```ts +h.state('prefs', { theme: 'dark' }); + +return h.response('Hello').state('prefs', { theme: 'dark' }); ``` -예제 코드는 `{ firstVisit: false }` 값을 설정한 위 예제의 `data` 쿠키 키를 사용합니다. +## 옵션 덮어쓰기 + +세 번째 인자는 이 응답에 한해 쿠키에 선언된 옵션을 덮어씁니다. + +```ts +return h.response('Hello').state('prefs', 'dark', { encoding: 'none' }); +``` + +## 쿠키 값 가져오기 + +`request.state`에는 파싱된 모든 쿠키가 이름을 키로 담겨 있습니다. 브라우저가 같은 이름의 쿠키를 두 개 보내면 값은 배열이 됩니다. 개요의 예제처럼 형태를 확인한 다음 값을 사용하세요. -## 쿠키 지우기 +## 쿠키 삭제하기 -[response toolkit](/api#response-toolkit) 또는 [response object](/api#response-object)의 `unstate()` 메소드를 호출하는 것으로 쿠키를 지울 수 있습니다. +[`h.unstate(name)`](/api#h-unstate-name-options) 또는 응답 객체의 `unstate()`는 브라우저에 쿠키를 삭제하라고 알립니다. -```javascript -return h.response('Bye').unstate('data'); +```ts +return h.response('Bye').unstate('prefs'); ``` diff --git a/docs/tutorials/pt_BR/cookies.md b/docs/tutorials/pt_BR/cookies.md index 06946360..431dfabc 100644 --- a/docs/tutorials/pt_BR/cookies.md +++ b/docs/tutorials/pt_BR/cookies.md @@ -4,66 +4,162 @@ title: Cookies -## Cookies +# Cookies + +_Este tutorial é compatível com hapi v21 e versões mais recentes_ + +## Visão geral + +O hapi lê o header `Cookie` da requisição para `request.state` e escreve headers `Set-Cookie` a partir de `h.state()`. Cada cookie é declarado uma única vez com `server.state()`, que define sua codificação e suas flags de segurança, então os handlers só lidam com valores: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as servidor hapi + B->>S: GET /theme/dark + Note over S: h.state('prefs', { theme: 'dark' }) + S-->>B: 200, Set-Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + B->>S: GET / com Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + Note over S: request.state.prefs é { theme: 'dark' } + S-->>B: 200 "Theme: dark" + B->>S: GET /reset + Note over S: h.unstate('prefs') + S-->>B: 200, Set-Cookie prefs com Max-Age=0 +``` -_Este tutorial é compatível com hapi v16_ +O servidor completo por trás dessa troca: -Quando estamos desenvolvendo uma aplicação web, usar cookies é com bastante frequência uma necessidade. Ao utilizar o hapi, cookies são flexíveis, seguros e simples. +```ts +import Hapi from '@hapi/hapi'; -## Configurando o servidor +const server = Hapi.server({ port: 3000 }); -O hapi possui várias opções configuráveis ​​ao lidar com cookies. Os padrões são bons para a maioria dos casos, mas podem ser alterados quando necessário. +server.state('prefs', { + encoding: 'base64json', + ttl: 365 * 24 * 60 * 60 * 1000, + isSecure: process.env.NODE_ENV === 'production', + path: '/', +}); -Para configurá-los, invoque `server.state(nome, opções)` onde `nome` é o nome em string do cookie e `opções` é um objeto usado para configurar o cookie específico. +server.route<{ Params: { theme: string } }>({ + method: 'GET', + path: '/theme/{theme}', + handler: (request, h) => { + h.state('prefs', { theme: request.params.theme }); + return 'saved'; + }, +}); -```javascript -server.state('data', { - ttl: null, - isSecure: true, - isHttpOnly: true, - encoding: 'base64json', - clearInvalid: false, // remove cookies inválidos - strictHeader: true, // não permite violações da RFC 6265 +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + const prefs = request.state.prefs; + const theme = typeof prefs === 'object' && prefs !== null && 'theme' in prefs ? prefs.theme : 'light'; + + return `Theme: ${theme}`; + }, }); + +server.route({ + method: 'GET', + path: '/reset', + handler: (request, h) => { + return h.response('reset').unstate('prefs'); + }, +}); + +await server.start(); ``` -Esta configuração fará com que o cookie chamado `data` tenha o tempo de vida da sessão(será apagado quando o navegador for fechado), e seja sinalizado como seguro e apenas HTTP (veja [RFC 6265](http://tools.ietf.org/html/rfc6265), especificamente as seções [4.1.2.5](http://tools.ietf.org/html/rfc6265#section-4.1.2.5) e [4.1.2.6](http://tools.ietf.org/html/rfc6265#section-4.1.2.6) para mais informações), e informa ao hapi que o valor é um JSON String codificado em base64. A documentação completa para as opções do ` server.state ()` podem ser encontradas em [referência da API](api#serverstatename-options). +`request.state` é tipado como `Record`, porque um cookie contém o que quer que o cliente envie. O handler de `/` verifica o formato do valor antes de ler `theme` e, se não bater, usa um valor padrão. -Além disso, você pode passar dois parâmetros para o `options` ao adicionar uma rota: +## Configurando o servidor -```json5 -{ - options: { - state: { - parse: true, // analisa e armazena em request.state - failAction: 'error', // também pode ser 'ignore' ou 'log' - }, - }, +### server.state() + +[`server.state(name, [options])`](/api#server-state-name-options) declara um cookie. Os padrões são seguros, então a maioria das aplicações só define `encoding`, `ttl` e `path`: + +| Opção | Padrão | Significado | +| -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ttl` | `null` | Tempo de vida em milissegundos. `null` cria um cookie de sessão, apagado quando o navegador fecha. | +| `encoding` | `'none'` | Como o valor é guardado: `'none'` (uma string), `'base64'`, `'base64json'` (qualquer valor JSON), `'form'` ou `'iron'` (criptografado, veja [abaixo](#iron)). | +| `path` | `null` | O caminho de URL para o qual o navegador envia o cookie. `null` omite o atributo, e os navegadores usam então o diretório da requisição que o definiu. Use `'/'` para cookies válidos no site inteiro. | +| `isSecure` | `true` | Enviado só por HTTPS. Use `false` para HTTP sem TLS durante o desenvolvimento. | +| `isHttpOnly` | `true` | Oculto do JavaScript do navegador, o que o protege de cross-site scripting. | +| `isSameSite` | `'Strict'` | Não é enviado em requisições vindas de outros sites. `'Lax'` permite navegação de nível superior a partir de outros sites; `false` omite o atributo. | +| `clearInvalid` | `false` | Diz ao navegador para apagar o cookie quando o valor dele não puder ser processado. | +| `strictHeader` | `true` | Rejeita nomes e valores que violam a [RFC 6265](https://tools.ietf.org/html/rfc6265). | + +Veja [`server.state()`](/api#server-state-name-options) para o restante, como `domain`, `autoValue` e assinatura. Para mudar os padrões de todos os cookies, passe as mesmas opções na opção [`state`](/api#server-options-state) do servidor. + +### Cookies criptografados + +`encoding: 'iron'` criptografa e assina o valor com o [iron](/module/iron). O cliente não consegue lê-lo, e o hapi o rejeita se ele tiver sido alterado. Use para qualquer coisa que você não mostraria ao usuário, como um id de sessão: + +```ts +const password = process.env.COOKIE_PASSWORD; +if (!password || password.length < 32) { + throw new Error('COOKIE_PASSWORD must be set to at least 32 characters'); } + +server.state('session', { + encoding: 'iron', + password, + ttl: 24 * 60 * 60 * 1000, + path: '/', +}); ``` -## Definindo um cookie +Para sessões de login, o [@hapi/cookie](/module/cookie) se apoia nisso: ele gerencia um cookie de sessão criptografado como uma [strategy de autenticação](/tutorials/pt_BR/auth#cookie). -A definição de um cookie é feita através da [interface `reply()`](/api#reply-interface) em um manipulador de requisição, pré-requisito, ou ponto de extensão do ciclo de vida da requisição e se parece com o seguinte: +### route.options.state -```javascript -reply('Hello').state('data', { firstVisit: false }); -``` +As rotas controlam o parsing com [`options.state`](/api#route-options-state): + +| Opção | Padrão | Significado | +| ------------ | --------- | --------------------------------------------------------------------------------- | +| `parse` | `true` | Processa os cookies para `request.state`. Use `false` em rotas que nunca os leem. | +| `failAction` | `'error'` | O que acontece quando um cookie não pode ser processado ou descriptografado. | + +Um cookie falha quando seu valor não corresponde à sua codificação, ou quando um cookie iron foi adulterado ou criptografado com outra senha: + +| `failAction` | Resultado | +| ------------ | --------------------------------------------------------------- | +| `'error'` | a requisição falha com `400 Bad Request` | +| `'log'` | a requisição continua sem esse cookie, e o hapi registra o erro | +| `'ignore'` | a requisição continua sem esse cookie | -Neste exemplo, o hapi irá responder com a string `Hello`, bem como definir um cookie chamado` data` a uma string codificada em base64 cuja representação é o objetivo especificado. +Com `clearInvalid: true` no cookie, a resposta também apaga o cookie inválido do navegador. Combine com `'log'` ou `'ignore'` para que a requisição que traz um cookie antigo, por exemplo um criptografado antes de uma troca de senha, tenha sucesso em vez de falhar uma vez com 400. -### Sobrescrevendo opções +## Definindo um cookie -Ao definir um cookie, você também pode passar as mesmas opções disponíveis para `server.state()` como um terceiro parâmetro, tais como: +### h.state() -```javascript -reply('Hello').state('data', 'test', { encoding: 'none' }); +[`h.state(name, value, [options])`](/api#h-state-name-value-options) define um cookie a partir de um handler, de um [pre-handler](/api#route-options-pre) ou de um [lifecycle hook](/tutorials/pt_BR/lifecycle-hooks). O mesmo método existe no objeto de resposta, para encadeamento: + +```ts +h.state('prefs', { theme: 'dark' }); + +return h.response('Hello').state('prefs', { theme: 'dark' }); +``` + +## Sobrescrevendo opções + +O terceiro argumento sobrescreve as opções declaradas do cookie apenas para esta resposta: + +```ts +return h.response('Hello').state('prefs', 'dark', { encoding: 'none' }); ``` -## Eliminando um cookie +## Lendo o valor de um cookie + +`request.state` contém todos os cookies processados, indexados pelo nome. Se o navegador enviar dois cookies com o mesmo nome, o valor é um array. Leia os valores como o exemplo da visão geral faz: verifique o formato e depois use. + +## Apagando um cookie -O cookie pode ser eliminado invocando o método `unstate()` no objecto [`response`](/api#response-object): +[`h.unstate(name)`](/api#h-unstate-name-options), ou `unstate()` na resposta, diz ao navegador para apagar o cookie: -```javascript -reply('Hello').unstate('data'); +```ts +return h.response('Bye').unstate('prefs'); ``` diff --git a/docs/tutorials/tr_TR/cookies.md b/docs/tutorials/tr_TR/cookies.md index ae2f64e7..6542c02a 100644 --- a/docs/tutorials/tr_TR/cookies.md +++ b/docs/tutorials/tr_TR/cookies.md @@ -1,91 +1,165 @@ --- -title: Cookies +title: Cookie'ler --- -## Kurabiyeler +# Cookie'ler -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -Bir web uygulaması yazarken, kurabiyeler sıklıkla kullanıcının istekleri arasındaki durumunu tutmak için kullanılır. Hapi ile kurabiyeler esnek, güvenli ve kullanımı kolaydır. +## Genel Bakış -## Sunucuyu ayarlamak +hapi isteğin `Cookie` header'ını `request.state` içine okur ve `Set-Cookie` header'larını `h.state()` üzerinden yazar. Her cookie `server.state()` ile bir kez bildirilir; bu çağrı cookie'nin kodlamasını ve güvenlik bayraklarını ayarlar, böylece handler'lar yalnızca değerlerle uğraşır: -hapi kurabiyeler ile ilgilenirken düzenlenebilir bir çok özellik sunar. Varsayılanları çoğu zaman yeterli olur ancak lazım olursa özellikleri düzenlebilir. +```mermaid +sequenceDiagram + participant B as Tarayıcı + participant S as hapi sunucusu + B->>S: GET /theme/dark + Note over S: h.state('prefs', { theme: 'dark' }) + S-->>B: 200, Set-Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + B->>S: Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== ile GET / + Note over S: request.state.prefs değeri { theme: 'dark' } + S-->>B: 200 "Theme: dark" + B->>S: GET /reset + Note over S: h.unstate('prefs') + S-->>B: 200, Max-Age=0 ile Set-Cookie prefs +``` + +Bu alışverişin arkasındaki sunucunun tamamı: -Bir kurabiye kullanmak için önce [`server.state(name, [options])`](/api#-serverstatename-options) ifadesini `name` kurabiye adı ve `options` ayarları içeren bir nesne olacak şekilde çağırarak ayarlamak gerekir. +```ts +import Hapi from '@hapi/hapi'; -```javascript -server.state('data', { - ttl: null, - isSecure: true, - isHttpOnly: true, +const server = Hapi.server({ port: 3000 }); + +server.state('prefs', { encoding: 'base64json', - clearInvalid: false, // bozuk kurabiyeleri at - strictHeader: true, // RFC 6265 ihlallerine göz yumma + ttl: 365 * 24 * 60 * 60 * 1000, + isSecure: process.env.NODE_ENV === 'production', + path: '/', }); -``` -Bu ayarlar bir oturumluk ömrü olan (tarayıcı kapandığında silinmek üzere) `data` adında güvenli ve Httpye Özel (bu bayrakların ne demek olduğunu hakkında daha fazla bilgi için bkz: [RFC 6265](http://tools.ietf.org/html/rfc6265), specifically bölümleri [4.1.2.5](http://tools.ietf.org/html/rfc6265#section-4.1.2.5) ve [4.1.2.6](http://tools.ietf.org/html/rfc6265#section-4.1.2.6)) bir kurabiye oluşturarak hapiye değerin base64 ile kodlanan bir JSON metin (string) olduğunu söyler. `server.state()` ifadesi ve seçeneklerinin tam bir dokümantasyonu [API Referansı](/api#serverstatename-options) altında bulunabilir. +server.route<{ Params: { theme: string } }>({ + method: 'GET', + path: '/theme/{theme}', + handler: (request, h) => { + h.state('prefs', { theme: request.params.theme }); + return 'saved'; + }, +}); -Buna ek olarak, `options.state` nesnesinde bulunan iki özelliği kullanarak kurabiye davranışlarını yol (route) seviyesinde düzenleyebilirsin: +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + const prefs = request.state.prefs; + const theme = typeof prefs === 'object' && prefs !== null && 'theme' in prefs ? prefs.theme : 'light'; -```json5 -{ - options: { - state: { - parse: true, // kurabiyeleri ayristir ve request.state icerisinde tut - failAction: 'error', // 'ignore' ya da 'log' da olabilir - }, + return `Theme: ${theme}`; }, -} +}); + +server.route({ + method: 'GET', + path: '/reset', + handler: (request, h) => { + return h.response('reset').unstate('prefs'); + }, +}); + +await server.start(); ``` -## Kurabiyeleri Ayarlamak +`request.state` tipi `Record` olarak belirlenmiştir, çünkü bir cookie istemci ne gönderirse onu taşır. `/` handler'ı `theme` değerini okumadan önce değerin yapısını denetler ve gerekirse varsayılana döner. -Kurabiyeler [yanıt alet takımı (response toolkit)](/api#response-toolkit) kullanılarak istek işleyici (request handler), ön gereksinim (pre-requesite) ya da istek yaşam döngüsü uzatma noktaları (request lifecycle extension point) kullanarak ayarlanır ve şöyle görünür: +## Sunucuyu yapılandırma -```javascript -h.state('data', { firstVisit: false }); -return h.response('Hello'); -``` +### server.state() -Hapi, bu örnekte `Hello` metin (string) ile cevap verecek ve aynı zamanda `data` adlı bir kurabiyeyi değeri `{ firstVisit: false }` metinnın base64 ile kodlanmış bir metin temsili olacak şekilde ayarlayacak. +[`server.state(name, [options])`](/api#server-state-name-options) bir cookie bildirir. Varsayılanlar güvenlidir, bu yüzden çoğu uygulama yalnızca `encoding`, `ttl` ve `path` ayarlar: -`state()` yöntemi aynı zamanda rahatça zincir çağrılar yapılabilecek şekilde [yanıt nesnesi](/api#response-object)nde de bulunmaktadır. Dolayısıyla yukarıdaki örnek şöyle de yazılabilir: +| Seçenek | Varsayılan | Anlamı | +| -------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ttl` | `null` | Milisaniye cinsinden ömür. `null`, tarayıcı kapandığında silinen bir oturum cookie'si oluşturur. | +| `encoding` | `'none'` | Değerin nasıl saklandığı: `'none'` (bir string), `'base64'`, `'base64json'` (herhangi bir JSON değeri), `'form'` ya da `'iron'` (şifrelenmiş, bkz. [aşağısı](#iron)). | +| `path` | `null` | Tarayıcının cookie'yi gönderdiği URL yolu. `null` bu özniteliği eklemez; tarayıcılar da cookie'yi ayarlayan isteğin dizinini kullanır. Sitenin tamamında geçerli cookie'ler için `'/'` ayarla. | +| `isSecure` | `true` | Yalnızca HTTPS üzerinden gönderilir. Geliştirme sırasında düz HTTP için `false` ayarla. | +| `isHttpOnly` | `true` | Tarayıcıdaki JavaScript'ten gizlenir; bu da onu cross-site scripting'e karşı korur. | +| `isSameSite` | `'Strict'` | Başka sitelerden gelen isteklerde gönderilmez. `'Lax'`, başka sitelerden üst düzey gezinmeye izin verir; `false` özniteliği eklemez. | +| `clearInvalid` | `false` | Değeri ayrıştırılamadığında tarayıcıya cookie'yi silmesini söyler. | +| `strictHeader` | `true` | [RFC 6265](https://tools.ietf.org/html/rfc6265) kurallarını bozan adları ve değerleri reddeder. | -```javascript -return h.response('Hello').state('data', { firstVisit: false }); -``` +`domain`, `autoValue` ve imzalama gibi diğer seçenekler için [`server.state()`](/api#server-state-name-options) sayfasına bak. Her cookie için varsayılanları değiştirmek istiyorsan aynı seçenekleri sunucunun [`state`](/api#server-options-state) seçeneği olarak ver. -### Seceneklerin uzerine yazmak +### Şifrelenmiş cookie'ler -Bir kurabiye ayarlarkan, `server.state()` yöntemine bazı seçenekler de gönderebilirsin. Mesela: +`encoding: 'iron'`, değeri [iron](/module/iron) ile şifreler ve imzalar. İstemci onu okuyamaz ve değiştirilmişse hapi onu reddeder. Kullanıcıya göstermeyeceğin her şey için bunu kullan; örneğin bir oturum id'si: -```javascript -return h.response('Hello').state('data', 'test', { encoding: 'none' }); +```ts +const password = process.env.COOKIE_PASSWORD; +if (!password || password.length < 32) { + throw new Error('COOKIE_PASSWORD must be set to at least 32 characters'); +} + +server.state('session', { + encoding: 'iron', + password, + ttl: 24 * 60 * 60 * 1000, + path: '/', +}); ``` -Bu örnekte kurabiye hiç bir kodlama olmaksızın `"test"` metinna ayarlanıyor. +Giriş oturumları için [@hapi/cookie](/module/cookie) bunun üzerine kuruludur: şifrelenmiş bir oturum cookie'sini bir [kimlik doğrulama stratejisi](/tutorials/tr_TR/auth#cookie) olarak yönetir. + +### route.options.state + +Route'lar ayrıştırmayı [`options.state`](/api#route-options-state) ile denetler: + +| Seçenek | Varsayılan | Anlamı | +| ------------ | ---------- | ----------------------------------------------------------------------------------------- | +| `parse` | `true` | Cookie'leri `request.state` içine ayrıştırır. Cookie okumayan route'larda `false` ayarla. | +| `failAction` | `'error'` | Bir cookie ayrıştırılamadığında ya da şifresi çözülemediğinde ne olacağı. | + +Bir cookie, değeri kodlamasıyla eşleşmediğinde ya da bir iron cookie'si değiştirildiğinde veya başka bir parolayla şifrelendiğinde başarısız olur: + +| `failAction` | Sonuç | +| ------------ | ------------------------------------------------------- | +| `'error'` | istek `400 Bad Request` ile başarısız olur | +| `'log'` | istek o cookie olmadan devam eder ve hapi hatayı loglar | +| `'ignore'` | istek o cookie olmadan devam eder | -## Bir kurabiyenin degerini almak +Cookie'de `clearInvalid: true` varsa yanıt hatalı cookie'yi tarayıcıdan da siler. Bunu `'log'` ya da `'ignore'` ile birlikte kullan; böylece eskimiş bir cookie taşıyan istek (örneğin parola değişikliğinden önce şifrelenmiş bir cookie) bir kez 400 ile başarısız olmak yerine başarılı olur. -Yol işleyici (route handler), ön gereksinim (pre-requesite) ya da istek yaşam döngüsü uzatma noktalarında (request lifecycle extension point) `request.state` ile bir kurabiyenin değerine ulaşabilirsin. +## Cookie ayarlama -`request.state` nesnesi işlenmiş HTTP durumunu içerir. Her bir anahtar kurabiye adını temsil eder ver değeri kurabiyenin değeridir. +### h.state() -```javascript -const value = request.state.data; -// console.log(value) sana { firstVisit : false } sonucunu verir +[`h.state(name, value, [options])`](/api#h-state-name-value-options), bir handler'dan, bir [pre-handler'dan](/api#route-options-pre) ya da bir [yaşam döngüsü hook'undan](/tutorials/tr_TR/lifecycle-hooks) cookie ayarlar. Zincirleme kullanım için aynı metot response nesnesinde de bulunur: + +```ts +h.state('prefs', { theme: 'dark' }); + +return h.response('Hello').state('prefs', { theme: 'dark' }); ``` -Bu örnek kod, yukarı bulunan ve değeri `{ firstVisit: false }` olan `data` kurabiye anahtarını kullanıyor. +## Seçenekleri geçersiz kılma + +Üçüncü argüman, cookie'nin bildirilen seçeneklerini yalnızca bu yanıt için geçersiz kılar: + +```ts +return h.response('Hello').state('prefs', 'dark', { encoding: 'none' }); +``` + +## Cookie değerini okuma + +`request.state`, ayrıştırılan her cookie'yi adına göre anahtarlanmış olarak tutar. Tarayıcı aynı ada sahip iki cookie gönderirse değer bir dizi olur. Değerleri genel bakış örneğindeki gibi oku: önce yapıyı denetle, sonra kullan. -## Kurabiyeleri temizlemek +## Cookie silme -Kurabiyeler [yanıt nesnesi (response object)](/api#response-object) ya da [yanıt alet takımı (response toolkit)](/api#response-toolkit)nda bulunan `unstate()` yöntemi kullanılarak temizlenebilir: +[`h.unstate(name)`](/api#h-unstate-name-options) ya da response üzerindeki `unstate()`, tarayıcıya cookie'yi silmesini söyler: -```javascript -return h.response('Bye').unstate('data'); +```ts +return h.response('Bye').unstate('prefs'); ``` diff --git a/docs/tutorials/zh_CN/cookies.md b/docs/tutorials/zh_CN/cookies.md index 876c6fba..031ef2f7 100644 --- a/docs/tutorials/zh_CN/cookies.md +++ b/docs/tutorials/zh_CN/cookies.md @@ -1,91 +1,165 @@ --- -title: Cookies +title: Cookie --- -## Cookies +# Cookie -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更新版本_ -当创建一个 web 应用时, cookies 通常用于保存一个用户在每个请求之间的状态。在 hapi 中, cookies 可以被灵活、安全以及便捷的使用。 +## 概述 -## 配置服务器 +hapi 将请求的 `Cookie` 请求头读入 `request.state`,并根据 `h.state()` 写出 `Set-Cookie` 响应头。每个 cookie 只需通过 `server.state()` 声明一次,声明中设置它的编码和安全标志,因此 handler 只需处理值本身: -hapi 有许多配置选项用于处理 cookies。默认的配置已经可以适用于大多数场景了,当然也可以在需要时进行替换。 +```mermaid +sequenceDiagram + participant B as 浏览器 + participant S as hapi 服务器 + B->>S: GET /theme/dark + Note over S: h.state('prefs', { theme: 'dark' }) + S-->>B: 200,Set-Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + B->>S: GET /,携带 Cookie prefs=eyJ0aGVtZSI6ImRhcmsifQ== + Note over S: request.state.prefs 为 { theme: 'dark' } + S-->>B: 200 "Theme: dark" + B->>S: GET /reset + Note over S: h.unstate('prefs') + S-->>B: 200,Set-Cookie prefs,带 Max-Age=0 +``` + +这次交互背后的完整服务器代码: -使用 cookie, 首先你需要调用 [`server.state(name, [options])`](/api#-serverstatename-options)来配置。这里`name` 是 cookie 的名字, `options` 是用来配置 cookie 的对象。 +```ts +import Hapi from '@hapi/hapi'; -```javascript -server.state('data', { - ttl: null, - isSecure: true, - isHttpOnly: true, +const server = Hapi.server({ port: 3000 }); + +server.state('prefs', { encoding: 'base64json', - clearInvalid: false, // remove invalid cookies - strictHeader: true, // don't allow violations of RFC 6265 + ttl: 365 * 24 * 60 * 60 * 1000, + isSecure: process.env.NODE_ENV === 'production', + path: '/', }); -``` -这份配置将 cookie 命名为 `data` ,它拥有一个 session 生命周期 (当浏览器关闭时将被删除),它标记为安全以及只支持 HTTP (参见 [RFC 6265](http://tools.ietf.org/html/rfc6265),的章节 [4.1.2.5](http://tools.ietf.org/html/rfc6265#section-4.1.2.5) 以及 [4.1.2.6](http://tools.ietf.org/html/rfc6265#section-4.1.2.6) 以了解这个标记的更多信息), 之后告诉 hapi 内容是 base64 编码的JSON字符串。关于 `server.state()` 的完整文档可以在 [the API reference](/api#serverstatename-options) 中找到。 +server.route<{ Params: { theme: string } }>({ + method: 'GET', + path: '/theme/{theme}', + handler: (request, h) => { + h.state('prefs', { theme: request.params.theme }); + return 'saved'; + }, +}); -除此之外,你也可以通过路由级别的两个属性更以进一步的配置 cookie 的行为,这两个属性位于路由的 `options.state` 对象中: +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + const prefs = request.state.prefs; + const theme = typeof prefs === 'object' && prefs !== null && 'theme' in prefs ? prefs.theme : 'light'; -```json5 -{ - config: { - state: { - parse: true, // 解析 cookies 并储存在 request.state - failAction: 'error', // 也可以为 'ignore' 或者 'log' - }, + return `Theme: ${theme}`; }, -} +}); + +server.route({ + method: 'GET', + path: '/reset', + handler: (request, h) => { + return h.response('reset').unstate('prefs'); + }, +}); + +await server.start(); ``` -## 设置一个 cookie +`request.state` 的类型是 `Record`,因为 cookie 里装的是客户端发送的任意内容。`/` 的 handler 在读取 `theme` 之前先检查值的结构,否则回退到默认值。 -cookie 的设置可以通过 [response toolkit](/api#response-toolkit)。这个设置可以在请求的 handler, 预处理或者请求生命周期的扩展的位置中使用,示例如下: +## 配置服务器 -```javascript -h.state('data', { firstVisit: false }); -return h.response('Hello'); -``` +### server.state() -这个例子中, hapi 将会返回字符串 `Hello` 同时也设置一个 cookie 名为 `data` 内容为 `{ firstVisit: false }` 的 base64 编码的 JSON 字符串。 +[`server.state(name, [options])`](/api#server-state-name-options) 声明一个 cookie。默认值是安全的,所以大多数应用只需设置 `encoding`、`ttl` 和 `path`: -`state()` 方法也可以在 [response 对象](/api#response-object) 中使用,这样可以方便链式调用,使用的例子如下: +| 选项 | 默认值 | 含义 | +| -------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `ttl` | `null` | 有效期,单位为毫秒。`null` 表示会话 cookie,浏览器关闭时删除。 | +| `encoding` | `'none'` | 值的存储方式:`'none'`(字符串)、`'base64'`、`'base64json'`(任意 JSON 值)、`'form'` 或 `'iron'`(加密,见[下文](#iron))。 | +| `path` | `null` | 浏览器针对哪个 URL 路径发送该 cookie。`null` 表示不设置该属性,浏览器会使用设置它的那个请求所在的目录。全站 cookie 请设置为 `'/'`。 | +| `isSecure` | `true` | 只通过 HTTPS 发送。开发期间使用普通 HTTP 时设为 `false`。 | +| `isHttpOnly` | `true` | 对浏览器中的 JavaScript 不可见,防止跨站脚本攻击。 | +| `isSameSite` | `'Strict'` | 来自其他站点的请求不携带该 cookie。`'Lax'` 允许来自其他站点的顶层导航携带;`false` 表示不设置该属性。 | +| `clearInvalid` | `false` | 当 cookie 的值无法解析时,通知浏览器删除它。 | +| `strictHeader` | `true` | 拒绝不符合 [RFC 6265](https://tools.ietf.org/html/rfc6265) 的名称和值。 | -```javascript -return h.response('Hello').state('data', { firstVisit: false }); -``` +其余选项(如 `domain`、`autoValue` 和签名)请参见 [`server.state()`](/api#server-state-name-options)。如需修改所有 cookie 的默认值,可以把同样的选项传给服务器的 [`state`](/api#server-options-state) 选项。 -### 重载选项 +### 加密 cookie -当设置 cookie 的时候,您也可以将 `server.state()` 可用的选项作为第三个参数传递,例如 +`encoding: 'iron'` 使用 [iron](/module/iron) 对值进行加密和签名。客户端无法读取它,如果它被修改过,hapi 会拒绝它。凡是不想让用户看到的内容都可以这样存储,例如会话 id: -```javascript -return h.response('Hello').state('data', 'test', { encoding: 'none' }); +```ts +const password = process.env.COOKIE_PASSWORD; +if (!password || password.length < 32) { + throw new Error('COOKIE_PASSWORD must be set to at least 32 characters'); +} + +server.state('session', { + encoding: 'iron', + password, + ttl: 24 * 60 * 60 * 1000, + path: '/', +}); ``` -这个例子中 cookie 将会被简单的设置为字符串 `"test"` 而不使用任何编码。 +对于登录会话,[@hapi/cookie](/module/cookie) 在此基础上更进一步:它作为一种[身份认证 strategy](/tutorials/zh_CN/auth#cookie) 来管理加密的会话 cookie。 + +### route.options.state + +路由通过 [`options.state`](/api#route-options-state) 控制 cookie 的解析: + +| 选项 | 默认值 | 含义 | +| ------------ | --------- | ------------------------------------------------------------------------ | +| `parse` | `true` | 将 cookie 解析到 `request.state`。不读取 cookie 的路由可以设为 `false`。 | +| `failAction` | `'error'` | cookie 解析或解密失败时的处理方式。 | + +当 cookie 的值与其编码不符,或者 iron cookie 被篡改、或是用另一个密码加密的,cookie 就会失败: + +| `failAction` | 结果 | +| ------------ | ---------------------------------------------- | +| `'error'` | 请求以 `400 Bad Request` 失败 | +| `'log'` | 请求继续,但不带该 cookie,hapi 会记录这个错误 | +| `'ignore'` | 请求继续,但不带该 cookie | -## 获取一个 cookie 的值 +如果 cookie 设置了 `clearInvalid: true`,响应还会从浏览器中删除这个坏掉的 cookie。将它与 `'log'` 或 `'ignore'` 搭配使用,携带过时 cookie(例如修改密码之前加密的 cookie)的请求就能成功,而不会先失败一次返回 400。 -在路由 handler,预处理或请求生命周期扩展的位置中通过 `request.state` 可以访问 cookie 的值。 +## 设置 cookie -`request.state` 对象包含了解析后的 HTTP 状态。每一个键代表了 cookie 的名字,而值代表了定义的内容。 +### h.state() -```javascript -const value = request.state.data; -// console.log(value) 将会得到 { firstVisit : false } +[`h.state(name, value, [options])`](/api#h-state-name-value-options) 可以在 handler、[pre-handler](/api#route-options-pre) 或[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)中设置 cookie。响应对象上也有同样的方法,便于链式调用: + +```ts +h.state('prefs', { theme: 'dark' }); + +return h.response('Hello').state('prefs', { theme: 'dark' }); ``` -示例代码使用了 `data` cookie 键,而相关的值被设定为了 `{ firstVisit: false }`. +## 覆盖选项 + +第三个参数只针对本次响应覆盖 cookie 声明时的选项: + +```ts +return h.response('Hello').state('prefs', 'dark', { encoding: 'none' }); +``` + +## 读取 cookie 的值 + +`request.state` 保存所有已解析的 cookie,以名称为键。如果浏览器发送了两个同名 cookie,其值是一个数组。读取值的方式与概述中的示例相同:先检查结构,再使用。 -## 清理一个 cookie +## 清除 cookie -cookie 可以通过调用 `unstate()` 来清理,这个方法在[response toolkit](/api#response-toolkit) 或者[response object](/api#response-object) 中: +[`h.unstate(name)`](/api#h-unstate-name-options),或响应对象上的 `unstate()`,通知浏览器删除该 cookie: -```javascript -return h.response('Bye').unstate('data'); +```ts +return h.response('Bye').unstate('prefs'); ``` From 0d3ff51f17227958a646198785d50d32c2122d54 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:56 -0400 Subject: [PATCH 09/22] docs: rework logging tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/logging.md | 135 +++++++++++++++++++-------- docs/tutorials/es_ES/logging.md | 159 ++++++++++++++++++++++++++++++++ docs/tutorials/ko_KR/logging.md | 145 ++++++++++++++++++++++------- docs/tutorials/pt_BR/logging.md | 154 +++++++++++++++++++++++++++---- docs/tutorials/tr_TR/logging.md | 145 ++++++++++++++++++++++------- docs/tutorials/zh_CN/logging.md | 145 ++++++++++++++++++++++------- 6 files changed, 735 insertions(+), 148 deletions(-) create mode 100644 docs/tutorials/es_ES/logging.md diff --git a/docs/tutorials/en_US/logging.md b/docs/tutorials/en_US/logging.md index 38393d6e..6055ea84 100644 --- a/docs/tutorials/en_US/logging.md +++ b/docs/tutorials/en_US/logging.md @@ -6,71 +6,117 @@ title: Logging # Logging -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -As with any server software, logging is very important. hapi has some built in logging methods, as well as some basic capabilities for displaying these logs. +Apart from the [`debug`](#debug) default, which prints errors from your own code to the console, hapi does not write logs anywhere. It turns every log call into an event on `server.events`, and you decide what to do with those events: print them, ship them to a logging service, or collect them per request. + +```mermaid +flowchart TD + A["server.log()"] --> L["'log' event"] + B["request.log()"] --> R["'request' event"] + H["hapi's internal events"] --> L + H --> R + L --> D["debug option
prints matching tags"] + R --> D + L --> Y["your listeners
server.events.on()"] + R --> Y + R --> C["request.logs
when the route sets log.collect"] + + class L,R highlight +``` ## Built-in Methods -There are two nearly identical logging methods, `server.log(tags, [data, [timestamp]])`, and `request.log(tags, [data])`, which are to be called whenever you want to log an event in your application. - -### request.log() +There are two logging methods. Pick the one that matches where your code runs: -You want to call `request.log()` whenever you want to log something in the context of a request, such as a route handler, request lifecycle extension, or authentication scheme. The method takes two argument: +| Method | Use it when | Emits | +| ----------------------------------------------------------------- | -------------------------------------------------------------- | ----------- | +| [`request.log(tags, [data])`](/api#request-log-tags-data) | a request is in scope: a handler, an extension, an auth scheme | `'request'` | +| [`server.log(tags, [data])`](/api#server-log-tags-data-timestamp) | no request is in scope: startup, a plugin's `register()` | `'log'` | -`tags`: a string or an array of strings (e.g. `['error', 'database', 'read']`) used to identify the event. Tags are used instead of log levels and provide a much more expressive mechanism for describing and filtering events. +Both take the same two main arguments: -`data`: (optional) a message string or object with the application data being logged. If data is a function, the function signature is `function()` and it called once to generate (return value) the actual data emitted to the listeners. +- `tags`: a string or an array of strings, such as `['error', 'database', 'read']`. hapi uses tags instead of log levels, so you can filter on any combination: everything tagged `database`, or only `error` and `database` together. +- `data`: optional. A message, an object, or an `Error`. Pass a function instead, and hapi calls it only when a listener receives the event, so expensive data costs nothing when nobody is listening. -For example: +### request.log() -```js +```ts server.route({ method: 'GET', - path: '/', - handler: function (request, h) { - request.log('error', 'Event error'); - return 'Hello World'; + path: '/users/{id}', + handler: async (request, h) => { + request.log(['users', 'read'], { id: request.params.id }); + + return { id: request.params.id }; }, }); ``` -In this example, if there is a request-specific event with a tag of `error`, the event will get logged. You also send a `data` parameter of `Event error`. This can be anything you want, such as an error message or any other details. +Every event logged this way carries the request's id, so you can group the events of one request together. ### server.log() -`server.log()` is used when you have no specific request in scope, for instance, just after your server has started or inside a plugin's `register()` method. +```ts +import Hapi from '@hapi/hapi'; -`server.log()` takes three parameters, `(tags, data, timestamp)`. The `tags` and `data` parameters are exactly the same as in `request.logs()`. The `timestamp` parameter defaults to `Date.now()` and should only be passed in if you need to override the default for some reason. +const server = Hapi.server({ port: 3000 }); -```js -const Hapi = require('@hapi/hapi'); -const server = Hapi.server({ port: 80 }); +await server.start(); -server.log(['test', 'error'], 'Test event'); +server.log(['startup'], `Server running at ${server.info.uri}`); ``` -In this example, you'll see that server will log an event with `tags` `'test'` and `'error'`. Since there is no specific request in scope in this example, you use `server.log()`. - ## Retrieving and Displaying Logs -The hapi server object emits events for each log event. You can use the standard EventEmitter API to listen for such events and display them however you wish. +Listen on [`server.events`](/api#server-events). Besides your own calls, hapi emits its own events on the same channels: + +| Event | `event.channel` | Comes from | +| ------------ | --------------- | -------------------------------------------------------------- | +| `'log'` | `'app'` | your `server.log()` calls | +| `'log'` | `'internal'` | hapi: server load rejections, client connection errors | +| `'request'` | `'app'` | your `request.log()` calls | +| `'request'` | `'internal'` | hapi: authentication failures, handler errors, invalid headers | +| `'request'` | `'error'` | hapi: once per request that ended with a 500 | +| `'response'` | none | hapi: once per request, after the response is sent | -```js +The full list of internal tags is in the API reference under the [`'log'`](/api#log-event) and [`'request'`](/api#request-event) events. + +A typical setup prints application events, reports 500s, and writes one access-log line per request: + +```ts server.events.on('log', (event, tags) => { - if (tags.error) { - console.log(`Server error: ${event.error ? event.error.message : 'unknown'}`); - } + console.log(event.timestamp, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'app' }, (request, event, tags) => { + console.log(request.info.id, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'error' }, (request, event, tags) => { + console.error(`Request ${request.info.id} failed:`, event.error); +}); + +server.events.on('response', (request) => { + const duration = request.info.completed - request.info.received; + + console.log(`${request.method.toUpperCase()} ${request.path} ${request.raw.res.statusCode} ${duration}ms`); }); ``` -In this example, you call `server.events.on()` to listen for any `log` event, in this case anything logged with `server.log()`. Events logged with `server.log()` will emit a `log` event and events logged with `request.log()` will emit a `request` event. +- `tags` is an object with one `true` key per tag, so `if (tags.error)` is a quick check. +- `event.data` and `event.error` never appear together: an `Error` passed as `data` arrives as `event.error`. +- The criteria object `{ name: 'request', channels: 'error' }` subscribes to one channel. Without it you get all three. -You can retrieve all logs for a particular request at once via `request.logs`. This will be an array containing all the logged request events. You must first set the `log.collect` option to `true` on the route, otherwise this array will be empty. +hapi emits `'response'` after the response is sent, and does not wait on promises a listener returns. For async work that hapi should await in order, use an `onPostResponse` extension; see [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks). -```js +### Collecting the logs of one request + +To read a request's events from inside the request, set `log.collect` on the route. Every event logged for that request then lands in `request.logs`: + +```ts server.route({ method: 'GET', path: '/', @@ -80,21 +126,34 @@ server.route({ }, }, handler: function (request, h) { - return 'hello'; + request.log(['step'], 'loaded user'); + request.log(['step'], 'loaded orders'); + + return { steps: request.logs.length }; }, }); ``` +Without `log.collect`, `request.logs` stays empty. + ## Debug Mode (development only) -hapi has a debug mode, which is a pain-free way to have your log events printed to the console, without configuring additional plugins or writing logging code yourself. +The [`debug`](/api#server-options-debug) server option prints matching events to the console with `console.error()`, with no listener code. It changes only what is printed, not what is emitted. + +| Option | Default | Prints | +| --------------- | -------------------- | ----------------------------------------------------------------------------------------------- | +| `debug.request` | `['implementation']` | `'request'` events with any of these tags. The default prints 500s caused by bugs in your code. | +| `debug.log` | none | `'log'` events with any of these tags. | +| `debug: false` | | nothing | -By default, the only errors debug mode will print to console are uncaught errors in user code, and runtime errors from incorrect implementation of hapi's API. You can configure your server to print request events based on tag, however. For example, if you wanted to print any error in a request you would configure your server as follows: +Each list takes tags, `'*'` for every event, or `false` for none. To print every request error while developing: -`const server = Hapi.server({ debug: { request: ['error'] } });` +```ts +import Hapi from '@hapi/hapi'; -You can find more information on debug mode in the [API documentation](/api#server.options.debug). +const server = Hapi.server({ debug: { request: ['error'] } }); +``` -## Logging Plugins +## Logging Plugins -The built-in methods provided by hapi for retrieving and printing logs are fairly minimal. For a more feature-rich logging experience, you can look into using a plugin like [hapi-pino](https://www.npmjs.com/package/hapi-pino), [New Relic](https://newrelic.com/instant-observability/hapi/2124da5b-0174-457a-be5a-e0068673b2b5) or any of the other [hapi logging plugins](/plugins#logging). +For production logging, use a plugin that listens to these events for you, such as [hapi-pino](https://www.npmjs.com/package/hapi-pino) for structured JSON logs, [New Relic](https://newrelic.com/instant-observability/hapi/2124da5b-0174-457a-be5a-e0068673b2b5), or any of the other [hapi logging plugins](/plugins#logging). diff --git a/docs/tutorials/es_ES/logging.md b/docs/tutorials/es_ES/logging.md new file mode 100644 index 00000000..acd9fdfd --- /dev/null +++ b/docs/tutorials/es_ES/logging.md @@ -0,0 +1,159 @@ +--- +title: Logging +--- + + + +# Logging + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Aparte del valor por defecto de [`debug`](#debug), que imprime en consola los errores de tu propio código, hapi no escribe logs en ningún sitio. Convierte cada llamada de log en un evento de `server.events`, y tú decides qué hacer con esos eventos: imprimirlos, enviarlos a un servicio de logging o recogerlos por petición. + +```mermaid +flowchart TD + A["server.log()"] --> L["evento 'log'"] + B["request.log()"] --> R["evento 'request'"] + H["eventos internos de hapi"] --> L + H --> R + L --> D["opción debug
imprime las etiquetas que coinciden"] + R --> D + L --> Y["tus listeners
server.events.on()"] + R --> Y + R --> C["request.logs
cuando la ruta fija log.collect"] + + class L,R highlight +``` + +## Métodos integrados + +Hay dos métodos de logging. Elige el que corresponda a dónde se ejecuta tu código: + +| Método | Úsalo cuando | Emite | +| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------- | +| [`request.log(tags, [data])`](/api#request-log-tags-data) | hay una petición en contexto: un handler, una extensión, un scheme de autenticación | `'request'` | +| [`server.log(tags, [data])`](/api#server-log-tags-data-timestamp) | no hay ninguna petición en contexto: el arranque, el `register()` de un plugin | `'log'` | + +Ambos reciben los mismos dos argumentos principales: + +- `tags`: una cadena o un array de cadenas, como `['error', 'database', 'read']`. hapi usa etiquetas en lugar de niveles de log, así que puedes filtrar por cualquier combinación: todo lo etiquetado con `database`, o solo `error` y `database` juntas. +- `data`: opcional. Un mensaje, un objeto o un `Error`. Si en su lugar pasas una función, hapi solo la llama cuando un listener recibe el evento, así que los datos costosos de calcular no cuestan nada cuando nadie escucha. + +### request.log() + +```ts +server.route({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + request.log(['users', 'read'], { id: request.params.id }); + + return { id: request.params.id }; + }, +}); +``` + +Cada evento registrado así lleva el id de la petición, de modo que puedes agrupar los eventos de una misma petición. + +### server.log() + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 3000 }); + +await server.start(); + +server.log(['startup'], `Server running at ${server.info.uri}`); +``` + +## Obtener y mostrar los logs + +Escucha en [`server.events`](/api#server-events). Además de tus propias llamadas, hapi emite sus propios eventos en los mismos canales: + +| Evento | `event.channel` | Procede de | +| ------------ | --------------- | ------------------------------------------------------------------------ | +| `'log'` | `'app'` | tus llamadas a `server.log()` | +| `'log'` | `'internal'` | hapi: rechazos por carga del servidor, errores de conexión del cliente | +| `'request'` | `'app'` | tus llamadas a `request.log()` | +| `'request'` | `'internal'` | hapi: fallos de autenticación, errores del handler, cabeceras no válidas | +| `'request'` | `'error'` | hapi: una vez por cada petición que terminó con un 500 | +| `'response'` | ninguno | hapi: una vez por petición, después de enviar la respuesta | + +La lista completa de etiquetas internas está en la referencia de la API, en los eventos [`'log'`](/api#log-event) y [`'request'`](/api#request-event). + +Una configuración típica imprime los eventos de la aplicación, informa de los 500 y escribe una línea de log de acceso por petición: + +```ts +server.events.on('log', (event, tags) => { + console.log(event.timestamp, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'app' }, (request, event, tags) => { + console.log(request.info.id, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'error' }, (request, event, tags) => { + console.error(`Request ${request.info.id} failed:`, event.error); +}); + +server.events.on('response', (request) => { + const duration = request.info.completed - request.info.received; + + console.log(`${request.method.toUpperCase()} ${request.path} ${request.raw.res.statusCode} ${duration}ms`); +}); +``` + +- `tags` es un objeto con una clave `true` por etiqueta, así que `if (tags.error)` es una comprobación rápida. +- `event.data` y `event.error` nunca aparecen juntos: un `Error` pasado como `data` llega como `event.error`. +- El objeto de criterios `{ name: 'request', channels: 'error' }` se suscribe a un solo canal. Sin él recibes los tres. + +hapi emite `'response'` después de enviar la respuesta y no espera a las promesas que devuelva un listener. Para trabajo asíncrono que hapi deba esperar en orden, usa una extensión `onPostResponse`; consulta [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks). + +### Recoger los logs de una petición + +Para leer los eventos de una petición desde dentro de ella, fija `log.collect` en la ruta. Todos los eventos registrados para esa petición acaban entonces en `request.logs`: + +```ts +server.route({ + method: 'GET', + path: '/', + options: { + log: { + collect: true, + }, + }, + handler: function (request, h) { + request.log(['step'], 'loaded user'); + request.log(['step'], 'loaded orders'); + + return { steps: request.logs.length }; + }, +}); +``` + +Sin `log.collect`, `request.logs` se queda vacío. + +## Modo debug (solo para desarrollo) + +La opción de servidor [`debug`](/api#server-options-debug) imprime en consola los eventos que coinciden, con `console.error()`, sin código de listener. Solo cambia lo que se imprime, no lo que se emite. + +| Opción | Por defecto | Imprime | +| --------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `debug.request` | `['implementation']` | eventos `'request'` con cualquiera de estas etiquetas. El valor por defecto imprime los 500 causados por bugs en tu código. | +| `debug.log` | ninguno | eventos `'log'` con cualquiera de estas etiquetas. | +| `debug: false` | | nada | + +Cada lista admite etiquetas, `'*'` para todos los eventos o `false` para ninguno. Para imprimir todos los errores de petición mientras desarrollas: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ debug: { request: ['error'] } }); +``` + +## Plugins de logging + +Para el logging en producción, usa un plugin que escuche estos eventos por ti, como [hapi-pino](https://www.npmjs.com/package/hapi-pino) para logs JSON estructurados, [New Relic](https://newrelic.com/instant-observability/hapi/2124da5b-0174-457a-be5a-e0068673b2b5) o cualquiera de los demás [plugins de logging para hapi](/plugins#logging). diff --git a/docs/tutorials/ko_KR/logging.md b/docs/tutorials/ko_KR/logging.md index 1f75082e..847f55d6 100644 --- a/docs/tutorials/ko_KR/logging.md +++ b/docs/tutorials/ko_KR/logging.md @@ -1,50 +1,122 @@ --- -title: Logging +title: 로깅 --- -## Logging +# 로깅 -_이 튜터리얼은 hapi v17에 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -다른 서버 소프트웨어와 마찬가지로 로깅은 매우 중요합니다. hapi는 이러한 로그를 볼수 있는 기본 기능뿐만 아니라 내장 로깅 메소드를 가지고 있습니다. +## 개요 -### 내장 메소드 +여러분 코드에서 발생한 에러를 콘솔에 출력하는 [`debug`](#debug) 기본 설정을 제외하면, hapi는 로그를 어디에도 기록하지 않습니다. 대신 모든 로그 호출을 `server.events`의 이벤트로 바꾸고, 그 이벤트로 무엇을 할지는 여러분이 정합니다. 출력하거나, 로깅 서비스로 보내거나, 요청별로 모을 수 있습니다. -거의 동일한 두 가지 로깅 방법인 [`server.log(tags, [data, [timestamp]])`](/api#-serverlogtags-data-timestamp)와 [`request.log(tags, [data])`](https://hapijs.com/api#-requestlogtags-data)가 있습니다. 이 둘은 응용프로그램에서 이벤트를 로깅할 때 호출될 수 있습니다. 경로 처리기(route handler), 요청 생애주기 확장(request lifecycle extension)또는 인증 스킴(authentication scheme)같은 request의 문맥 안에서 `request.log()`를 호출할 것입니다. 예를들어 서버가 시작한 직후 또는 플러그인의 `register()` 메소드 안에서 처럼 특별한 요청이 없는 그 밖에 다른 곳에서는 `server.log()`를 사용할 것입니다. +```mermaid +flowchart TD + A["server.log()"] --> L["'log' 이벤트"] + B["request.log()"] --> R["'request' 이벤트"] + H["hapi 내부 이벤트"] --> L + H --> R + L --> D["debug 옵션
일치하는 태그를 출력"] + R --> D + L --> Y["여러분의 리스너
server.events.on()"] + R --> Y + R --> C["request.logs
라우트가 log.collect를 설정했을 때"] -둘 모두 처음 2개의 동일한 인자를 받습니다. 순서대로 `tags`, `data`입니다. + class L,R highlight +``` + +## 내장 메서드 + +로깅 메서드는 두 가지입니다. 코드가 실행되는 위치에 맞는 것을 고르세요. + +| 메서드 | 사용할 때 | 발생 이벤트 | +| ----------------------------------------------------------------- | -------------------------------------------------- | ----------- | +| [`request.log(tags, [data])`](/api#request-log-tags-data) | 요청이 있는 곳: 핸들러, 확장, 인증 스킴 | `'request'` | +| [`server.log(tags, [data])`](/api#server-log-tags-data-timestamp) | 요청이 없는 곳: 서버 시작, 플러그인의 `register()` | `'log'` | + +두 메서드는 같은 두 가지 주요 인자를 받습니다. + +- `tags`: `['error', 'database', 'read']` 같은 문자열 또는 문자열 배열입니다. hapi는 로그 레벨 대신 태그를 사용하므로 어떤 조합으로든 필터링할 수 있습니다. `database` 태그가 붙은 모든 것, 또는 `error`와 `database`가 함께 붙은 것만 고를 수 있습니다. +- `data`: 선택 사항입니다. 메시지, 객체, 또는 `Error`입니다. 대신 함수를 전달하면 hapi는 리스너가 이벤트를 받을 때만 그 함수를 호출하므로, 아무도 듣고 있지 않을 때 비용이 큰 데이터는 아무 비용도 들지 않습니다. + +### request.log() + +```ts +server.route({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + request.log(['users', 'read'], { id: request.params.id }); + + return { id: request.params.id }; + }, +}); +``` + +이렇게 기록한 이벤트에는 모두 요청 id가 붙어 있으므로, 한 요청의 이벤트를 묶어서 볼 수 있습니다. + +### server.log() -`tags`는 이벤트를 바로 식별하는 문자열 또는 문자열의 배열입니다. 로그 레벨 같지만 보다 표현적이라고 생각하세요. 예를 들어 다음과 같이 데이터베이스로부터 데이터를 받는 동안 에러를 태그할 수 있습니다.: +```ts +import Hapi from '@hapi/hapi'; -```javascript -server.log(['error', 'database', 'read']); +const server = Hapi.server({ port: 3000 }); + +await server.start(); + +server.log(['startup'], `Server running at ${server.info.uri}`); ``` -hapi가 내부에서 생성하는 로그 이벤트는 항상 그 이벤트와 연관된 `hapi` 태그를 가지고 있습니다. +## 로그 받기와 출력하기 -두 번째 인자 `data`는 이벤트와 함께 기록할 선택적인 문자열 또는 객체입니다. 이 인자는 에러 메시지 같은 것 또는 태그를 붙이고 싶은 다른 구체적인 것이 저장되는 곳입니다. +[`server.events`](/api#server-events)를 구독하세요. 여러분의 호출 외에도 hapi는 같은 채널에 자체 이벤트를 발생시킵니다. -추가적으로 `server.log()`는 세번째 `timestamp`인자를 받습니다. 기본 값은 `Date.now()`이고 어떤 이유로 기본 값을 덮어쓸 필요가 있을 경우만 전달합니다. +| 이벤트 | `event.channel` | 출처 | +| ------------ | --------------- | ------------------------------------------------- | +| `'log'` | `'app'` | 여러분의 `server.log()` 호출 | +| `'log'` | `'internal'` | hapi: 서버 부하로 인한 거부, 클라이언트 연결 에러 | +| `'request'` | `'app'` | 여러분의 `request.log()` 호출 | +| `'request'` | `'internal'` | hapi: 인증 실패, 핸들러 에러, 잘못된 헤더 | +| `'request'` | `'error'` | hapi: 500으로 끝난 요청마다 한 번 | +| `'response'` | 없음 | hapi: 응답을 보낸 뒤 요청마다 한 번 | -### 로그 가져오기와 표시하기 +내부 태그의 전체 목록은 API 레퍼런스의 [`'log'`](/api#log-event)와 [`'request'`](/api#request-event) 이벤트에 있습니다. -hapi 서버 객체는 각 로그 이벤트마다 이벤트를 생성합니다. 표준 EventEmitter API를 사용하여 그 이벤트를 수신하고 원하는 대로 표시할 수 있습니다. +일반적인 설정은 애플리케이션 이벤트를 출력하고, 500 에러를 보고하고, 요청마다 접근 로그 한 줄을 기록합니다. -```javascript +```ts server.events.on('log', (event, tags) => { - if (tags.error) { - console.log(`Server error: ${event.error ? event.error.message : 'unknown'}`); - } + console.log(event.timestamp, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'app' }, (request, event, tags) => { + console.log(request.info.id, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'error' }, (request, event, tags) => { + console.error(`Request ${request.info.id} failed:`, event.error); +}); + +server.events.on('response', (request) => { + const duration = request.info.completed - request.info.received; + + console.log(`${request.method.toUpperCase()} ${request.path} ${request.raw.res.statusCode} ${duration}ms`); }); ``` -`server.log()`로 로깅된 이벤트는 `log` 이벤트로 생성되고 `request.log()`로 로깅된 이벤트는 `request` 이벤트로 샌성됩니다. +- `tags`는 태그마다 `true` 키를 하나씩 가진 객체이므로, `if (tags.error)`로 간단히 확인할 수 있습니다. +- `event.data`와 `event.error`는 함께 나타나지 않습니다. `data`로 전달한 `Error`는 `event.error`로 도착합니다. +- 조건 객체 `{ name: 'request', channels: 'error' }`는 채널 하나만 구독합니다. 이 객체가 없으면 세 채널을 모두 받습니다. + +hapi는 응답을 보낸 뒤에 `'response'`를 발생시키며, 리스너가 반환한 promise를 기다리지 않습니다. hapi가 순서대로 기다려야 하는 비동기 작업에는 `onPostResponse` 확장을 사용하세요. [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks)을 참고하세요. -`request.logs`를 통해 특정 요청에 대한 모든 로그들을 가져올 수 있습니다. 이는 로깅된 모든 이벤트들을 포함하고 있는 배열일 것입니다. 먼저 경로 설정에서 `logs.collect` 옵션을 `true`로 설정해야 합니다. 그렇지 않으면 배열은 비어있을 것입니다. +### 한 요청의 로그 모으기 -```javascript +요청 안에서 그 요청의 이벤트를 읽으려면 라우트에 `log.collect`를 설정하세요. 그러면 그 요청에 대해 기록된 모든 이벤트가 `request.logs`에 쌓입니다. + +```ts server.route({ method: 'GET', path: '/', @@ -54,23 +126,34 @@ server.route({ }, }, handler: function (request, h) { - return 'hello'; + request.log(['step'], 'loaded user'); + request.log(['step'], 'loaded orders'); + + return { steps: request.logs.length }; }, }); ``` -### 디버그 모드(개발에서만) +`log.collect`가 없으면 `request.logs`는 비어 있습니다. -추가적인 플러그인 설정하거나 로깅 코드를 작성없이 로그 이벤트를 콘솔에 출력하는 고통없는 방법인 hapi는 디버그 모드를 가지고 있습니다. +## 디버그 모드 (개발 전용) -기본적으로 디버그 모드에서 코드에서 잡히지 않은 에러와 hapi API의 잘못 구현으로 인한 실행시간 에러만 콘솔에 출력될 것입니다. 그러나 태그에 기반한 요청 이벤트가 출력되도록 설정할 수 있습니다. 예를들면 요청에서 에러를 출력하려고 한다면 다음처럼 서버를 설정하세요.: +[`debug`](/api#server-options-debug) 서버 옵션은 리스너 코드 없이 일치하는 이벤트를 `console.error()`로 콘솔에 출력합니다. 출력되는 것만 바뀔 뿐, 발생하는 이벤트는 바뀌지 않습니다. + +| 옵션 | 기본값 | 출력 | +| --------------- | -------------------- | ----------------------------------------------------------------------------------------- | +| `debug.request` | `['implementation']` | 이 태그 중 하나라도 붙은 `'request'` 이벤트. 기본값은 코드 버그로 생긴 500 에러를 출력함. | +| `debug.log` | 없음 | 이 태그 중 하나라도 붙은 `'log'` 이벤트. | +| `debug: false` | | 출력하지 않음 | + +각 목록에는 태그, 모든 이벤트를 뜻하는 `'*'`, 또는 아무것도 출력하지 않는 `false`를 지정합니다. 개발 중에 모든 요청 에러를 출력하려면 다음과 같이 합니다. + +```ts +import Hapi from '@hapi/hapi'; -```javascript const server = Hapi.server({ debug: { request: ['error'] } }); ``` -디버그 모드에 대한 더 많은 정보는 [API 문서](https://hapijs.com/api#-serveroptionsdebug)에서 찾을 수 있습니다. - -## 로깅 플러그인 +## 로깅 플러그인 -hapi에서 로그 검색 및 출력을 위햇 제공하는 내장 메소드는 매우 적습니다. 보다 풍부한 로깅 경험을 위해서 [hapi-pino](https://www.npmjs.com/package/hapi-pino) 같은 플러그인 사용법을 실제로 봐야 합니다. +프로덕션 로깅에는 이 이벤트들을 대신 구독해 주는 플러그인을 사용하세요. 구조화된 JSON 로그를 위한 [hapi-pino](https://www.npmjs.com/package/hapi-pino), [New Relic](https://newrelic.com/instant-observability/hapi/2124da5b-0174-457a-be5a-e0068673b2b5), 또는 그 밖의 [hapi 로깅 플러그인](/plugins#logging)이 있습니다. diff --git a/docs/tutorials/pt_BR/logging.md b/docs/tutorials/pt_BR/logging.md index 43a19d9a..c68c5c11 100644 --- a/docs/tutorials/pt_BR/logging.md +++ b/docs/tutorials/pt_BR/logging.md @@ -4,36 +4,156 @@ title: Logging -## Métodos nativos +# Logging -Como todo programa de servidor, o log é muito importante. O Hapi possui alguns métodos nativos de log, bem como uma capacidade limitada para visualização destes logs. +_Este tutorial é compatível com hapi v21 e versões mais recentes_ -Existem dois métodos bem parecidos de log, `server.log`, e `request.log`. A diferença entre os dois está em qual evento eles emitem, qual objeto emite o evento, e qual dado é automaticamente associado. O método `server.log` emite um evento de `log` no server, e possui a URI do server associada a ele. O `request.log` emite um evento de `request` no `server` e tem o id interno da requisição (`request`) associado a ele. +## Visão geral -Os dois aceitam até três parâmetros. Sendo eles `tags`, `data` e `timestamp`, respectivamente. +Tirando o padrão de [`debug`](#debug), que imprime no console os erros do seu próprio código, o hapi não escreve logs em lugar nenhum. Ele transforma cada chamada de log em um evento em `server.events`, e você decide o que fazer com esses eventos: imprimi-los, enviá-los para um serviço de logging ou coletá-los por requisição. -`tags` é uma string ou um array de strings usado para identificar brevemente o evento. Pense nelas como níveis de log, mas muito mais expressivas. Por exemplo, você pode usar uma tag para um erro ao tentar recuperar dados da sua base de dados da seguinte maneira: +```mermaid +flowchart TD + A["server.log()"] --> L["evento 'log'"] + B["request.log()"] --> R["evento 'request'"] + H["eventos internos do hapi"] --> L + H --> R + L --> D["opção debug
imprime as tags que batem"] + R --> D + L --> Y["seus listeners
server.events.on()"] + R --> Y + R --> C["request.logs
quando a rota define log.collect"] -```javascript -server.log(['error', 'database', 'read']); + class L,R highlight ``` -Qualquer evento de log que o hapi gera internamente sempre terá a tag `hapi` associada a ele. É através deste parâmetro que você deve passar coisas como uma mensagem de erro, ou algum outro detalhe que você deseja que vá com a suas tags. O evento de log terá automaticamente a URI do server o qual ele é associado como uma propriedade. +## Métodos nativos -Por último o parâmetro timestamp. O valor padrão (_default_) dele é `Date.now()`, e somente deve ser passado se você precisa sobrescrever o valor padrão por algum motivo. +Existem dois métodos de logging. Escolha o que corresponde ao lugar onde seu código roda: -### Recuperando logs de _request_ +| Método | Use quando | Emite | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------- | +| [`request.log(tags, [data])`](/api#request-log-tags-data) | há uma requisição no contexto: um handler, uma extensão, um scheme de autenticação | `'request'` | +| [`server.log(tags, [data])`](/api#server-log-tags-data-timestamp) | não há requisição no contexto: a inicialização, o `register()` de um plugin | `'log'` | -O Hapi também possui um método (`request.getLog`) para recuperar eventos de log de um request, assumindo que você ainda tem acesso ao objeto de request. Se chamado sem parâmetros ele ira retornar um array com todos os eventos de log associados ao request. Você também pode passar uma tag ou um array de tags para filtrar o retorno. Isto pode ser útil para recuperar o histórico de todos os eventos de log em um request quando ocorrer um erro para análise. +Os dois recebem os mesmos dois argumentos principais: -### Configuração +- `tags`: uma string ou um array de strings, como `['error', 'database', 'read']`. O hapi usa tags em vez de níveis de log, então você pode filtrar por qualquer combinação: tudo com a tag `database`, ou só `error` e `database` juntas. +- `data`: opcional. Uma mensagem, um objeto ou um `Error`. Se você passar uma função, o hapi só a chama quando um listener recebe o evento, então dados caros não custam nada quando ninguém está escutando. -Por padrão, os únicos erros que o hapi irá apresentar no console são os erros não tratados em códigos externos, e erros de execução devida a uso incorreto da API do hapi. Porém, você pode configurar o server para apresentar os eventos de request baseados em tag. Por exemplo, se você deseja apresentar qualquer erro em uma requisição você deve configurar seu server da seguinte maneira: +### request.log() -```javascript -const server = new Hapi.Server({ debug: { request: ['error'] } }); +```ts +server.route({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + request.log(['users', 'read'], { id: request.params.id }); + + return { id: request.params.id }; + }, +}); +``` + +Todo evento registrado dessa forma carrega o id da requisição, então você pode agrupar os eventos de uma mesma requisição. + +### server.log() + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 3000 }); + +await server.start(); + +server.log(['startup'], `Server running at ${server.info.uri}`); +``` + +## Lendo e exibindo logs + +Escute em [`server.events`](/api#server-events). Além das suas próprias chamadas, o hapi emite seus próprios eventos nos mesmos canais: + +| Evento | `event.channel` | Vem de | +| ------------ | --------------- | ------------------------------------------------------------------ | +| `'log'` | `'app'` | suas chamadas a `server.log()` | +| `'log'` | `'internal'` | hapi: rejeições por carga do servidor, erros de conexão do cliente | +| `'request'` | `'app'` | suas chamadas a `request.log()` | +| `'request'` | `'internal'` | hapi: falhas de autenticação, erros de handler, headers inválidos | +| `'request'` | `'error'` | hapi: uma vez por requisição que terminou com 500 | +| `'response'` | nenhum | hapi: uma vez por requisição, depois que a resposta é enviada | + +A lista completa de tags internas está na referência da API, nos eventos [`'log'`](/api#log-event) e [`'request'`](/api#request-event). + +Uma configuração típica imprime os eventos da aplicação, reporta os 500s e escreve uma linha de log de acesso por requisição: + +```ts +server.events.on('log', (event, tags) => { + console.log(event.timestamp, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'app' }, (request, event, tags) => { + console.log(request.info.id, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'error' }, (request, event, tags) => { + console.error(`Request ${request.info.id} failed:`, event.error); +}); + +server.events.on('response', (request) => { + const duration = request.info.completed - request.info.received; + + console.log(`${request.method.toUpperCase()} ${request.path} ${request.raw.res.statusCode} ${duration}ms`); +}); +``` + +- `tags` é um objeto com uma chave `true` por tag, então `if (tags.error)` é uma checagem rápida. +- `event.data` e `event.error` nunca aparecem juntos: um `Error` passado como `data` chega como `event.error`. +- O objeto de critérios `{ name: 'request', channels: 'error' }` assina um único canal. Sem ele, você recebe os três. + +O hapi emite `'response'` depois que a resposta é enviada e não espera pelas promises que um listener retorna. Para trabalho assíncrono que o hapi deve aguardar em ordem, use uma extensão `onPostResponse`; veja [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks). + +### Coletando os logs de uma requisição + +Para ler os eventos de uma requisição de dentro dela, defina `log.collect` na rota. Todo evento registrado para aquela requisição vai então para `request.logs`: + +```ts +server.route({ + method: 'GET', + path: '/', + options: { + log: { + collect: true, + }, + }, + handler: function (request, h) { + request.log(['step'], 'loaded user'); + request.log(['step'], 'loaded orders'); + + return { steps: request.logs.length }; + }, +}); +``` + +Sem `log.collect`, `request.logs` fica vazio. + +## Modo debug (só em desenvolvimento) + +A opção de servidor [`debug`](/api#server-options-debug) imprime os eventos correspondentes no console com `console.error()`, sem código de listener. Ela só muda o que é impresso, não o que é emitido. + +| Opção | Padrão | Imprime | +| --------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | +| `debug.request` | `['implementation']` | eventos `'request'` com qualquer uma dessas tags. O padrão imprime os 500s causados por bugs no seu código. | +| `debug.log` | nenhum | eventos `'log'` com qualquer uma dessas tags. | +| `debug: false` | | nada | + +Cada lista recebe tags, `'*'` para todos os eventos ou `false` para nenhum. Para imprimir todo erro de requisição durante o desenvolvimento: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ debug: { request: ['error'] } }); ``` -## Plugins de log +## Plugins de logging -Os métodos nativos são mínimos, no entando, para um log mais completo você realmente deveria pensar em usar um plugin como [hapi-pino](https://www.npmjs.com/package/hapi-pino). +Para logging em produção, use um plugin que escute esses eventos por você, como o [hapi-pino](https://www.npmjs.com/package/hapi-pino) para logs estruturados em JSON, o [New Relic](https://newrelic.com/instant-observability/hapi/2124da5b-0174-457a-be5a-e0068673b2b5) ou qualquer outro dos [plugins de logging do hapi](/plugins#logging). diff --git a/docs/tutorials/tr_TR/logging.md b/docs/tutorials/tr_TR/logging.md index 21fbb4d4..df87d663 100644 --- a/docs/tutorials/tr_TR/logging.md +++ b/docs/tutorials/tr_TR/logging.md @@ -1,50 +1,122 @@ --- -title: Logging +title: Loglama --- -## Gunlukleme +# Loglama -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -Her sunucu yazılımında olduğu gibi, günlükleme çok önemlidir. hapinin hazır gelen bazı günlükleme ve bu günlükleri görüntüleme yöntemleri bulunur. +## Genel Bakış -### Hazir gelen yontemler +Kendi kodundaki hataları konsola yazdıran varsayılan [`debug`](#debug) ayarı dışında hapi hiçbir yere log yazmaz. Her log çağrısını `server.events` üzerinde bir olaya dönüştürür ve bu olaylarla ne yapılacağına sen karar verirsin: yazdırmak, bir loglama servisine göndermek ya da istek başına toplamak. -Neredeyse aynı çalışan iki günlükleme yöntemi var: [`server.log(tags, [data, [timestamp]])`](/api#-serverlogtags-data-timestamp), and [`request.log(tags, [data])`](https://hapijs.com/api#-requestlogtags-data). Her ikisi de uygulamandaki bir olayı günlüklemek için kullanılıyor. İstek işleyici, istek yaşam döngüsü uzatması ya da kimlik doğrulama şeması gibi istek kapsamında bulunan yerlerde `request.log()` kullanmak uygunken; geri kalan yerlerde, kapsamında belli bir istek yokken, örneğin, sunucun başlar başlamaz ya da bir eklentinin `register()` yönteminde, `server.log()` kullanmak daha uygundur. +```mermaid +flowchart TD + A["server.log()"] --> L["'log' olayı"] + B["request.log()"] --> R["'request' olayı"] + H["hapi'nin iç olayları"] --> L + H --> R + L --> D["debug seçeneği
eşleşen tag'leri yazdırır"] + R --> D + L --> Y["senin dinleyicilerin
server.events.on()"] + R --> Y + R --> C["request.logs
route log.collect ayarladığında"] -Her ikisi de ilk iki değiştirge olarak `tags` (etiketler) ve `data` (veri) kabul eder. + class L,R highlight +``` + +## Yerleşik Metotlar + +İki loglama metodu vardır. Kodunun çalıştığı yere uyanı seç: + +| Metot | Ne zaman kullanılır | Yaydığı olay | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------ | +| [`request.log(tags, [data])`](/api#request-log-tags-data) | bir istek kapsamdayken: bir handler, bir extension, bir auth şeması | `'request'` | +| [`server.log(tags, [data])`](/api#server-log-tags-data-timestamp) | kapsamda istek yokken: başlatma, bir plugin'in `register()` fonksiyonu | `'log'` | + +İkisi de aynı iki ana argümanı alır: + +- `tags`: `['error', 'database', 'read']` gibi bir string ya da string dizisi. hapi log seviyeleri yerine tag'ler kullanır, böylece herhangi bir kombinasyona göre filtreleyebilirsin: `database` tag'li her şey ya da yalnızca `error` ve `database` birlikte olanlar. +- `data`: isteğe bağlı. Bir mesaj, bir nesne ya da bir `Error`. Bunun yerine bir fonksiyon verirsen hapi onu yalnızca bir dinleyici olayı aldığında çağırır; böylece kimse dinlemiyorsa pahalı verinin hiçbir maliyeti olmaz. + +### request.log() + +```ts +server.route({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + request.log(['users', 'read'], { id: request.params.id }); + + return { id: request.params.id }; + }, +}); +``` + +Bu şekilde loglanan her olay isteğin id'sini taşır, böylece bir isteğe ait olayları bir arada gruplayabilirsin. + +### server.log() -`tags` olayı açıkça adlandıran bir metin ya da metin dizisi olabilir. Bunları günlükleme seviyeleri (log level) olarak düşünebilirsin ancak bir farkla: bunlar daha açıklayıcılar. Örneğin, veritabanından veri alırken meydana gelen bir hatayı şöyle etiketleyebilirsin: +```ts +import Hapi from '@hapi/hapi'; -```javascript -server.log(['error', 'database', 'read']); +const server = Hapi.server({ port: 3000 }); + +await server.start(); + +server.log(['startup'], `Server running at ${server.info.uri}`); ``` -hapinin oluşturduğu her günlükleme olayında kendiğinden `hapi` etiketi bulunur. +## Logları Almak ve Göstermek -İkinci değiştirge olan `data` (veri) değiştirgesi olayla birlikte günlüklenecek seçime bağlı bir metin ya da nesnedir. Hata mesajı ya da etiketle birlikte günlüklenmesini istediğin bilgileri buraya koyacaksın. +[`server.events`](/api#server-events) üzerinde dinle. hapi senin çağrılarının yanında aynı kanallarda kendi olaylarını da yayar: -Ek olarak `server.log()` yönteminin üçüncü bir `timestamp` (zaman damgası) değiştirgesi bulunur. Bunun varsayılanı `Date.now()`dır ve yalnızca bir sebeple üstüne yazmak istenirse kullanılmalıdır. +| Olay | `event.channel` | Kaynağı | +| ------------ | --------------- | -------------------------------------------------------------------------- | +| `'log'` | `'app'` | senin `server.log()` çağrıların | +| `'log'` | `'internal'` | hapi: sunucu yükü nedeniyle reddedilen istekler, istemci bağlantı hataları | +| `'request'` | `'app'` | senin `request.log()` çağrıların | +| `'request'` | `'internal'` | hapi: kimlik doğrulama hataları, handler hataları, geçersiz header'lar | +| `'request'` | `'error'` | hapi: 500 ile sonuçlanan her istek için bir kez | +| `'response'` | yok | hapi: her istek için bir kez, yanıt gönderildikten sonra | -### Hataları okumak ve görüntülemek +İç tag'lerin tam listesi API referansında [`'log'`](/api#log-event) ve [`'request'`](/api#request-event) olayları altında yer alır. -hapi sunucu nesnesi her bir günlükleme olayı için bir olay yayınlar. Standart EventEmitter API (olay yayınlayıcı uygulama programa arayüzü)nü kullanarak bu olayları dinleyebilir ve istediğin gibi gösterebilirsin. +Tipik bir kurulum uygulama olaylarını yazdırır, 500'leri raporlar ve her istek için bir satır erişim logu yazar: -```javascript +```ts server.events.on('log', (event, tags) => { - if (tags.error) { - console.log(`Server error: ${event.error ? event.error.message : 'unknown'}`); - } + console.log(event.timestamp, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'app' }, (request, event, tags) => { + console.log(request.info.id, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'error' }, (request, event, tags) => { + console.error(`Request ${request.info.id} failed:`, event.error); +}); + +server.events.on('response', (request) => { + const duration = request.info.completed - request.info.received; + + console.log(`${request.method.toUpperCase()} ${request.path} ${request.raw.res.statusCode} ${duration}ms`); }); ``` -`server.log()` kullanılarak günlüklenen olaylar bir `log` (günlükleme) olayı, `request.log()` kullanılarak günlüklenen olaylar ise bir `request` (istek) olayı yayınlar. +- `tags`, her tag için bir `true` anahtarı olan bir nesnedir; bu yüzden `if (tags.error)` hızlı bir denetimdir. +- `event.data` ve `event.error` hiçbir zaman birlikte gelmez: `data` olarak verilen bir `Error`, `event.error` olarak gelir. +- `{ name: 'request', channels: 'error' }` ölçüt nesnesi tek bir kanala abone olur. Bu olmadan üç kanalın hepsini alırsın. + +hapi `'response'` olayını yanıt gönderildikten sonra yayar ve bir dinleyicinin döndürdüğü promise'leri beklemez. hapi'nin sırayla beklemesi gereken asenkron işler için bir `onPostResponse` extension'ı kullan; bkz. [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks). -Bir istek ile ilgili tüm günlükleri `request.logs` kullanarak alabilirsin. Günlüklenen tüm istek olaylarını içeren bir dizi döner. Önce yol (route) üzerinde `log.collect` seçeneğini `true` olarak ayarlamalısın, yoksa dizi boş gelir. +### Tek bir isteğin loglarını toplamak -```javascript +Bir isteğin olaylarını isteğin içinden okumak için route'ta `log.collect` ayarla. O istek için loglanan her olay `request.logs` içine düşer: + +```ts server.route({ method: 'GET', path: '/', @@ -54,23 +126,34 @@ server.route({ }, }, handler: function (request, h) { - return 'hello'; + request.log(['step'], 'loaded user'); + request.log(['step'], 'loaded orders'); + + return { steps: request.logs.length }; }, }); ``` -### Hata ayiklama modu (yalnizca gelistirme sirasinda) +`log.collect` olmadan `request.logs` boş kalır. -hapinin ekstra bir eklenti ya da kendi yazdığın bir günlükleme koduna gerek olmaksızın günlükleme olaylarını konsola döktüğü bir hata ayıklama modu var. +## Debug Modu (yalnızca geliştirme için) -Varsayılan olarak, konsola yalnızca kullanıcı kodu içerisinde yakalanmayan ve hapi API içerisindeki hatalı donatılardan kaynaklanan hatalar ekrana dökülür. Ancak istersen sunucunu etiketlere dayalı olarak istek (request) olaylarını yazdırmak üzere de ayarlayabilirsin. Örneğin, istekler sırasında oluşan tüm hataları yazdırmak için sunucunu şu şekilde ayarlayabilirsin: +[`debug`](/api#server-options-debug) sunucu seçeneği, eşleşen olayları dinleyici kodu yazmadan `console.error()` ile konsola yazdırır. Yalnızca neyin yazdırıldığını değiştirir, neyin yayıldığını değil. + +| Seçenek | Varsayılan | Yazdırdığı | +| --------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `debug.request` | `['implementation']` | bu tag'lerden herhangi birini taşıyan `'request'` olayları. Varsayılan, kodundaki hataların neden olduğu 500'leri yazdırır. | +| `debug.log` | yok | bu tag'lerden herhangi birini taşıyan `'log'` olayları. | +| `debug: false` | | hiçbir şey | + +Her liste tag'ler, her olay için `'*'` ya da hiçbiri için `false` alır. Geliştirme sırasında her istek hatasını yazdırmak için: + +```ts +import Hapi from '@hapi/hapi'; -```javascript const server = Hapi.server({ debug: { request: ['error'] } }); ``` -Hata ayıklama modu hakkında daha fazla bilgili [API dokümantasyonu](https://hapijs.com/api#-serveroptionsdebug)nda bulabilirsin. - -## Gunlukleme eklentileri +## Loglama Plugin'leri -hapi tarafından hataları okumak ve yazdırmak için sunulan yöntemler gerçekten minimalisttir. Daha çok özellik bulunduran bir günlükleme deneyimi için [hapi-pino](https://www.npmjs.com/package/hapi-pino). +Production'da loglama için bu olayları senin yerine dinleyen bir plugin kullan; örneğin yapılandırılmış JSON loglar için [hapi-pino](https://www.npmjs.com/package/hapi-pino), [New Relic](https://newrelic.com/instant-observability/hapi/2124da5b-0174-457a-be5a-e0068673b2b5) ya da diğer [hapi loglama plugin'lerinden](/plugins#logging) herhangi biri. diff --git a/docs/tutorials/zh_CN/logging.md b/docs/tutorials/zh_CN/logging.md index f455d9bd..93a9d0f8 100644 --- a/docs/tutorials/zh_CN/logging.md +++ b/docs/tutorials/zh_CN/logging.md @@ -1,50 +1,122 @@ --- -title: Logging +title: 日志 --- -## 日志 +# 日志 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更新版本_ -对于软件开发而言,日志的重要性不言而喻。 hapi 内建的日志方法已经基本可以满足日常开发的需要。 +## 概述 -### 内建方法 +除了默认的 [`debug`](#debug) 选项会把你自己代码中的错误打印到控制台之外,hapi 不会把日志写到任何地方。它把每次日志调用转换成 `server.events` 上的一个事件,由你决定如何处理这些事件:打印出来、发送到日志服务,或者按请求收集。 -在你的应用程序内,你可以使用 [`server.log(tags, [data, [timestamp]])`](/api#-serverlogtags-data-timestamp), 以及[`request.log(tags, [data])`](https://hapijs.com/api#-requestlogtags-data) 这两种日志方法。 你可以通过调用 `request.log()` 记录一个请求的上下文信息。 例如在路由的处理过程中. 记录请求的生命周期扩展以及授权方案。你也可以通过调用 `server.log()` 记录除请求之外的其余信息。 例如服务的启动信息以及插件的 `register()` 方法。 +```mermaid +flowchart TD + A["server.log()"] --> L["'log' 事件"] + B["request.log()"] --> R["'request' 事件"] + H["hapi 内部事件"] --> L + H --> R + L --> D["debug 选项
打印匹配的标签"] + R --> D + L --> Y["你的监听器
server.events.on()"] + R --> Y + R --> C["request.logs
当路由设置了 log.collect 时"] -以上两种方法前两个参数都一样,分别是 `tags` 和 `data`。 + class L,R highlight +``` + +## 内置方法 + +日志方法有两个。根据代码运行的位置选择对应的方法: + +| 方法 | 适用场景 | 触发的事件 | +| ----------------------------------------------------------------- | --------------------------------------------- | ----------- | +| [`request.log(tags, [data])`](/api#request-log-tags-data) | 有请求可用时:handler、扩展、认证 scheme | `'request'` | +| [`server.log(tags, [data])`](/api#server-log-tags-data-timestamp) | 没有请求可用时:启动阶段、插件的 `register()` | `'log'` | + +两者接受相同的两个主要参数: + +- `tags`:一个字符串或字符串数组,例如 `['error', 'database', 'read']`。hapi 使用标签而不是日志级别,所以你可以按任意组合进行过滤:所有带 `database` 标签的事件,或者只要同时带有 `error` 和 `database` 的事件。 +- `data`:可选。可以是一条消息、一个对象或一个 `Error`。如果传入一个函数,hapi 只在有监听器接收该事件时才调用它,因此在没人监听时,开销大的数据不会产生任何成本。 + +### request.log() + +```ts +server.route({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + request.log(['users', 'read'], { id: request.params.id }); + + return { id: request.params.id }; + }, +}); +``` + +以这种方式记录的每个事件都带有请求的 id,因此你可以把同一个请求的事件归到一起。 + +### server.log() -`tags` 可以是一个字符串或者字符串数组用于鉴别事件。 你可以把它当作日志等级,或者添加更多可描述的内容。 例如:记录一个从数据库中的读取错误你可以这样写: +```ts +import Hapi from '@hapi/hapi'; -```javascript -server.log(['error', 'database', 'read']); +const server = Hapi.server({ port: 3000 }); + +await server.start(); + +server.log(['startup'], `Server running at ${server.info.uri}`); ``` -hapi 中所有生成的日志事件,都会有一个 `hapi` 标签。 +## 获取和显示日志 -第二个参数 `data` 是一个可选的字符串或者对象。 这里你可以传入错误信息,或者其余相关细节。 +在 [`server.events`](/api#server-events) 上监听。除了你自己的调用之外,hapi 也会在同样的通道上触发它自己的事件: -除此之外 `server.log()` 可以接受 `timestamp` 作为第三个参数。 它的默认值是 `Date.now()`, 除非有特殊的原因,我们不建议你覆盖这个默认值。 +| 事件 | `event.channel` | 来源 | +| ------------ | --------------- | ---------------------------------------------- | +| `'log'` | `'app'` | 你的 `server.log()` 调用 | +| `'log'` | `'internal'` | hapi:因服务器负载而拒绝请求、客户端连接错误 | +| `'request'` | `'app'` | 你的 `request.log()` 调用 | +| `'request'` | `'internal'` | hapi:身份认证失败、handler 错误、无效的请求头 | +| `'request'` | `'error'` | hapi:每个以 500 结束的请求触发一次 | +| `'response'` | 无 | hapi:每个请求在响应发送之后触发一次 | -### 检索以及显示日志 +内部标签的完整列表见 API 参考中的 [`'log'`](/api#log-event) 和 [`'request'`](/api#request-event) 事件。 -hapi 服务器对象为每个日志事件都发出了事件。你可以通过标准的 EventEmitter API 去监听这些事件并做处理。 +典型的配置会打印应用事件、报告 500 错误,并为每个请求写一行访问日志: -```javascript +```ts server.events.on('log', (event, tags) => { - if (tags.error) { - console.log(`Server error: ${event.error ? event.error.message : 'unknown'}`); - } + console.log(event.timestamp, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'app' }, (request, event, tags) => { + console.log(request.info.id, event.tags, event.data ?? event.error); +}); + +server.events.on({ name: 'request', channels: 'error' }, (request, event, tags) => { + console.error(`Request ${request.info.id} failed:`, event.error); +}); + +server.events.on('response', (request) => { + const duration = request.info.completed - request.info.received; + + console.log(`${request.method.toUpperCase()} ${request.path} ${request.raw.res.statusCode} ${duration}ms`); }); ``` -调用 `server.log()` 时将会发出 `log` 事件,而调用 `request.log()` 时将会发出一个 `request` 事件。 +- `tags` 是一个对象,每个标签对应一个值为 `true` 的键,所以用 `if (tags.error)` 就能快速判断。 +- `event.data` 和 `event.error` 不会同时出现:作为 `data` 传入的 `Error` 会以 `event.error` 的形式到达。 +- 条件对象 `{ name: 'request', channels: 'error' }` 只订阅一个通道。不使用它,你会收到全部三个通道的事件。 + +hapi 在响应发送之后触发 `'response'`,并且不会等待监听器返回的 promise。对于需要 hapi 按顺序等待的异步工作,请使用 `onPostResponse` 扩展;参见[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)。 -你可以通过 `request.logs` 一次性获取到所有日志。 这将返回一个包含所有请求日志事件的数组。想要使用这个功能需要将路由的 `log.collect` 选项设置为 `true`,否则只能得到一个空的数组。 +### 收集单个请求的日志 -```javascript +如果要在请求内部读取该请求的事件,请在路由上设置 `log.collect`。之后为该请求记录的每个事件都会存入 `request.logs`: + +```ts server.route({ method: 'GET', path: '/', @@ -54,23 +126,34 @@ server.route({ }, }, handler: function (request, h) { - return 'hello'; + request.log(['step'], 'loaded user'); + request.log(['step'], 'loaded orders'); + + return { steps: request.logs.length }; }, }); ``` -### Debug 模式 (只针对开发) +不设置 `log.collect` 时,`request.logs` 始终为空。 -hapi 拥有 debug 模式, 它可以直接显示所有日志事件到控制台,而不需要额外去配置或者使用一些插件。 +## 调试模式(仅用于开发) -默认情况,debug 模式只会输出应用未捕获的错误、运行时错误以及错误实现hapi API所带来错误等。你可以在服务器配置中配置 debug 模式需要打印哪些 tag 的错误。例如你想将所有请求中的错误信息在 debug 模式中输出,可以如下配置: +服务器选项 [`debug`](/api#server-options-debug) 通过 `console.error()` 把匹配的事件打印到控制台,无需编写监听器代码。它只改变打印的内容,不改变触发的事件。 + +| 选项 | 默认值 | 打印内容 | +| --------------- | -------------------- | ---------------------------------------------------------------------------- | +| `debug.request` | `['implementation']` | 带有其中任一标签的 `'request'` 事件。默认值会打印由代码缺陷导致的 500 错误。 | +| `debug.log` | 无 | 带有其中任一标签的 `'log'` 事件。 | +| `debug: false` | | 什么都不打印 | + +每个列表可以是标签、表示所有事件的 `'*'`,或者表示不打印的 `false`。在开发时打印所有请求错误: + +```ts +import Hapi from '@hapi/hapi'; -```javascript const server = Hapi.server({ debug: { request: ['error'] } }); ``` -更多关于 debug 模式的信息请参阅 [API documentation](https://hapijs.com/api#-serveroptionsdebug). - -## 日志插件 +## 日志插件 -hapi 内建的日志方法已经基本满足了开发的需求。如果需要更多的日志功能,可以使用如[hapi-pino](https://www.npmjs.com/package/hapi-pino)之类的日志插件。 +在生产环境中,请使用替你监听这些事件的插件,例如输出结构化 JSON 日志的 [hapi-pino](https://www.npmjs.com/package/hapi-pino)、[New Relic](https://newrelic.com/instant-observability/hapi/2124da5b-0174-457a-be5a-e0068673b2b5),或其他任何 [hapi 日志插件](/plugins#logging)。 From 414733910487c1d80203f276c3163e4e83265eec Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:56 -0400 Subject: [PATCH 10/22] docs: rework plugins tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/plugins.md | 249 ++++++++++++------------ docs/tutorials/es_ES/plugins.md | 231 +++++++++++++++++++++++ docs/tutorials/ko_KR/plugins.md | 241 ++++++++++++++--------- docs/tutorials/pt_BR/plugins.md | 325 ++++++++++++++++---------------- docs/tutorials/tr_TR/plugins.md | 241 ++++++++++++++--------- docs/tutorials/zh_CN/plugins.md | 241 ++++++++++++++--------- 6 files changed, 976 insertions(+), 552 deletions(-) create mode 100644 docs/tutorials/es_ES/plugins.md diff --git a/docs/tutorials/en_US/plugins.md b/docs/tutorials/en_US/plugins.md index 71c316c5..2543278e 100644 --- a/docs/tutorials/en_US/plugins.md +++ b/docs/tutorials/en_US/plugins.md @@ -6,27 +6,34 @@ title: Plugins # Plugins -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -hapi has an extensive and powerful plugin system that allows you to very easily break your application up into isolated pieces of business logic, and reusable utilities. You can either add an existing plugin to your application, or create your own. +A plugin is a piece of a hapi application packaged so it can be registered on a server: a set of routes, an authentication scheme, a logger, a template engine. Most of the hapi ecosystem is published as plugins ([inert](/module/inert), [vision](/module/vision), [@hapi/basic](/module/basic)), and splitting your own application into plugins keeps each part isolated and testable. -## Creating a plugin +A plugin receives the server in its `register` function and changes it through the normal server API. The ones that matter most for plugin authors: + +| Server API | What the plugin adds | Tutorial | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------- | --------------------------------------------------- | +| [`server.route()`](/api#server-route-route) | routes | [Routing](/tutorials/en_US/routing) | +| [`server.ext()`](/api#server-ext-events) | code that runs at a step of every request, or at server start and stop | [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks) | +| [`server.decorate()`](/api#server-decorate-type-property-method-options) | new methods on `server`, `request`, or `h` | [Decorators](/tutorials/en_US/decorators) | +| [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) | an authentication scheme | [Authentication](/tutorials/en_US/auth) | +| [`server.method()`](/api#server-method-name-method-options) | shared, cacheable functions | [Server Methods](/tutorials/en_US/server-methods) | +| [`server.expose()`](/api#server-expose-key-value-options) | values other plugins read from `server.plugins[name]` | | -Plugins are very simple to write. At their core they are an object with a `register` property, that is a function with the signature `async function (server, options)`. Additionally the plugin object has a required `name` property and several optional properties including `version`. +## Creating a plugin -A very simple plugin looks like: +A plugin is an object with a `name` and an async `register(server, options)` function: -```javascript -'use strict'; +```ts +import type { Plugin } from '@hapi/hapi'; -const myPlugin = { +const myPlugin: Plugin = { name: 'myPlugin', version: '1.0.0', register: async function (server, options) { - // Create a route for example - server.route({ method: 'GET', path: '/test', @@ -34,25 +41,39 @@ const myPlugin = { return 'hello, world'; }, }); - - // etc ... - await someAsyncMethods(); }, }; ``` -Once this plugin is registered, the server will display `'hello, world'` when the user goes to route `/test`. +Once registered, `GET /test` answers `'hello, world'`. -To write a plugin as an external module, you can specify a `pkg` property: +`Plugin` takes the type of the plugin's options. This plugin has none, so it is `Plugin`. With the plugin typed, TypeScript infers `server` and `options` in `register`, and checks that the plugin has a `name` or a `pkg`. -```javascript -'use strict'; +The plugin object's properties: -exports.plugin = { - pkg: require('./package.json'), - register: async function (server, options) { - // Create a route for example +| Property | Meaning | +| -------------- | --------------------------------------------------------------------------------------------------------- | +| `register` | Required. Sets the plugin up. Registration waits for it, and fails if it throws. | +| `name` | Required unless `pkg` is set. Must be unique on the server. Published plugins use their npm package name. | +| `version` | Informational. Other plugins can read it, and [dependencies](#dependencies) can require a version range. | +| `pkg` | Your `package.json`, used for `name` and `version` instead of setting them by hand. | +| `dependencies` | Plugins that must also be registered. See [dependencies](#dependencies). | +| `requirements` | Supported `node` and `hapi` version ranges, such as `{ hapi: '>=21' }`. Registration fails outside them. | +| `multiple` | `true` allows registering the plugin more than once on the same server. | +| `once` | `true` makes repeated registrations a silent no-op instead of an error. | + +### Publishing a plugin as a module + +A plugin module exports the plugin object, either as the default export or as a named `plugin` export. `pkg` reads the name and version from `package.json`: +```ts +import Pkg from './package.json' with { type: 'json' }; + +import type { Plugin } from '@hapi/hapi'; + +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { server.route({ method: 'GET', path: '/test', @@ -60,163 +81,151 @@ exports.plugin = { return 'hello, world'; }, }); - - // etc... - await someAsyncMethods(); }, }; ``` -Note that in the first example, we set the `name` and `version` properties explicitly, however in the second we set a `pkg` parameter with the contents of package.json as its value. Either method is acceptable. - -When written as a module, a plugin can either be top-level module export i.e `module.exports = { register, name, version }` or if you want your module to export more than a hapi plugin, it can be exported as `exports.plugin = { register, name, version }`. - -Additionally, the plugin object may contain the property `multiple` that when set to `true` tells hapi that it is safe to register your plugin more than once in the same server. - -Another available property is `once`. When set to `true` will mean hapi ignores subsequent registers of the same plugin without throwing an error. - -### The register method +hapi accepts either export shape: `server.register()` takes a plugin object, or a module whose `plugin` property is one. -As we've seen above, the `register` method accepts two parameters, `server` and `options`. +### Plugin options -`register` should be an async function that returns once your plugin has completed whatever steps are necessary for it to be registered. Alternatively your register plugin should throw an error if an error occurred while registering your plugin. +`options` is whatever the caller passes at registration, unchanged. Type it with `Plugin`: -The `server` object is a reference to the `server` your plugin is being loaded in. +```ts +import Pkg from './package.json' with { type: 'json' }; -The `options` parameter is simply whatever options the user passes to your plugin when calling `server.register(plugins, [options])`. No changes are made and the object is passed directly to your `register` method. Here is an example: +import type { Plugin } from '@hapi/hapi'; -```javascript -'use strict'; +interface Options { + name: string; +} -exports.plugin = { - pkg: require('./package.json'), +export const plugin: Plugin = { + pkg: Pkg, register: async function (server, options) { - // Create a route for example - server.route({ method: 'GET', path: '/test', handler: function (request, h) { - const name = options.name; - return `Hello ${name}`; + return `Hello ${options.name}`; }, }); - - // etc... - await someAsyncMethods(); }, }; ``` -Here, you grab a name from the `options` object via `options.name`. You then use that name to return a message to the user. Lets see how you pass that name to the plugin: +`options.name` is a `string`, and TypeScript flags a `server.register()` call whose options don't match `Options`; hapi does not check them at runtime. + +## Loading a plugin + +Register plugins with [`server.register()`](/api#await-server-register-plugins-options). Pass the plugin itself, or an object with `plugin` and `options`, or an array of either: + +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; +import { plugin as YourPlugin } from 'yourplugin'; -```javascript const start = async function () { + const server = Hapi.server(); + + await server.register(YourPlugin); + await server.register({ - plugin: require('myplugin'), + plugin: MyPlugin, options: { name: 'Bob', }, }); + + await server.register([{ plugin: YourPlugin }, { plugin: MyPlugin, options: { name: 'Bob' } }]); }; ``` -When you register the plugin, you can pass whatever options to it with `server.register(plugins, [options])`. Here you are passing `{ name: "Bob" }` to your plugin, which as you see above, can be accessed with the `options` object when you create the plugin. - -## Loading a plugin - -Plugins can be loaded one at a time, or as a group in an array, by the `server.register(plugins, [options])` method, for example: - -```javascript -const start = async function () { - const server = Hapi.server(); +Plugins in an array register one after another, in order. Each `register` function finishes before the next one starts. - // load one plugin - - await server.register(require('myplugin')); +### Registration options - // load multiple plugins +The second argument to `server.register()` configures how hapi registers the plugin. It is _not_ passed to the plugin: - await server.register([require('myplugin'), require('yourplugin')]); -}; -``` +| Option | Effect | +| --------------- | -------------------------------------------------------------------------------- | +| `routes.prefix` | Prepends a path to every route the plugin adds. `/test` becomes `/plugins/test`. | +| `routes.vhost` | Applies a virtual host to every route the plugin adds. | +| `once` | `true` skips the plugin if it is already registered, instead of throwing. | -To pass options to your plugin, we instead pass an object with `plugin` and `options` keys, such as: +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; -```javascript const start = async function () { const server = Hapi.server(); - await server.register({ - plugin: require('myplugin'), - options: { - message: 'hello', + await server.register(MyPlugin, { + routes: { + prefix: '/plugins', }, }); }; ``` -These objects can also be passed in an array: +Prefixes nest: if a plugin registered with prefix `/api` registers a child plugin with prefix `/v1`, the child's routes start with `/api/v1`. See [`server.register()`](/api#await-server-register-plugins-options) for the full list. -```javascript -const start = async function () { - const server = Hapi.server(); +## What a plugin can and cannot affect - await server.register([ - { - plugin: require('plugin1'), - options: {}, - }, - { - plugin: require('plugin2'), - options: {}, - }, - ]); -}; -``` +Each plugin gets its own [`server.realm`](/api#server-realm): a container for settings that stay inside the plugin. Everything else a plugin does changes the whole server: -### Registration options +| Change | Scope | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| route prefix and vhost from registration options | the plugin's routes and its child plugins | +| [`server.bind()`](/api#server-bind-context), [`server.path()`](/api#server-path-relativeto) | routes, handlers, and extensions added by the plugin | +| request [lifecycle hooks](/tutorials/en_US/lifecycle-hooks) with `options.sandbox: 'plugin'` | the plugin's routes | +| request lifecycle hooks without `sandbox` | **every route on the server** | +| [decorations](/tutorials/en_US/decorators), server methods, auth strategies, `server.auth.default()` | **the whole server** | + +A plugin that adds an `onPreResponse` hook to format its own errors should pass `sandbox: 'plugin'`, or it will reformat every other plugin's errors too. -You may also pass a second optional parameter to `server.register()`. Documentation for this object can be found in the [API reference](). +## Dependencies -The options object is used by hapi and is _not_ passed to the plugin(s) being loaded. It allows you to apply `vhost` or `prefix` modifiers to any routes that your plugins register. +A plugin that needs another plugin declares it, instead of relying on registration order: -For example, let's say we have a plugin that looks like this: +```ts +import type { Plugin } from '@hapi/hapi'; -```javascript -'use strict'; +declare module '@hapi/hapi' { + interface PluginProperties { + database: { migrate: () => Promise }; + } +} -exports.plugin = { - pkg: require('./package.json'), +export const plugin: Plugin = { + name: 'dashboard', + dependencies: ['@hapi/vision', 'auth'], register: async function (server, options) { - server.route({ - method: 'GET', - path: '/test', - handler: function (request, h) { - return 'test passed'; - }, + server.dependency('database', async (server) => { + await server.plugins.database.migrate(); }); - - // etc... - await someAsyncMethods(); }, }; ``` -Normally, when this plugin is loaded it will create a `GET` route at `/test`. This can be changed by using the `prefix` setting in the options, which will prepend a string to all routes created in the plugin: - -```javascript -const start = async function () { - const server = Hapi.server(); - - await server.register(require('myplugin'), { - routes: { - prefix: '/plugins', - }, - }); -}; +hapi checks dependencies when the server initializes, not when the plugin registers, so plugins can be registered in any order: + +```mermaid +sequenceDiagram + participant A as your app + participant S as server + A->>S: register(dashboard) + Note over S: records: dashboard needs @hapi/vision, auth, database + A->>S: register(vision, auth, database) + A->>S: initialize() or start() + Note over S: 1. fails here if a dependency is missing + Note over S: 2. starts the cache clients + Note over S: 3. onPreStart: runs the server.dependency() callback + Note over S: 4. start() only: the server begins listening ``` -Now when the plugin is loaded, because of the `prefix` option the `GET` route will be created at `/plugins/test`. +- `dependencies` is a list of names, or an object of names to version ranges such as `{ '@hapi/vision': '7.x' }`. +- [`server.dependency(names, after)`](/api#server-dependency-dependencies-after) declares the same thing from inside `register`, and accepts an `after` function. hapi runs `after` during server start, once every listed plugin is registered. Use it for setup that needs another plugin's API. +- hapi throws on circular `after` dependencies. -Similarly the `options.routes.vhost` property will assign a default `vhost` configuration to any routes created by the plugins being loaded. More detail about the `vhost` configuration can be found in the [API reference](). +`server.plugins.database` is what the `database` plugin published with [`server.expose()`](/api#server-expose-key-value-options). hapi cannot know its shape, so the `declare module` block adds it to hapi's `PluginProperties` interface. diff --git a/docs/tutorials/es_ES/plugins.md b/docs/tutorials/es_ES/plugins.md new file mode 100644 index 00000000..4afa5225 --- /dev/null +++ b/docs/tutorials/es_ES/plugins.md @@ -0,0 +1,231 @@ +--- +title: Plugins +--- + + + +# Plugins + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Un plugin es una parte de una aplicación hapi empaquetada para poder registrarla en un servidor: un conjunto de rutas, un scheme de autenticación, un logger, un motor de plantillas. La mayor parte del ecosistema hapi se publica como plugins ([inert](/module/inert), [vision](/module/vision), [@hapi/basic](/module/basic)), y dividir tu propia aplicación en plugins mantiene cada parte aislada y fácil de probar. + +Un plugin recibe el servidor en su función `register` y lo modifica mediante la API normal del servidor. Las partes que más importan a quien escribe plugins: + +| API del servidor | Qué añade el plugin | Tutorial | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| [`server.route()`](/api#server-route-route) | rutas | [Enrutamiento](/tutorials/es_ES/routing) | +| [`server.ext()`](/api#server-ext-events) | código que se ejecuta en un paso de cada petición, o al arrancar y detener el servidor | [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) | +| [`server.decorate()`](/api#server-decorate-type-property-method-options) | métodos nuevos en `server`, `request` o `h` | [Decoradores](/tutorials/es_ES/decorators) | +| [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) | un scheme de autenticación | [Autenticación](/tutorials/es_ES/auth) | +| [`server.method()`](/api#server-method-name-method-options) | funciones compartidas que se pueden cachear | [Métodos de servidor](/tutorials/es_ES/server-methods) | +| [`server.expose()`](/api#server-expose-key-value-options) | valores que otros plugins leen de `server.plugins[name]` | | + +## Crear un plugin + +Un plugin es un objeto con un `name` y una función asíncrona `register(server, options)`: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const myPlugin: Plugin = { + name: 'myPlugin', + version: '1.0.0', + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return 'hello, world'; + }, + }); + }, +}; +``` + +Una vez registrado, `GET /test` responde `'hello, world'`. + +`Plugin` recibe el tipo de las opciones del plugin. Este plugin no tiene ninguna, así que es `Plugin`. Con el plugin tipado, TypeScript infiere `server` y `options` en `register`, y comprueba que el plugin tenga un `name` o un `pkg`. + +Las propiedades del objeto plugin: + +| Propiedad | Significado | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `register` | Obligatoria. Configura el plugin. El registro espera a que termine y falla si lanza un error. | +| `name` | Obligatoria salvo que se indique `pkg`. Debe ser única en el servidor. Los plugins publicados usan su nombre de paquete npm. | +| `version` | Informativa. Otros plugins pueden leerla, y las [dependencias](#dependencies) pueden exigir un rango de versiones. | +| `pkg` | Tu `package.json`, del que se toman `name` y `version` en lugar de escribirlos a mano. | +| `dependencies` | Plugins que también deben estar registrados. Consulta [dependencias](#dependencies). | +| `requirements` | Rangos de versiones de `node` y `hapi` admitidos, como `{ hapi: '>=21' }`. El registro falla fuera de ellos. | +| `multiple` | `true` permite registrar el plugin más de una vez en el mismo servidor. | +| `once` | `true` hace que los registros repetidos se ignoren en silencio en lugar de dar error. | + +### Publicar un plugin como módulo + +Un módulo de plugin exporta el objeto plugin, ya sea como export por defecto o como export con nombre `plugin`. `pkg` toma el nombre y la versión de `package.json`: + +```ts +import Pkg from './package.json' with { type: 'json' }; + +import type { Plugin } from '@hapi/hapi'; + +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return 'hello, world'; + }, + }); + }, +}; +``` + +hapi acepta cualquiera de las dos formas de export: `server.register()` recibe un objeto plugin, o un módulo cuya propiedad `plugin` sea uno. + +### Opciones del plugin + +`options` es lo que pase quien registra el plugin, sin cambios. Tipéalo con `Plugin`: + +```ts +import Pkg from './package.json' with { type: 'json' }; + +import type { Plugin } from '@hapi/hapi'; + +interface Options { + name: string; +} + +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return `Hello ${options.name}`; + }, + }); + }, +}; +``` + +`options.name` es un `string`, y TypeScript marca como error una llamada a `server.register()` cuyas opciones no encajen con `Options`; hapi no las comprueba en tiempo de ejecución. + +## Cargar un plugin + +Registra plugins con [`server.register()`](/api#await-server-register-plugins-options). Pasa el propio plugin, un objeto con `plugin` y `options`, o un array de cualquiera de los dos: + +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; +import { plugin as YourPlugin } from 'yourplugin'; + +const start = async function () { + const server = Hapi.server(); + + await server.register(YourPlugin); + + await server.register({ + plugin: MyPlugin, + options: { + name: 'Bob', + }, + }); + + await server.register([{ plugin: YourPlugin }, { plugin: MyPlugin, options: { name: 'Bob' } }]); +}; +``` + +Los plugins de un array se registran uno tras otro, en orden. Cada función `register` termina antes de que empiece la siguiente. + +### Opciones de registro + +El segundo argumento de `server.register()` configura cómo registra hapi el plugin. _No_ se le pasa al plugin: + +| Opción | Efecto | +| --------------- | -------------------------------------------------------------------------------------- | +| `routes.prefix` | Antepone una ruta a cada ruta que añade el plugin. `/test` pasa a ser `/plugins/test`. | +| `routes.vhost` | Aplica un host virtual a cada ruta que añade el plugin. | +| `once` | `true` omite el plugin si ya está registrado, en lugar de lanzar un error. | + +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; + +const start = async function () { + const server = Hapi.server(); + + await server.register(MyPlugin, { + routes: { + prefix: '/plugins', + }, + }); +}; +``` + +Los prefijos se anidan: si un plugin registrado con el prefijo `/api` registra un plugin hijo con el prefijo `/v1`, las rutas del hijo empiezan por `/api/v1`. Consulta [`server.register()`](/api#await-server-register-plugins-options) para ver la lista completa. + +## Qué puede y qué no puede afectar un plugin + +Cada plugin recibe su propio [`server.realm`](/api#server-realm): un contenedor de ajustes que se quedan dentro del plugin. Todo lo demás que hace un plugin cambia el servidor entero: + +| Cambio | Alcance | +| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| prefijo de ruta y vhost de las opciones de registro | las rutas del plugin y sus plugins hijos | +| [`server.bind()`](/api#server-bind-context), [`server.path()`](/api#server-path-relativeto) | rutas, handlers y extensiones que añade el plugin | +| [hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) de la petición con `options.sandbox: 'plugin'` | las rutas del plugin | +| hooks del ciclo de vida de la petición sin `sandbox` | **todas las rutas del servidor** | +| [decoraciones](/tutorials/es_ES/decorators), métodos de servidor, strategies de autenticación, `server.auth.default()` | **todo el servidor** | + +Un plugin que añade un hook `onPreResponse` para dar formato a sus propios errores debe pasar `sandbox: 'plugin'`; si no, también reformateará los errores de todos los demás plugins. + +## Dependencias + +Un plugin que necesita otro plugin lo declara, en lugar de depender del orden de registro: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginProperties { + database: { migrate: () => Promise }; + } +} + +export const plugin: Plugin = { + name: 'dashboard', + dependencies: ['@hapi/vision', 'auth'], + register: async function (server, options) { + server.dependency('database', async (server) => { + await server.plugins.database.migrate(); + }); + }, +}; +``` + +hapi comprueba las dependencias cuando el servidor se inicializa, no cuando se registra el plugin, así que los plugins se pueden registrar en cualquier orden: + +```mermaid +sequenceDiagram + participant A as tu aplicación + participant S as servidor + A->>S: register(dashboard) + Note over S: anota: dashboard necesita @hapi/vision, auth, database + A->>S: register(vision, auth, database) + A->>S: initialize() o start() + Note over S: 1. falla aquí si falta una dependencia + Note over S: 2. arranca los clientes de caché + Note over S: 3. onPreStart: ejecuta el callback de server.dependency() + Note over S: 4. solo con start(): el servidor empieza a escuchar +``` + +- `dependencies` es una lista de nombres, o un objeto que asocia nombres a rangos de versiones, como `{ '@hapi/vision': '7.x' }`. +- [`server.dependency(names, after)`](/api#server-dependency-dependencies-after) declara lo mismo desde dentro de `register` y acepta una función `after`. hapi ejecuta `after` durante el arranque del servidor, una vez registrados todos los plugins de la lista. Úsala para la configuración que necesita la API de otro plugin. +- hapi lanza un error ante dependencias `after` circulares. + +`server.plugins.database` es lo que el plugin `database` publicó con [`server.expose()`](/api#server-expose-key-value-options). hapi no puede conocer su forma, así que el bloque `declare module` la añade a la interfaz `PluginProperties` de hapi. diff --git a/docs/tutorials/ko_KR/plugins.md b/docs/tutorials/ko_KR/plugins.md index 4b19bede..a163b837 100644 --- a/docs/tutorials/ko_KR/plugins.md +++ b/docs/tutorials/ko_KR/plugins.md @@ -1,30 +1,39 @@ --- -title: Plugins +title: 플러그인 --- -## Plugins +# 플러그인 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -hapi는 막대하고 강력한 플러그인 시스템을 가지고 있어 응용프로그램을 쉽게 독립된 비즈니스 로직 부분과 재사용 가능한 유틸리티 부분으로 쉽게 나눌 수 있습니다. +## 개요 -## 플러그인 만들기 +플러그인은 서버에 등록할 수 있도록 묶어 둔 hapi 애플리케이션의 한 조각입니다. 라우트 묶음, 인증 스킴, 로거, 템플릿 엔진 등이 플러그인이 될 수 있습니다. hapi 생태계의 대부분은 플러그인으로 배포되며([inert](/module/inert), [vision](/module/vision), [@hapi/basic](/module/basic)), 여러분의 애플리케이션도 플러그인으로 나누면 각 부분을 격리하고 테스트하기 쉬워집니다. -플러그인은 작성하기에 매우 간단합니다. 그 중심에 `async function (server, options)` 형태의 `register` 함수를 가진 객체가 있습니다. 추가로 플러그인 객체는 `name`이라는 필수 속성과 `version`을 포함한 몇 가지 선택적인 속성을 가지고 있습니다. +플러그인은 `register` 함수에서 서버를 받아 일반 서버 API로 서버를 변경합니다. 플러그인 작성자에게 가장 중요한 API는 다음과 같습니다. -매우 간단한 플러그인은 다음과 같습니다.: +| 서버 API | 플러그인이 추가하는 것 | 튜토리얼 | +| ------------------------------------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------- | +| [`server.route()`](/api#server-route-route) | 라우트 | [라우팅](/tutorials/ko_KR/routing) | +| [`server.ext()`](/api#server-ext-events) | 모든 요청의 특정 단계나 서버 시작과 종료 시점에 실행되는 코드 | [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks) | +| [`server.decorate()`](/api#server-decorate-type-property-method-options) | `server`, `request`, `h`의 새 메서드 | [데코레이터](/tutorials/ko_KR/decorators) | +| [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) | 인증 스킴 | [인증](/tutorials/ko_KR/auth) | +| [`server.method()`](/api#server-method-name-method-options) | 공유하고 캐싱할 수 있는 함수 | [서버 메서드](/tutorials/ko_KR/server-methods) | +| [`server.expose()`](/api#server-expose-key-value-options) | 다른 플러그인이 `server.plugins[name]`에서 읽는 값 | | -```javascript -'use strict'; +## 플러그인 만들기 -const myPlugin = { +플러그인은 `name`과 비동기 `register(server, options)` 함수를 가진 객체입니다. + +```ts +import type { Plugin } from '@hapi/hapi'; + +const myPlugin: Plugin = { name: 'myPlugin', version: '1.0.0', register: async function (server, options) { - // Create a route for example - server.route({ method: 'GET', path: '/test', @@ -32,23 +41,39 @@ const myPlugin = { return 'hello, world'; }, }); - - // etc ... - await someAsyncMethods(); }, }; ``` -또는 외부 모듈처럼 작성할 때, `pkg` 속성을 정의할 수 있습니다.: +등록하고 나면 `GET /test`가 `'hello, world'`로 응답합니다. -```javascript -'use strict'; +`Plugin`는 플러그인 옵션의 타입을 받습니다. 이 플러그인은 옵션이 없으므로 `Plugin`입니다. 플러그인에 타입을 지정하면 TypeScript가 `register`의 `server`와 `options`를 추론하고, 플러그인에 `name`이나 `pkg`가 있는지 검사합니다. -exports.plugin = { - pkg: require('./package.json'), - register: async function (server, options) { - // Create a route for example +플러그인 객체의 속성은 다음과 같습니다. + +| 속성 | 의미 | +| -------------- | ------------------------------------------------------------------------------------------------------ | +| `register` | 필수. 플러그인을 설정합니다. 등록은 이 함수를 기다리며, 함수가 에러를 던지면 실패합니다. | +| `name` | `pkg`가 없으면 필수. 서버에서 고유해야 합니다. 배포된 플러그인은 npm 패키지 이름을 사용합니다. | +| `version` | 참고용 정보. 다른 플러그인이 읽을 수 있고, [의존성](#dependencies)에서 버전 범위를 요구할 수 있습니다. | +| `pkg` | `name`과 `version`을 직접 쓰는 대신 사용하는 `package.json`. | +| `dependencies` | 함께 등록되어야 하는 플러그인. [의존성](#dependencies)을 참고하세요. | +| `requirements` | 지원하는 `node`와 `hapi` 버전 범위(예: `{ hapi: '>=21' }`). 범위를 벗어나면 등록이 실패합니다. | +| `multiple` | `true`면 같은 서버에 플러그인을 두 번 이상 등록할 수 있습니다. | +| `once` | `true`면 반복 등록이 에러 대신 아무 일도 하지 않습니다. | + +### 플러그인을 모듈로 배포하기 +플러그인 모듈은 플러그인 객체를 default export나 이름이 `plugin`인 named export로 내보냅니다. `pkg`는 `package.json`에서 이름과 버전을 읽습니다. + +```ts +import Pkg from './package.json' with { type: 'json' }; + +import type { Plugin } from '@hapi/hapi'; + +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { server.route({ method: 'GET', path: '/test', @@ -56,117 +81,151 @@ exports.plugin = { return 'hello, world'; }, }); - - // etc... - await someAsyncMethods(); }, }; ``` -첫 번째 예제에서 명확하게 `name`과 `version`을 설정했지만, 두 번째 예제에서는 값으로 package.json의 내용으로 `pkg` 인자를 설정했습니다. 두 방법 모두 가능합니다. +hapi는 두 export 형태를 모두 받습니다. `server.register()`는 플러그인 객체, 또는 `plugin` 속성이 플러그인 객체인 모듈을 받습니다. -모듈로 작성할 때, 플러그인은 예를 들어 `module.exports = { register, name, version }`처럼 최상위 모듈 내보내기 또는 hapi 플러그인 이상으로 내보내기 위한 모듈을 원한다면 `exports.plugin = { register, name, version }` 으로 쓸 수 있습니다. +### 플러그인 옵션 -추가적으로 플러그인 객체는 `multiple` 속성을 포함할 수 있습니다. 이 속성이 `true`로 설정되면 hapi에게 해당 플러그인이 같은 서버에서 여러번 등록되도 안전하다는 것을 알립니다. +`options`는 호출하는 쪽이 등록할 때 전달한 값 그대로입니다. 타입은 `Plugin`로 지정합니다. -다른 사용 가능한 속성은 `once`입니다. 이 속성이 `true`로 설정되면 hapi는 에러를 발생하지 않고 같은 플러그인의 두 번째 등록을 무시합니다. +```ts +import Pkg from './package.json' with { type: 'json' }; -### The register method +import type { Plugin } from '@hapi/hapi'; -위에서 본 것처럼 `register` 메소드는 `server`, `options` 2개의 인자를 받습니다. +interface Options { + name: string; +} -`options` 인자는 `server.register(plugin, options)` 호출할 때 전달한 모든 옵션을 플러그인에 전달합니다. 변경되는 것 없이 객체가 `register` 메소드로 바로 전달됩니다. +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return `Hello ${options.name}`; + }, + }); + }, +}; +``` -`register`는 플러그인이 등록될 때 필요한 모든 단계를 완료되면 반환하는 aync 함수이어야 합니다. 또는 플러그인을 등록하는 동안 에러가 발생하면 에러를 발생시켜야 합니다. +`options.name`은 `string`이며, 옵션이 `Options`와 맞지 않는 `server.register()` 호출은 TypeScript가 잡아냅니다. hapi는 런타임에 옵션을 검사하지 않습니다. -`server` 객체는 플러그인을 적재하는 `server`에 대한 참조입니다. +## 플러그인 불러오기 -## 플러그인 적재 +플러그인은 [`server.register()`](/api#await-server-register-plugins-options)로 등록합니다. 플러그인 자체, `plugin`과 `options`를 담은 객체, 또는 이들의 배열을 전달합니다. -플러그인은 `server.register()` 메소드로 한 번에 하나씩 또는 배열의 그룹으로 적재될 수 있습니다. 예를 들면 다음과 같습니다.: +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; +import { plugin as YourPlugin } from 'yourplugin'; -```javascript const start = async function () { - // load one plugin - - await server.register(require('myplugin')); + const server = Hapi.server(); - // load multiple plugins + await server.register(YourPlugin); - await server.register([require('myplugin'), require('yourplugin')]); -}; -``` - -플러그인에 옵션을 전달하기 위해서, `register`와 `options` 키를 가지는 객체를 다음과 같이 대신 전달합니다.: - -```javascript -const start = async function () { await server.register({ - plugin: require('myplugin'), + plugin: MyPlugin, options: { - message: 'hello', + name: 'Bob', }, }); + + await server.register([{ plugin: YourPlugin }, { plugin: MyPlugin, options: { name: 'Bob' } }]); }; ``` -이 객체들은 배열로 전달될 수도 있습니다.: +배열에 담긴 플러그인은 순서대로 하나씩 등록됩니다. 각 `register` 함수가 끝난 뒤에 다음 함수가 시작됩니다. + +### 등록 옵션 + +`server.register()`의 두 번째 인자는 hapi가 플러그인을 등록하는 방식을 설정합니다. 이 인자는 플러그인에 전달되지 _않습니다_. + +| 옵션 | 효과 | +| --------------- | ----------------------------------------------------------------------------------------- | +| `routes.prefix` | 플러그인이 추가하는 모든 라우트 앞에 경로를 붙입니다. `/test`는 `/plugins/test`가 됩니다. | +| `routes.vhost` | 플러그인이 추가하는 모든 라우트에 가상 호스트를 적용합니다. | +| `once` | `true`면 이미 등록된 플러그인을 에러 없이 건너뜁니다. | + +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; -```javascript const start = async function () { - await server.register([ - { - plugin: require('plugin1'), - options: {}, - }, - { - plugin: require('plugin2'), - options: {}, + const server = Hapi.server(); + + await server.register(MyPlugin, { + routes: { + prefix: '/plugins', }, - ]); + }); }; ``` -### 등록 옵션 +prefix는 중첩됩니다. prefix `/api`로 등록된 플러그인이 prefix `/v1`로 하위 플러그인을 등록하면, 하위 플러그인의 라우트는 `/api/v1`로 시작합니다. 전체 목록은 [`server.register()`](/api#await-server-register-plugins-options)를 참고하세요. + +## 플러그인이 영향을 줄 수 있는 범위 + +각 플러그인은 자신만의 [`server.realm`](/api#server-realm)을 가집니다. realm은 플러그인 안에만 머무는 설정을 담는 컨테이너입니다. 그 밖에 플러그인이 하는 모든 일은 서버 전체를 바꿉니다. + +| 변경 | 범위 | +| --------------------------------------------------------------------------------------------- | -------------------------------------- | +| 등록 옵션의 라우트 prefix와 vhost | 플러그인의 라우트와 하위 플러그인 | +| [`server.bind()`](/api#server-bind-context), [`server.path()`](/api#server-path-relativeto) | 플러그인이 추가한 라우트, 핸들러, 확장 | +| `options.sandbox: 'plugin'`을 지정한 요청 [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks) | 플러그인의 라우트 | +| `sandbox`를 지정하지 않은 요청 라이프사이클 훅 | **서버의 모든 라우트** | +| [데코레이션](/tutorials/ko_KR/decorators), 서버 메서드, 인증 전략, `server.auth.default()` | **서버 전체** | + +자신의 에러 형식을 맞추려고 `onPreResponse` 훅을 추가하는 플러그인은 `sandbox: 'plugin'`을 지정해야 합니다. 그렇지 않으면 다른 모든 플러그인의 에러 형식까지 바꿔 버립니다. -두 번째 선택적인 인자를 `server.register()`에 전달할 수 있습니다. 이 객체에 대한 문서는 [API reference](/api#-await-serverregisterplugins-options)에서 찾을 수 있습니다. +## 의존성 -options 객체는 로드될 플러그인에 전달되지 _않고_ hapi에서 사용됩니다. 이는 플러그인이 등록된 어떤 경로에 `vhost` 또는 `prefix` 수정자를 적용할 수 있습니다. +다른 플러그인이 필요한 플러그인은 등록 순서에 기대는 대신 의존성을 선언합니다. -예를 들면 다음과 같은 플러그인이 있다고 가정합니다.: +```ts +import type { Plugin } from '@hapi/hapi'; -```javascript -'use strict'; +declare module '@hapi/hapi' { + interface PluginProperties { + database: { migrate: () => Promise }; + } +} -exports.plugin = { - pkg: require('./package.json'), +export const plugin: Plugin = { + name: 'dashboard', + dependencies: ['@hapi/vision', 'auth'], register: async function (server, options) { - server.route({ - method: 'GET', - path: '/test', - handler: function (request, h) { - return 'test passed'; - }, + server.dependency('database', async (server) => { + await server.plugins.database.migrate(); }); - - // etc... - await someAsyncMethods(); }, }; ``` -보통 이 플러그인이 로드되면 `/test`에 `GET` 경로가 생성됩니다. options에 `prefix`를 사용하면 플러그인에서 생성한 모든 경로에 문자열이 추가는 것으로 변경됩니다. - -```javascript -const start = async function () { - await server.register(require('myplugin'), { - routes: { - prefix: '/plugins', - }, - }); -}; +hapi는 플러그인을 등록할 때가 아니라 서버를 초기화할 때 의존성을 검사하므로, 플러그인은 어떤 순서로든 등록할 수 있습니다. + +```mermaid +sequenceDiagram + participant A as 여러분의 앱 + participant S as 서버 + A->>S: register(dashboard) + Note over S: 기록: dashboard에는 @hapi/vision, auth, database가 필요 + A->>S: register(vision, auth, database) + A->>S: initialize() 또는 start() + Note over S: 1. 의존성이 없으면 여기서 실패 + Note over S: 2. 캐시 클라이언트 시작 + Note over S: 3. onPreStart: server.dependency() 콜백 실행 + Note over S: 4. start()만: 서버가 수신 시작 ``` -이제 플러그인이 로드될 때 `prefix` 옵션 때문에 `/plugins/test`에 `GET` 경로가 만들어질 것입니다. +- `dependencies`는 이름의 목록이거나, `{ '@hapi/vision': '7.x' }`처럼 이름과 버전 범위를 짝지은 객체입니다. +- [`server.dependency(names, after)`](/api#server-dependency-dependencies-after)는 같은 내용을 `register` 안에서 선언하며, `after` 함수를 받습니다. hapi는 나열된 플러그인이 모두 등록된 뒤 서버 시작 중에 `after`를 실행합니다. 다른 플러그인의 API가 필요한 설정 작업에 사용하세요. +- `after` 의존성이 순환하면 hapi가 에러를 던집니다. -비슷하게 `options.routes.vhost` 인자는 플러그인이 로드될 때 생성된 경로의 기본 `vhost`를 설정합니다. `vhost` 설정에 대한 자세한 내용은 [API reference](/api#-serverrouteroute)에서 찾을 수 있습니다. +`server.plugins.database`는 `database` 플러그인이 [`server.expose()`](/api#server-expose-key-value-options)로 공개한 값입니다. hapi는 그 형태를 알 수 없으므로, `declare module` 블록으로 hapi의 `PluginProperties` 인터페이스에 추가합니다. diff --git a/docs/tutorials/pt_BR/plugins.md b/docs/tutorials/pt_BR/plugins.md index 2fbcb3e6..3c75d0db 100644 --- a/docs/tutorials/pt_BR/plugins.md +++ b/docs/tutorials/pt_BR/plugins.md @@ -4,221 +4,228 @@ title: Plugins -## Plugins +# Plugins -hapi possui um poderoso e extensível sistema de plugins que permite que você facilmente divida sua aplicação em pedaços isolados contendo lógica de negócios, e utilitários reutilizáveis. +_Este tutorial é compatível com hapi v21 e versões mais recentes_ -## Criando um plugin +## Visão geral -Plugins são muito fáceis de escrever. Em sua essência eles são um objeto contendo uma função `register` com a seguinte declaração `function (server, options, next)`. Essa função `register` possui também um objeto `attributes` em sua estrutura que fornece ao hapi informações adicionais sobre o plugin, como o nome e a versão. +Um plugin é uma parte de uma aplicação hapi empacotada para ser registrada em um servidor: um conjunto de rotas, um scheme de autenticação, um logger, um template engine. A maior parte do ecossistema hapi é publicada como plugins ([inert](/module/inert), [vision](/module/vision), [@hapi/basic](/module/basic)), e dividir sua própria aplicação em plugins mantém cada parte isolada e testável. -Um plugin básico pode ser definido da seguinte forma: +Um plugin recebe o servidor na sua função `register` e o altera pela API normal do servidor. As que mais importam para quem escreve plugins: -```javascript -'use strict'; +| API do servidor | O que o plugin adiciona | Tutorial | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| [`server.route()`](/api#server-route-route) | rotas | [Roteamento](/tutorials/pt_BR/routing) | +| [`server.ext()`](/api#server-ext-events) | código que roda em uma etapa de toda requisição, ou na inicialização e parada do servidor | [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks) | +| [`server.decorate()`](/api#server-decorate-type-property-method-options) | novos métodos em `server`, `request` ou `h` | [Decorators](/tutorials/pt_BR/decorators) | +| [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) | um scheme de autenticação | [Autenticação](/tutorials/pt_BR/auth) | +| [`server.method()`](/api#server-method-name-method-options) | funções compartilhadas e com cache | [Métodos de servidor](/tutorials/pt_BR/server-methods) | +| [`server.expose()`](/api#server-expose-key-value-options) | valores que outros plugins leem em `server.plugins[name]` | | -const myPlugin = { - register: function (server, options, next) { - next(); - }, -}; - -myPlugin.register.attributes = { - name: 'myPlugin', - version: '1.0.0', -}; -``` +## Criando um plugin -Ou quando escrito como módulo externo: +Um plugin é um objeto com um `name` e uma função assíncrona `register(server, options)`: -```javascript -'use strict'; +```ts +import type { Plugin } from '@hapi/hapi'; -exports.register = function (server, options, next) { - next(); -}; - -exports.register.attributes = { - pkg: require('./package.json'), +const myPlugin: Plugin = { + name: 'myPlugin', + version: '1.0.0', + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return 'hello, world'; + }, + }); + }, }; ``` -Note que no primeiro exemplo, nós definimos a propriedade `name` e `version` especificamente, entretanto no segundo exemplo nós definimos um atributo `pkg` com o conteúdo do package.json e os seus valores. Ambos os métodos são aceitáveis. - -Adicionamente, o objeto `attributes` pode conter uma chave `multiple` que quando definida como `true` informa ao hapi que é seguro registrar o plugin mais de uma vez no mesmo servidor. +Depois de registrado, `GET /test` responde `'hello, world'`. -### O método `register` +`Plugin` recebe o tipo das opções do plugin. Este plugin não tem opções, então é `Plugin`. Com o plugin tipado, o TypeScript infere `server` e `options` em `register` e verifica se o plugin tem um `name` ou um `pkg`. -Como nós vimos anteriormente, o método `register` aceita três parâmetro, `server`, `options`, e `next`. +As propriedades do objeto de plugin: -O parâmetro `options` é simplesmente qualquer opção que o usuário passa para o seu plugin. Nenhuma mudança é feita e o objeto é passado diretamente para o seu método `register`. +| Propriedade | Significado | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `register` | Obrigatória. Configura o plugin. O registro espera por ela e falha se ela lançar um erro. | +| `name` | Obrigatória, a menos que `pkg` esteja definido. Precisa ser única no servidor. Plugins publicados usam o nome do pacote npm. | +| `version` | Informativa. Outros plugins podem lê-la, e as [dependências](#dependencies) podem exigir uma faixa de versões. | +| `pkg` | Seu `package.json`, usado para `name` e `version` em vez de defini-los à mão. | +| `dependencies` | Plugins que também precisam estar registrados. Veja [dependências](#dependencies). | +| `requirements` | Faixas de versão suportadas de `node` e `hapi`, como `{ hapi: '>=21' }`. O registro falha fora delas. | +| `multiple` | `true` permite registrar o plugin mais de uma vez no mesmo servidor. | +| `once` | `true` faz registros repetidos serem ignorados em silêncio em vez de gerar um erro. | -`next` é o método a ser chamado quando o seu plugin já finalizou todas as atividades necessárias para estar registrado. Esse método aceita somente um único parâmetro, `err`, que deve somente ser definido se algum erro aconteceu no processo de registro do seu plugin. +### Publicando um plugin como módulo -O objeto `server` é uma referência ao `server` que está carregando e registrando o seu plugin. +Um módulo de plugin exporta o objeto de plugin, como export default ou como um export nomeado `plugin`. `pkg` lê o nome e a versão do `package.json`: -#### `server.select()` +```ts +import Pkg from './package.json' with { type: 'json' }; -Servidores podem ter conexões adicionadas com um label definido à elas: +import type { Plugin } from '@hapi/hapi'; -```javascript -const server = new Hapi.Server(); -server.connection({ labels: ['api'] }); +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return 'hello, world'; + }, + }); + }, +}; ``` -Esse label pode então ser utilizado para aplicar plugins e outras configuração somente para conexões específicas utilizando o método `server.select()`. +O hapi aceita os dois formatos de export: `server.register()` recebe um objeto de plugin, ou um módulo cuja propriedade `plugin` seja um. -Por exemplo, para adicionar uma rota somente para as conexões com o label `'api'`, você utilizaria: +### Opções do plugin -```javascript -const api = server.select('api'); +`options` é o que quer que quem registra passe no registro, sem alterações. Tipe com `Plugin`: -api.route({ - method: 'GET', - path: '/', - handler: function (request, reply) { - reply('api index'); - }, -}); -``` +```ts +import Pkg from './package.json' with { type: 'json' }; -Múltiplos labels podem ser seleciondos ao mesmo tempo passando um array de strings, a seleção é feita com uma lógica OU(OR). -Para definir uma lógica E(AND), você pode realizar chamadas sucessivas ao `server.select()` da seguinte forma: +import type { Plugin } from '@hapi/hapi'; -```javascript -// todos os servidores com a label backend OU api -const myServers = server.select(['backend', 'api']); +interface Options { + name: string; +} -// servidores com a label api E admin -const adminServers = server.select('api').select('admin'); +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return `Hello ${options.name}`; + }, + }); + }, +}; ``` -O método `server.select()` retorna um objeto server contendo somente as conexões selecionadas. +`options.name` é uma `string`, e o TypeScript aponta uma chamada a `server.register()` cujas opções não batem com `Options`; o hapi não as verifica em tempo de execução. -## Carregando um plugin +## Carregando um plugin -Plugins podem ser carregados individualmente, ou como parte de um grupo definido em um array, pelo método `server.register()`, por exemplo: +Registre plugins com [`server.register()`](/api#await-server-register-plugins-options). Passe o próprio plugin, ou um objeto com `plugin` e `options`, ou um array de qualquer um dos dois: -```javascript -// carrega um plugin -server.register(require('myplugin'), (err) => { - if (err) { - console.error('Failed to load plugin:', err); - } -}); +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; +import { plugin as YourPlugin } from 'yourplugin'; -// carrega múltiplos plugins -server.register([require('myplugin'), require('yourplugin')], (err) => { - if (err) { - console.error('Failed to load a plugin:', err); - } -}); -``` +const start = async function () { + const server = Hapi.server(); -Para passar opções para o seu plugin, cria-se um objeto com as chaves `register` e `options`, por exemplo: + await server.register(YourPlugin); -```javascript -server.register( - { - register: require('myplugin'), + await server.register({ + plugin: MyPlugin, options: { - message: 'hello', + name: 'Bob', }, - }, - (err) => { - if (err) { - throw err; - } - }, -); -``` - -Esses objetos podem também ser passados como itens de um array + }); -```javascript -server.register( - [ - { - register: require('plugin1'), - options: {}, - }, - { - register: require('plugin2'), - options: {}, - }, - ], - (err) => { - if (err) { - throw err; - } - }, -); + await server.register([{ plugin: YourPlugin }, { plugin: MyPlugin, options: { name: 'Bob' } }]); +}; ``` -### Opções dos plugins +Os plugins de um array são registrados um depois do outro, em ordem. Cada função `register` termina antes de a próxima começar. + +### Opções de registro -Você pode também passar um parâmetro opcional para `server.register()` antes do callback. Documentação para esse objeto pode ser encontrado [API reference](/api#serverregisterplugins-options-callback). +O segundo argumento de `server.register()` configura como o hapi registra o plugin. Ele _não_ é passado ao plugin: -O objeto de opções é utilizado pelo hapi e _não_ é passado para o plugin(s) sendo carregado(s). Isso permite que você pré-selecione os servidores baseados em uma ou mais labels, como também aplique modificadores `vhost` ou `prefix` em qualquer rota que o seu plugin registrar. +| Opção | Efeito | +| --------------- | ----------------------------------------------------------------------------------------------- | +| `routes.prefix` | Adiciona um caminho no início de toda rota que o plugin adiciona. `/test` vira `/plugins/test`. | +| `routes.vhost` | Aplica um virtual host a toda rota que o plugin adiciona. | +| `once` | `true` pula o plugin se ele já estiver registrado, em vez de lançar um erro. | -Por exemplo, vamos dizer que temos um plugin definido da seguinte forma: +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; -```javascript -'use strict'; +const start = async function () { + const server = Hapi.server(); -exports.register = function (server, options, next) { - server.route({ - method: 'GET', - path: '/test', - handler: function (request, reply) { - reply('test passed'); + await server.register(MyPlugin, { + routes: { + prefix: '/plugins', }, }); - - next(); -}; - -exports.register.attributes = { - pkg: require('./package.json'), }; ``` -Normalmente, quando esse plugin é carregado ele cria uma rota `GET` em `/test`. Isso pode ser alterado utilizando a configuração `prefix` do objeto de opcões, que irá concatenar uma string como prefixo para todas as rotas definidas por esse plugin: +Os prefixos se acumulam: se um plugin registrado com prefixo `/api` registra um plugin filho com prefixo `/v1`, as rotas do filho começam com `/api/v1`. Veja [`server.register()`](/api#await-server-register-plugins-options) para a lista completa. -```javascript -server.register( - { register: require('myplugin') }, - { - routes: { - prefix: '/plugins', - }, - }, - (err) => { - if (err) { - throw err; - } - }, -); -``` +## O que um plugin pode e não pode afetar -Agora, quando este plugin for carregado, por causa da opção `prexix` a rota `GET` será criada em `/plugins/test`. +Cada plugin recebe seu próprio [`server.realm`](/api#server-realm): um contêiner para configurações que ficam dentro do plugin. Todo o resto que um plugin faz muda o servidor inteiro: -De forma análoga o parâmetro `options.routes.vhost` irá designar uma configuração `vhost` padrão para qualquer rota criada pelo plugin sendo carregado. Mais detalhes sobre a configuração `vhost` pode ser encontrado na [API reference](/api#route-options). +| Mudança | Escopo | +| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| prefixo de rota e vhost vindos das opções de registro | as rotas do plugin e seus plugins filhos | +| [`server.bind()`](/api#server-bind-context), [`server.path()`](/api#server-path-relativeto) | rotas, handlers e extensões adicionados pelo plugin | +| [lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks) de requisição com `options.sandbox: 'plugin'` | as rotas do plugin | +| lifecycle hooks de requisição sem `sandbox` | **todas as rotas do servidor** | +| [decorações](/tutorials/pt_BR/decorators), métodos de servidor, strategies de autenticação, `server.auth.default()` | **o servidor inteiro** | -O parâmetro `select` funciona exatamente do mesmo jeito que `server.select()` funciona, de forma que você pode passar uma label ou um array de labels para o plugin para o plugins se associar. +Um plugin que adiciona um hook `onPreResponse` para formatar os próprios erros deve passar `sandbox: 'plugin'`, senão vai reformatar também os erros de todos os outros plugins. -```javascript -server.register( - { register: require('myplugin') }, - { - select: ['webserver', 'admin'], - }, - (err) => { - if (err) { - throw err; - } +## Dependências + +Um plugin que precisa de outro plugin o declara, em vez de depender da ordem de registro: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginProperties { + database: { migrate: () => Promise }; + } +} + +export const plugin: Plugin = { + name: 'dashboard', + dependencies: ['@hapi/vision', 'auth'], + register: async function (server, options) { + server.dependency('database', async (server) => { + await server.plugins.database.migrate(); + }); }, -); +}; ``` -Isso permite que você defina plugins para conexões específicas em um server sem precisar alterar o código do plugin. +O hapi verifica as dependências quando o servidor inicializa, não quando o plugin é registrado, então os plugins podem ser registrados em qualquer ordem: + +```mermaid +sequenceDiagram + participant A as sua aplicação + participant S as servidor + A->>S: register(dashboard) + Note over S: registra: dashboard precisa de @hapi/vision, auth, database + A->>S: register(vision, auth, database) + A->>S: initialize() ou start() + Note over S: 1. falha aqui se faltar uma dependência + Note over S: 2. inicia os cache clients + Note over S: 3. onPreStart: roda o callback de server.dependency() + Note over S: 4. só em start(): o servidor começa a escutar +``` + +- `dependencies` é uma lista de nomes, ou um objeto que mapeia nomes para faixas de versão, como `{ '@hapi/vision': '7.x' }`. +- [`server.dependency(names, after)`](/api#server-dependency-dependencies-after) declara a mesma coisa de dentro de `register` e aceita uma função `after`. O hapi roda `after` durante a inicialização do servidor, quando todos os plugins listados estão registrados. Use para configurações que precisam da API de outro plugin. +- O hapi lança um erro em dependências `after` circulares. + +`server.plugins.database` é o que o plugin `database` publicou com [`server.expose()`](/api#server-expose-key-value-options). O hapi não tem como saber o formato disso, então o bloco `declare module` o adiciona à interface `PluginProperties` do hapi. diff --git a/docs/tutorials/tr_TR/plugins.md b/docs/tutorials/tr_TR/plugins.md index 946e5ffe..4767b770 100644 --- a/docs/tutorials/tr_TR/plugins.md +++ b/docs/tutorials/tr_TR/plugins.md @@ -1,30 +1,39 @@ --- -title: Plugins +title: Plugin'ler --- -## Eklentiler +# Plugin'ler -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -hapinin uygulamanı kolayca ayrıştırılmış (isolated) işletme mantığı ve tekrar kullanılabilir hizmetlere bölebileceğin kapsamlı ve güçlü bir eklenti sistemi bulunur. +## Genel Bakış -## Bir eklenti oluşturmak +Plugin, bir hapi uygulamasının bir sunucuya kaydedilebilecek şekilde paketlenmiş bir parçasıdır: bir route kümesi, bir kimlik doğrulama şeması, bir logger, bir şablon motoru. hapi ekosisteminin çoğu plugin olarak yayınlanır ([inert](/module/inert), [vision](/module/vision), [@hapi/basic](/module/basic)); kendi uygulamanı plugin'lere bölmek de her parçayı yalıtılmış ve test edilebilir tutar. -Eklenti yazmak çok kolay. Aslında yalnızca `register` (kaydet) özelliği olan bir nesneler. Bu özellik ise `async function (server, options)` imzası olan bir işlev. Bir de zorunlu olan `name` (isim) özelliği ve `versiyon` gibi bir çok seçime bağlı özelliği var. +Bir plugin, sunucuyu `register` fonksiyonunda alır ve onu normal sunucu API'siyle değiştirir. Plugin yazarları için en önemlileri: -Çok basit bir eklenti şöyle görünür: +| Sunucu API'si | Plugin'in eklediği | Eğitim | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------- | +| [`server.route()`](/api#server-route-route) | route'lar | [Routing](/tutorials/tr_TR/routing) | +| [`server.ext()`](/api#server-ext-events) | her isteğin bir adımında ya da sunucu başlarken ve dururken çalışan kod | [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks) | +| [`server.decorate()`](/api#server-decorate-type-property-method-options) | `server`, `request` ya da `h` üzerinde yeni metotlar | [Decorator'lar](/tutorials/tr_TR/decorators) | +| [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) | bir kimlik doğrulama şeması | [Kimlik Doğrulama](/tutorials/tr_TR/auth) | +| [`server.method()`](/api#server-method-name-method-options) | paylaşılan, önbelleğe alınabilir fonksiyonlar | [Server Method'lar](/tutorials/tr_TR/server-methods) | +| [`server.expose()`](/api#server-expose-key-value-options) | diğer plugin'lerin `server.plugins[name]` üzerinden okuduğu değerler | | -```javascript -'use strict'; +## Plugin oluşturmak -const myPlugin = { +Plugin, bir `name` ve asenkron bir `register(server, options)` fonksiyonu olan bir nesnedir: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const myPlugin: Plugin = { name: 'myPlugin', version: '1.0.0', register: async function (server, options) { - // Örnek bir yol yaratalım - server.route({ method: 'GET', path: '/test', @@ -32,23 +41,39 @@ const myPlugin = { return 'hello, world'; }, }); - - // vesaire... - await someAsyncMethods(); }, }; ``` -Ya da harici bir modül olarak yazıldıklarında `pkg` özelliği belirleyebilirsin: +Kaydedildikten sonra `GET /test`, `'hello, world'` yanıtını verir. -```javascript -'use strict'; +`Plugin`, plugin seçeneklerinin tipini alır. Bu plugin'in seçeneği yoktur, bu yüzden tipi `Plugin` olur. Plugin tiplendiğinde TypeScript `register` içindeki `server` ve `options` tiplerini çıkarır ve plugin'in bir `name` ya da `pkg` değerine sahip olduğunu denetler. -exports.plugin = { - pkg: require('./package.json'), - register: async function (server, options) { - // Örnek bir yol yaratalım +Plugin nesnesinin özellikleri: + +| Özellik | Anlamı | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| `register` | Zorunlu. Plugin'i kurar. Kayıt onu bekler ve hata fırlatırsa başarısız olur. | +| `name` | `pkg` ayarlanmadıysa zorunlu. Sunucuda benzersiz olmalıdır. Yayınlanan plugin'ler npm paket adlarını kullanır. | +| `version` | Bilgi amaçlı. Diğer plugin'ler onu okuyabilir ve [bağımlılıklar](#dependencies) bir sürüm aralığı isteyebilir. | +| `pkg` | `name` ve `version` değerlerini elle ayarlamak yerine kullanılan `package.json` dosyan. | +| `dependencies` | Kaydedilmiş olması da gereken plugin'ler. Bkz. [bağımlılıklar](#dependencies). | +| `requirements` | Desteklenen `node` ve `hapi` sürüm aralıkları; örneğin `{ hapi: '>=21' }`. Bunların dışında kayıt başarısız olur. | +| `multiple` | `true`, plugin'in aynı sunucuya birden fazla kez kaydedilmesine izin verir. | +| `once` | `true`, tekrarlanan kayıtları hata yerine sessizce hiçbir şey yapmayan işlemlere dönüştürür. | + +### Bir plugin'i modül olarak yayınlamak +Bir plugin modülü plugin nesnesini default export olarak ya da `plugin` adında bir named export olarak dışa aktarır. `pkg`, adı ve sürümü `package.json` dosyasından okur: + +```ts +import Pkg from './package.json' with { type: 'json' }; + +import type { Plugin } from '@hapi/hapi'; + +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { server.route({ method: 'GET', path: '/test', @@ -56,117 +81,151 @@ exports.plugin = { return 'hello, world'; }, }); - - // vesaire... - await someAsyncMethods(); }, }; ``` -Birinci örnekte `name` (isim) ve `version` (versiyon) özelliklerini açıkça belirtirken ikinci örnekte `pkg` özelliğini içeriği package.json olacak şekilde gönderdiğimize dikkat et. İki şekilde de çalışır. +hapi her iki export biçimini de kabul eder: `server.register()` bir plugin nesnesi ya da `plugin` özelliği bir plugin nesnesi olan bir modül alır. -Bir modül olarak yazıldığında, bir eklenti ya üst seviye bir modül çıktısı örn: `module.exports = { register, name, version }` ya da modülün bir hapi eklentisinden fazlasını çıkartsın istersen `exports.plugin = { register, name, version }` olabilir. +### Plugin seçenekleri -Ayrıca, eklenti nesnesi değeri `true` olan bir `multiple` (çoklu) özelliği içererek hapiye bu eklentinin bir sunucuya birden fazla kez kaydedilmesinin güvenilir olduğunu söyleyebilir. +`options`, çağıranın kayıt sırasında verdiği değerin değiştirilmemiş hâlidir. Tipini `Plugin` ile belirt: -Kullanılabilir bir diğer özellik ise `once`tır (bir kez). `true` olarak ayarlandığında mükerrer kayıtlar hapi tarafından hata fırlatmaksızın görmezden gelinecektir. +```ts +import Pkg from './package.json' with { type: 'json' }; -### Kaydetme (register) Yontemi +import type { Plugin } from '@hapi/hapi'; -Yukarıda gördüğümüz üzere, `register` (kaydet) yöntemi iki değiştirge kabul eder: `server` ve `options`. +interface Options { + name: string; +} -`options` (seçenekler) değiştirgesi basitçe kullanıcının `server.register(plugin, options)` yöntemini çağırırken eklentine gönderdiği seçeneklerdir. Nesne olduğu gibi yazdığın `register`(kaydet) yöntemine gönderilir. +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return `Hello ${options.name}`; + }, + }); + }, +}; +``` -`register`, eklentinin kaydedilmesi için gerekli adımlar tamamlandıktan sonra async (asenkron) olarak sonuç dönen bir işlev olmalıdır. Alternatif olarak eklentin, eğer kayıt işlemi sırasında bir sorun oluşursa hata fırlatmalıdır. +`options.name` bir `string` olur ve TypeScript, seçenekleri `Options` ile eşleşmeyen bir `server.register()` çağrısını işaretler; hapi bunları çalışma zamanında denetlemez. -`server` (sunucu) nesnesi eklentinin kurulduğu sunucuya bir referanstır. +## Plugin yüklemek -## Bir eklentiyi yüklemek +Plugin'leri [`server.register()`](/api#await-server-register-plugins-options) ile kaydet. Plugin'in kendisini, `plugin` ve `options` içeren bir nesneyi ya da bunlardan oluşan bir diziyi ver: -Eklentiler `server.register()` yöntemiyle tek tek ya da bir grup olarak bir dizinin içinde yüklenebilir. Örneğin: +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; +import { plugin as YourPlugin } from 'yourplugin'; -```javascript const start = async function () { - // bir eklenti yükle - - await server.register(require('myplugin')); + const server = Hapi.server(); - // birden fazla eklenti yükle + await server.register(YourPlugin); - await server.register([require('myplugin'), require('yourplugin')]); -}; -``` - -Eklentine seçenekler göndermek için, bunun yerine `plugin` (eklenti) ve `options` (seçenekler) anahtarlarıyla birer nesne gönderiyoruz. Mesela: - -```javascript -const start = async function () { await server.register({ - plugin: require('myplugin'), + plugin: MyPlugin, options: { - message: 'hello', + name: 'Bob', }, }); + + await server.register([{ plugin: YourPlugin }, { plugin: MyPlugin, options: { name: 'Bob' } }]); }; ``` -Bu nesneler bir dizi içinde de gönderilebilir: +Bir dizideki plugin'ler sırayla, birbiri ardına kaydedilir. Her `register` fonksiyonu bir sonraki başlamadan önce tamamlanır. + +### Kayıt seçenekleri + +`server.register()` çağrısının ikinci argümanı, hapi'nin plugin'i nasıl kaydedeceğini yapılandırır. Plugin'e _verilmez_: + +| Seçenek | Etkisi | +| --------------- | ------------------------------------------------------------------------------------ | +| `routes.prefix` | Plugin'in eklediği her route'un başına bir yol ekler. `/test`, `/plugins/test` olur. | +| `routes.vhost` | Plugin'in eklediği her route'a bir virtual host uygular. | +| `once` | `true`, plugin zaten kayıtlıysa hata fırlatmak yerine onu atlar. | + +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; -```javascript const start = async function () { - await server.register([ - { - plugin: require('plugin1'), - options: {}, - }, - { - plugin: require('plugin2'), - options: {}, + const server = Hapi.server(); + + await server.register(MyPlugin, { + routes: { + prefix: '/plugins', }, - ]); + }); }; ``` -### Kayit secenekleri +Önekler iç içe geçer: `/api` önekiyle kaydedilmiş bir plugin, `/v1` önekiyle bir alt plugin kaydederse alt plugin'in route'ları `/api/v1` ile başlar. Tam liste için [`server.register()`](/api#await-server-register-plugins-options) sayfasına bak. + +## Bir plugin neyi etkileyebilir, neyi etkileyemez + +Her plugin kendi [`server.realm`](/api#server-realm) değerini alır: plugin'in içinde kalan ayarlar için bir kapsayıcı. Bir plugin'in yaptığı diğer her şey sunucunun tamamını değiştirir: + +| Değişiklik | Kapsam | +| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| kayıt seçeneklerinden gelen route öneki ve vhost | plugin'in route'ları ve alt plugin'leri | +| [`server.bind()`](/api#server-bind-context), [`server.path()`](/api#server-path-relativeto) | plugin'in eklediği route'lar, handler'lar ve extension'lar | +| `options.sandbox: 'plugin'` ile istek [yaşam döngüsü hook'ları](/tutorials/tr_TR/lifecycle-hooks) | plugin'in route'ları | +| `sandbox` olmadan istek yaşam döngüsü hook'ları | **sunucudaki her route** | +| [decoration'lar](/tutorials/tr_TR/decorators), server method'lar, auth stratejileri, `server.auth.default()` | **sunucunun tamamı** | + +Kendi hatalarını biçimlendirmek için bir `onPreResponse` hook'u ekleyen bir plugin `sandbox: 'plugin'` vermelidir; yoksa diğer tüm plugin'lerin hatalarını da yeniden biçimlendirir. -`server.register()`a isteğe bağlı ikinci bir değiştirge de gönderebilirsin. Bu nesnenin dokümantasyonu [API referansı](/api#-await-serverregisterplugins-options)nda bulunabilir. +## Bağımlılıklar -Seçenekler nesnesi hapi tarafından klentinin yüklendiği yollara (route) `vhost` ya da `prefix` değiştirgeçlerini göndermen için kullanılır ve yüklenen eklentilerle paylaşılmaz. +Başka bir plugin'e ihtiyaç duyan bir plugin, kayıt sırasına güvenmek yerine bunu bildirir: -Farzı mahal şöyle bir eklentimiz olsun: +```ts +import type { Plugin } from '@hapi/hapi'; -```javascript -'use strict'; +declare module '@hapi/hapi' { + interface PluginProperties { + database: { migrate: () => Promise }; + } +} -exports.plugin = { - pkg: require('./package.json'), +export const plugin: Plugin = { + name: 'dashboard', + dependencies: ['@hapi/vision', 'auth'], register: async function (server, options) { - server.route({ - method: 'GET', - path: '/test', - handler: function (request, h) { - return 'test passed'; - }, + server.dependency('database', async (server) => { + await server.plugins.database.migrate(); }); - - // vesaire... - await someAsyncMethods(); }, }; ``` -Normalde bu eklenti yüklendiğinde `/test`e bir `GET` yolu oluşturur. Ancak seçeneklerdeki `prefix` (ön ek) ayarı kullanılarak bu eklenti içerisinde oluşturulan tüm yollara belirli bir ön ek eklenebilir: - -```javascript -const start = async function () { - await server.register(require('myplugin'), { - routes: { - prefix: '/plugins', - }, - }); -}; +hapi bağımlılıkları plugin kaydedilirken değil, sunucu başlatılırken (initialize) denetler; bu yüzden plugin'ler herhangi bir sırayla kaydedilebilir: + +```mermaid +sequenceDiagram + participant A as uygulaman + participant S as sunucu + A->>S: register(dashboard) + Note over S: kaydeder: dashboard için @hapi/vision, auth, database gerekli + A->>S: register(vision, auth, database) + A->>S: initialize() ya da start() + Note over S: 1. bir bağımlılık eksikse burada başarısız olur + Note over S: 2. cache client'larını başlatır + Note over S: 3. onPreStart: server.dependency() callback'ini çalıştırır + Note over S: 4. yalnızca start(): sunucu dinlemeye başlar ``` -`prefix` (ön ek) seçeneği sebebiyle artık bu eklenti yüklendiğinde `GET` yolu (route) `/plugins/test` olarak oluşacak. +- `dependencies` bir ad listesi ya da adları `{ '@hapi/vision': '7.x' }` gibi sürüm aralıklarına eşleyen bir nesnedir. +- [`server.dependency(names, after)`](/api#server-dependency-dependencies-after) aynı şeyi `register` içinden bildirir ve bir `after` fonksiyonu kabul eder. hapi `after` fonksiyonunu sunucu başlarken, listelenen her plugin kaydedildikten sonra çalıştırır. Bunu başka bir plugin'in API'sine ihtiyaç duyan kurulum işleri için kullan. +- hapi döngüsel `after` bağımlılıklarında hata fırlatır. -Aynı şekilde `options.routes.vhost` özelliği yüklenen eklentiler tarafından oluşturulan tüm yollara (route) varsayılan bir sanal sunucu `vhost` atar. `vhost` ayarı hakkında daha fazla bilgi [API referansı](/api#-serverrouteroute)nda bulunabilir. +`server.plugins.database`, `database` plugin'inin [`server.expose()`](/api#server-expose-key-value-options) ile yayınladığı değerdir. hapi onun yapısını bilemez, bu yüzden `declare module` bloğu onu hapi'nin `PluginProperties` arayüzüne ekler. diff --git a/docs/tutorials/zh_CN/plugins.md b/docs/tutorials/zh_CN/plugins.md index 9ca5fd59..86bc9efa 100644 --- a/docs/tutorials/zh_CN/plugins.md +++ b/docs/tutorials/zh_CN/plugins.md @@ -1,30 +1,39 @@ --- -title: Plugins +title: 插件 --- -## 插件 +# 插件 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更新版本_ -hapi 拥有一个可扩展并且强健的插件系统,它允许你将应用分割为独立的业务逻辑或者可重用的组件。 +## 概述 -## 创建一个插件 +插件是 hapi 应用中经过打包、可以注册到服务器上的一部分:一组路由、一个身份认证 scheme、一个日志记录器、一个模板引擎。hapi 生态的大部分内容都以插件的形式发布([inert](/module/inert)、[vision](/module/vision)、[@hapi/basic](/module/basic)),把你自己的应用拆分成插件,可以让每个部分彼此隔离、便于测试。 -插件很容易去实现,它的核心是一个拥有 `register` 属性的对象。属性的值是一个拥有 `async function (server, options)` 签名的函数。 除此之外,插件必须拥有 `name` 属性以及多个包括 `version` 在内的可选属性。 +插件在其 `register` 函数中接收服务器,并通过常规的服务器 API 修改它。对插件作者来说最重要的几个 API: -一个简单的插件如下: +| 服务器 API | 插件添加的内容 | 教程 | +| ------------------------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------ | +| [`server.route()`](/api#server-route-route) | 路由 | [路由](/tutorials/zh_CN/routing) | +| [`server.ext()`](/api#server-ext-events) | 在每个请求的某个步骤,或在服务器启动和停止时运行的代码 | [生命周期钩子](/tutorials/zh_CN/lifecycle-hooks) | +| [`server.decorate()`](/api#server-decorate-type-property-method-options) | `server`、`request` 或 `h` 上的新方法 | [装饰器](/tutorials/zh_CN/decorators) | +| [`server.auth.scheme()`](/api#server-auth-scheme-name-scheme) | 一个身份认证 scheme | [身份认证](/tutorials/zh_CN/auth) | +| [`server.method()`](/api#server-method-name-method-options) | 共享的、可缓存的函数 | [服务器方法](/tutorials/zh_CN/server-methods) | +| [`server.expose()`](/api#server-expose-key-value-options) | 其他插件从 `server.plugins[name]` 读取的值 | | -```javascript -'use strict'; +## 创建插件 -const myPlugin = { +插件是一个带有 `name` 和异步 `register(server, options)` 函数的对象: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const myPlugin: Plugin = { name: 'myPlugin', version: '1.0.0', register: async function (server, options) { - // 创建一个路由作为示例 - server.route({ method: 'GET', path: '/test', @@ -32,23 +41,39 @@ const myPlugin = { return 'hello, world'; }, }); - - // etc ... - await someAsyncMethods(); }, }; ``` -当作为一个外部模块时, 你需要指定 `pkg` 属性: +注册之后,`GET /test` 会返回 `'hello, world'`。 -```javascript -'use strict'; +`Plugin` 接受插件选项的类型。这个插件没有选项,所以是 `Plugin`。为插件标注类型后,TypeScript 会推断出 `register` 中 `server` 和 `options` 的类型,并检查插件是否有 `name` 或 `pkg`。 -exports.plugin = { - pkg: require('./package.json'), - register: async function (server, options) { - // 创建一个路由作为示例 +插件对象的属性: + +| 属性 | 含义 | +| -------------- | ------------------------------------------------------------------------------- | +| `register` | 必需。负责设置插件。注册过程会等待它完成,如果它抛出错误,注册就会失败。 | +| `name` | 除非设置了 `pkg`,否则必需。在服务器上必须唯一。已发布的插件使用其 npm 包名。 | +| `version` | 仅供参考。其他插件可以读取它,[依赖](#dependencies)可以要求某个版本范围。 | +| `pkg` | 你的 `package.json`,用来提供 `name` 和 `version`,无需手动设置。 | +| `dependencies` | 必须同时注册的插件。参见[依赖](#dependencies)。 | +| `requirements` | 支持的 `node` 和 `hapi` 版本范围,例如 `{ hapi: '>=21' }`。超出范围时注册失败。 | +| `multiple` | 为 `true` 时允许在同一个服务器上多次注册该插件。 | +| `once` | 为 `true` 时重复注册会被静默忽略,而不是报错。 | + +### 将插件发布为模块 +插件模块导出插件对象,可以作为默认导出,也可以作为名为 `plugin` 的具名导出。`pkg` 从 `package.json` 中读取名称和版本: + +```ts +import Pkg from './package.json' with { type: 'json' }; + +import type { Plugin } from '@hapi/hapi'; + +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { server.route({ method: 'GET', path: '/test', @@ -56,117 +81,151 @@ exports.plugin = { return 'hello, world'; }, }); - - // etc... - await someAsyncMethods(); }, }; ``` -请注意在第一个例子中,我们显示的设定了 `name` 和 `version` 属性, 然而在第二个例子中,我们将 package.json 中的内容设置为 `pkg` 属性的值。两种方法都是可以的。 +两种导出形式 hapi 都接受:`server.register()` 接受一个插件对象,或一个 `plugin` 属性为插件对象的模块。 -当作为模块编写时, 插件可以作为模块被导出,如 `module.exports = { register, name, version }` 。如果你希望你的模块以多个hapi插件导出时,可以 `exports.plugin = { register, name, version }`。 +### 插件选项 -除此之外,当一个插件的 `multiple` 属性设置为 `true`的时候,这表明一个插件可以被重复注册多次。 +`options` 就是调用方在注册时传入的内容,原样传递。用 `Plugin` 为它标注类型: -另外一个可用的属性是`once`,当它设置为 `true` 时意味着 hapi 将会忽略掉之后的注册,并且也不会抛出错误。 +```ts +import Pkg from './package.json' with { type: 'json' }; -### 注册方法 +import type { Plugin } from '@hapi/hapi'; -综上所述, `register` 方法接受两个参数, `server` 和 `options`。 +interface Options { + name: string; +} -`options` 参数只是用户在调用 `server.register(plugin, options)` 时传递给插件的选项内容。不做任何更改,这个对象将直接传递给 `register` 方法。 +export const plugin: Plugin = { + pkg: Pkg, + register: async function (server, options) { + server.route({ + method: 'GET', + path: '/test', + handler: function (request, h) { + return `Hello ${options.name}`; + }, + }); + }, +}; +``` -`register` 应为异步函数,一旦你的插件完成了注册的所有步骤后它将被返回。 另外当注册插件出现异常时,需要抛出一个错误。 +`options.name` 是 `string` 类型,如果某个 `server.register()` 调用传入的选项与 `Options` 不符,TypeScript 会报错;hapi 在运行时不会检查它们。 -`server` 对象是需要载入你的插件的 `server` 对象的引用。 +## 加载插件 -## 载入一个插件 +使用 [`server.register()`](/api#await-server-register-plugins-options) 注册插件。可以传入插件本身,或者一个带有 `plugin` 和 `options` 的对象,或者由两者组成的数组: -插件可以一次载入一个,也可以通过方法 `server.register()` 分组载入, 例如: +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; +import { plugin as YourPlugin } from 'yourplugin'; -```javascript const start = async function () { - // 载入一个插件 - - await server.register(require('myplugin')); + const server = Hapi.server(); - // 载入多个插件 + await server.register(YourPlugin); - await server.register([require('myplugin'), require('yourplugin')]); -}; -``` - -要将选项传递给插件时,我们将传递一个拥有 `plugin` 和 `options` 作为键的对象, 例如: - -```javascript -const start = async function () { await server.register({ - plugin: require('myplugin'), + plugin: MyPlugin, options: { - message: 'hello', + name: 'Bob', }, }); + + await server.register([{ plugin: YourPlugin }, { plugin: MyPlugin, options: { name: 'Bob' } }]); }; ``` -这些对象也可以以数组的方式传入: +数组中的插件按顺序依次注册。每个 `register` 函数完成后,下一个才会开始。 + +### 注册选项 + +`server.register()` 的第二个参数配置 hapi 如何注册插件。它 _不会_ 传给插件: + +| 选项 | 效果 | +| --------------- | ---------------------------------------------------------------- | +| `routes.prefix` | 为插件添加的每个路由加上路径前缀。`/test` 变为 `/plugins/test`。 | +| `routes.vhost` | 为插件添加的每个路由应用一个虚拟主机。 | +| `once` | 为 `true` 时,如果插件已经注册,则跳过它,而不是抛出错误。 | + +```ts +import Hapi from '@hapi/hapi'; +import { plugin as MyPlugin } from 'myplugin'; -```javascript const start = async function () { - await server.register([ - { - plugin: require('plugin1'), - options: {}, - }, - { - plugin: require('plugin2'), - options: {}, + const server = Hapi.server(); + + await server.register(MyPlugin, { + routes: { + prefix: '/plugins', }, - ]); + }); }; ``` -### 注册选项 +前缀可以嵌套:如果一个以 `/api` 为前缀注册的插件又以 `/v1` 为前缀注册了一个子插件,子插件的路由就会以 `/api/v1` 开头。完整列表参见 [`server.register()`](/api#await-server-register-plugins-options)。 + +## 插件能影响什么、不能影响什么 + +每个插件都有自己的 [`server.realm`](/api#server-realm):一个存放只在插件内部生效的设置的容器。插件做的其他所有事情都会改变整个服务器: + +| 改动 | 作用范围 | +| ------------------------------------------------------------------------------------------- | -------------------------------- | +| 注册选项中的路由前缀和 vhost | 该插件的路由及其子插件 | +| [`server.bind()`](/api#server-bind-context)、[`server.path()`](/api#server-path-relativeto) | 该插件添加的路由、handler 和扩展 | +| 带 `options.sandbox: 'plugin'` 的请求[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks) | 该插件的路由 | +| 不带 `sandbox` 的请求生命周期钩子 | **服务器上的每个路由** | +| [装饰](/tutorials/zh_CN/decorators)、服务器方法、认证 strategy、`server.auth.default()` | **整个服务器** | + +一个添加 `onPreResponse` 钩子来格式化自身错误的插件应当传入 `sandbox: 'plugin'`,否则它也会重新格式化其他所有插件的错误。 -你也可以传递第二个可选参数到 `server.register()`。 关于这个对象的文档可以在 [API reference](/api#-await-serverregisterplugins-options) 中找到。 +## 依赖 -options 对象将被 hapi 使用,并且 _不会_ 传递到被装载的插件中。它可以将`vhost`或`prefix`修饰符添加到使用插件的路由上。 +需要另一个插件的插件应当声明这一依赖,而不是依赖注册顺序: -例如我们有这样一个插件: +```ts +import type { Plugin } from '@hapi/hapi'; -```javascript -'use strict'; +declare module '@hapi/hapi' { + interface PluginProperties { + database: { migrate: () => Promise }; + } +} -exports.plugin = { - pkg: require('./package.json'), +export const plugin: Plugin = { + name: 'dashboard', + dependencies: ['@hapi/vision', 'auth'], register: async function (server, options) { - server.route({ - method: 'GET', - path: '/test', - handler: function (request, h) { - return 'test passed'; - }, + server.dependency('database', async (server) => { + await server.plugins.database.migrate(); }); - - // 其他... - await someAsyncMethods(); }, }; ``` -通常来说, 当插件载入的时候将会创建一个 `GET` 路由在 `/test` 路径上。 我们可以通过添加 `prefix` 的值来修改任意使用这个插件生成的路由。 - -```javascript -const start = async function () { - await server.register(require('myplugin'), { - routes: { - prefix: '/plugins', - }, - }); -}; +hapi 在服务器初始化时检查依赖,而不是在插件注册时检查,所以插件可以按任意顺序注册: + +```mermaid +sequenceDiagram + participant A as 你的应用 + participant S as 服务器 + A->>S: register(dashboard) + Note over S: 记录:dashboard 需要 @hapi/vision、auth、database + A->>S: register(vision, auth, database) + A->>S: initialize() 或 start() + Note over S: 1. 如果缺少依赖,在这里失败 + Note over S: 2. 启动缓存 client + Note over S: 3. onPreStart:运行 server.dependency() 回调 + Note over S: 4. 仅 start():服务器开始监听 ``` -现在当插件被载入时, 因为修改了 `prefix` ,`GET` 路由将注册为 `/plugins/test`. +- `dependencies` 是一个名称列表,或者一个从名称到版本范围的对象,例如 `{ '@hapi/vision': '7.x' }`。 +- [`server.dependency(names, after)`](/api#server-dependency-dependencies-after) 在 `register` 内部声明同样的依赖,并接受一个 `after` 函数。hapi 在服务器启动期间、所有列出的插件都注册之后运行 `after`。需要使用其他插件 API 的初始化工作可以放在这里。 +- 如果 `after` 依赖出现循环,hapi 会抛出错误。 -与之类似,通过修改 `options.routes.vhost` 属性,将为所有使用插件创建的路由分配默认的 `vhost`配置。 更多关于 `vhost` 的配置信息可以在这里找到 [API reference](/api#-serverrouteroute). +`server.plugins.database` 是 `database` 插件通过 [`server.expose()`](/api#server-expose-key-value-options) 发布的内容。hapi 无法知道它的结构,所以 `declare module` 代码块把它添加到 hapi 的 `PluginProperties` 接口中。 From 1e576541b622d6929fea0e71885675baa3aecd12 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:56 -0400 Subject: [PATCH 11/22] docs: rework routing tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/routing.md | 286 ++++++++++++++------------------ docs/tutorials/es_ES/routing.md | 253 ++++++++++++++++++++++++++++ docs/tutorials/ko_KR/routing.md | 242 ++++++++++++++++++++------- docs/tutorials/pt_BR/routing.md | 246 +++++++++++++++++++++------ docs/tutorials/tr_TR/routing.md | 244 ++++++++++++++++++++------- docs/tutorials/zh_CN/routing.md | 242 ++++++++++++++++++++------- 6 files changed, 1129 insertions(+), 384 deletions(-) create mode 100644 docs/tutorials/es_ES/routing.md diff --git a/docs/tutorials/en_US/routing.md b/docs/tutorials/en_US/routing.md index 924ca4e8..e1a8f7ed 100644 --- a/docs/tutorials/en_US/routing.md +++ b/docs/tutorials/en_US/routing.md @@ -6,135 +6,139 @@ title: Routing # Routing -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -When defining a route in hapi, you need three basic elements: the path, the method, and a handler. These are passed to your server as an object, and can be as simple as the following: +A route needs three things: a `method`, a `path`, and a `handler`. You add it with [`server.route()`](/api#server-route-route): -```js +```ts server.route({ method: 'GET', path: '/', - handler: function (request, h) { + handler: (request, h) => { return 'Hello World!'; }, }); ``` +`server.route()` also accepts an array of route objects, so you can register many routes in one call. + ## Methods -The route above responds to a `GET` request to `/` with the string `Hello World!`. The method option can be any valid HTTP method, or an array of methods. Let's say you want the same response when your user sends either a `PUT` or a `POST` request, you could do that with the following: +`method` is any HTTP method except `HEAD` (GET routes answer HEAD requests), an array of methods, or `'*'` for all of them: -```js +```ts server.route({ method: ['PUT', 'POST'], path: '/', - handler: function (request, h) { + handler: (request, h) => { return 'I did something!'; }, }); ``` +A route for `GET` also answers `HEAD` requests, so you never need a separate `HEAD` route. + ## Path -The path option must be a string, though it can contain named parameters. To name a parameter in a path, simply wrap it with `{}`. For example: +Wrap a path segment in `{}` to capture it as a named parameter. Its value is available on `request.params`: -```js -server.route({ +```ts +import * as Hoek from '@hapi/hoek'; + +server.route<{ Params: { user: string } }>({ method: 'GET', path: '/hello/{user}', - handler: function (request, h) { - return `Hello ${request.params.user}!`; + handler: (request, h) => { + return `Hello ${Hoek.escapeHtml(request.params.user)}!`; }, }); ``` -Note: It is best practice to always return escaped and validated user inputs such as query/path parameters. This is done to prevent echo or XSS attacks. One way to do this is to use [Hoek](/module/hoek) `escapeHtml()` method. With escaping in place, the above example would look like the following: - -```js -return `Hello ${Hoek.escapeHtml(request.params.user)}!`; -``` - -As you can see above, you have the string `{user}` in your path, which means you're asking for that segment of the path to be assigned to a named parameter. These parameters are stored in the object `request.params` within the handler. Since you named your parameter user, you are able to access the value with the property `request.params.user`, after URI encoding it so as to prevent content injection attacks. For example, going to `/hello/ferris` in your browser, you will see `Hello ferris!`. +A request to `/hello/ferris` replies `Hello ferris!`. -## Optional Parameters +- hapi URI-decodes parameter values, so `/hello/a%20b` gives `request.params.user === 'a b'`. +- The value comes straight from the URL. Escape it before you put it in HTML, as the [Hoek](/module/hoek) `escapeHtml()` call does above, or validate it with [joi](/tutorials/en_US/validation). +- Since hapi 21.4.10, `request.params` values are typed `unknown` by default. The type argument `server.route<{ Params: { user: string } }>()` declares the shape, so `request.params.user` is a `string` in the handler. The same type argument accepts `Query`, `Payload`, `Headers`, and the other request parts below. -In the above example, the user parameter is required: a request to `/hello/bob` or `/hello/susan` will work, but a request to `/hello` will not. In order to make a parameter optional, put a question mark at the end of the parameter's name. Here is the same route, but updated to make the `user` parameter optional: +### Parameter types -```js -server.route({ - method: 'GET', - path: '/hello/{user?}', - handler: function (request, h) { - const user = request.params.user ? request.params.user : 'stranger'; +| Path | Matches | `request.params` | +| ------------------- | ---------------------------------------- | ----------------------------------- | +| `/hello/{user}` | `/hello/ferris` | `{ user: 'ferris' }` | +| `/hello/{user?}` | `/hello`, `/hello/ferris` | `{}` or `{ user: 'ferris' }` | +| `/hello/{user*2}` | `/hello/john/doe` (exactly two segments) | `{ user: 'john/doe' }` | +| `/files/{path*}` | `/files`, `/files/a`, `/files/a/b/c` | `{}` or `{ path: 'a/b/c' }` | +| `/{filename}.{ext}` | `/photo.jpg` | `{ filename: 'photo', ext: 'jpg' }` | - return `Hello ${user}!`; - }, -}); -``` +Rules hapi enforces when you add the route: -Now a request to `/hello/sloan` will reply with `Hello sloan!` and a request to just `/hello` will reply with `Hello stranger!`. It is important to be aware that only the last named parameter in a path can be optional. That means that `/{one?}/{two}/` is an invalid path, since in this case there is another parameter after the optional one. You may also have a named parameter covering only part of a segment of the path for instance `/{filename}.jpg` is valid. You may also have multiple parameters per segment provided there is non-parameter separator between them, meaning `/{filename}.{ext}` is valid whereas `/{filename}{ext}` is not. +- `{user*}` and a whole-segment optional `{user?}` must be the last segment of the path. `{user*2}` and a partial-segment optional such as `/a{user?}/b` can appear anywhere. +- Two parameters in one segment need a literal separator between them: `{filename}.{ext}` is valid, `{filename}{ext}` is not. +- A parameter name can appear only once per path. +- Two routes with the same method and an equivalent path conflict, and `server.route()` throws. `/{name}` and `/{other}` are equivalent. -## Multi-Segment Parameters +### Multi-segment parameters -Along with optional path parameters, you can also allow parameters that match multiple segments. In order to do this, you use an asterisk and a number. For example: +A multi-segment parameter holds the matched segments joined with `/`, so split it to get the parts: -```js -server.route({ +```ts +server.route<{ Params: { user: string } }>({ method: 'GET', path: '/hello/{user*2}', - handler: function (request, h) { - const userParts = request.params.user.split('/'); + handler: (request, h) => { + const [first, last] = request.params.user.split('/'); - return `Hello ${userParts[0]} ${userParts[1]}!`; + return `Hello ${first} ${last}!`; }, }); ``` -With this configuration, a request to `/hello/john/doe` will reply with the string `Hello john doe!`. The important thing to note here is that the parameter is actually the string `"john/doe"`. That's why you did a split on that character to get the two separate parts. The number after the asterisk represents how many path segments should be assigned to the parameter. You can also omit the number entirely, and the parameter will match any number of segments available. Like the optional parameters, a wildcard parameter (for example `/{files*}`) may only appear as the last parameter in your path. +A request to `/hello/john/doe` replies `Hello john doe!`. -When determining what handler to use for a particular request, hapi searches paths in order from most specific to least specific. That means if you have two routes, one with the path `/filename.jpg` and a second route `/filename.{ext}` a request to `/filename.jpg` will match the first route, and not the second. This also means that a route with the path `/{files*}` will be the last route tested, and will only match if all other routes fail. +### Which route wins -## Query Parameters +hapi does not try routes in the order you added them. For each path segment it tries the kinds of match from most to least specific, and falls back to the next kind when the rest of the path does not match: -Query parameters are common way of sending data to the server. Query parameters are sent via the URL in a `key=value` format. For example: +```mermaid +flowchart TD + L["1. literal
/filename.jpg"] -->|no match| M["2. mixed
/{name}.jpg"] + M -->|no match| P["3. parameter
/{name}"] + P -->|no match| W["4. wildcard
/{any*}"] + W -->|no match| N[404 Not Found] +``` -`localhost:3000?name=ferris&location=chicago` +With these four routes registered: -There are two query parameters here, `name=ferris` and `location=chicago`. In hapi, you can access query parameters by the `request.query` object. +| Request | Route that answers | +| --------------- | ------------------ | +| `/filename.jpg` | `/filename.jpg` | +| `/photo.jpg` | `/{name}.jpg` | +| `/photo` | `/{name}` | +| `/a/b` | `/{any*}` | -```js -server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return `Hello ${request.query.name}!`; - }, -}); -``` +Paths are case-sensitive by default: with the routes above, `/Filename.jpg` is answered by `/{name}.jpg`. Set [`router.isCaseSensitive`](/api#server-options-router) to `false` on the server to change that. -Here, you simply access the `name` query parameter and return it in the handler, which would read `Hello ferris!`. +## Query parameters -For more complex query structures, you may opt to use the `qs` module. Consider the following: +`request.query` holds the parsed query string. For `/?name=ferris&location=chicago`: -```js -server.route({ +```ts +server.route<{ Query: { name: string } }>({ method: 'GET', path: '/', - handler: function (request, h) { - return request.query; + handler: (request, h) => { + return `Hello ${request.query.name}!`; }, }); ``` -If you sent the request `localhost:3000?foo[bar]=baz`, hapi, by default would return `{ "foo[bar]": "baz" }`. +hapi's built-in parser does not understand nested keys: `/?foo[bar]=baz` gives `{ 'foo[bar]': 'baz' }`. To parse nested keys, set the server's `query.parser` to a parser such as [qs](https://github.com/ljharb/qs): -With the [qs](https://github.com/ljharb/qs) module, you can parse the query string out. An example: - -```js -const Hapi = require('@hapi/hapi'); -const Qs = require('qs'); +```ts +import Hapi from '@hapi/hapi'; +import Qs from 'qs'; const server = Hapi.server({ port: 3000, @@ -147,141 +151,103 @@ const server = Hapi.server({ server.route({ method: 'GET', path: '/', - handler: function (request, h) { + handler: (request, h) => { return request.query; }, }); -const init = async () => { - await server.start(); - console.log('Server running on %s', server.info.uri); -}; - -process.on('unhandledRejection', (err) => { - console.log(err); - process.exit(1); -}); - -init(); -``` - -Here, you first require the [qs](https://github.com/ljharb/qs) module. - -Second, you set the query parameters parser method by setting the `server.options.query.parser` value. In this case, you use the `Qs.parse()` method where `query` is an object containing the incoming `request.query` parameters. Now, anything coming into `request.query` will be parsed with `Qs.parse()`. - -Lastly, you returned the parsed query string, which would now be: - -``` -{ - "foo": { - "bar": "baz" - } -} +await server.start(); +console.log('Server running on %s', server.info.uri); ``` -Note: In the above example, you used the [qs](https://github.com/ljharb/qs) module to handle our parsing, but any parser will do, be it from `npm` or even custom. Just be aware that the method must return an object where each key is a parameter and matching value is the parameter value. +Now `/?foo[bar]=baz` replies `{ "foo": { "bar": "baz" } }`. The parser receives the query hapi already parsed, such as `{ 'foo[bar]': 'baz' }`, not the raw string. Any function works, as long as it returns an object of parameter names to values. -## Request Payload +## Request payload -Anytime you send request data to your API, you will be able to access this data in the route handler with `request.payload`. See the following: +The parsed request body is on `request.payload`. hapi parses JSON, form-encoded, and text bodies based on the `Content-Type` header. Multipart bodies need the route option `payload: { multipart: true }`; without it they get `415 Unsupported Media Type`: -```js -server.route({ +```ts +server.route<{ Payload: { username: string; password: string } }>({ method: 'POST', path: '/signup', - handler: function (request, h) { - const payload = request.payload; - - return `Welcome ${payload.username}!`; + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; }, }); ``` -In the above example, the handler receives data via `request.payload`. In this case, the `request.payload` contains an object that stores user sign up data: - -`{ username: 'ferris', password: 'password' }` - -Note: It's always best practice to validate the incoming payload, as it may contain unsafe data. See validation tutorial for more info. +The type argument only tells TypeScript the shape. Nothing checks it at runtime until you add [payload validation](/tutorials/en_US/validation). ## Handler -The handler option is a function that accepts two parameters, `request`, and `h`. +The handler receives two arguments: -The `request` parameter is an object with details about the end user's request, such as path [parameters](#parameters), an [associated payload](#requestpayload), [query parameters](#query), authentication information, headers, etc. Full documentation on what the `request` object contains can be found in the [API reference](/api#request-properties). +- `request` holds everything about the incoming request: `params`, `query`, `payload`, `headers`, `auth`, and more. See the [request object](/api#request). +- `h` is the [response toolkit](/api#response-toolkit), with methods such as `h.response()` and `h.redirect()`. -The second parameter, `h`, is the response toolkit, an object with several methods used to respond to the request. As you've seen in the previous examples, if you wish to respond to a request with some value, you simply return it from the handler. The payload may be a string, a buffer, a JSON serializable object, a stream or a promise. +What the handler returns becomes the response: -Alternatively you may pass the same value to `h.response(value)` and return that from the handler. The result of this call is a response object, that can be chained with additional methods to alter the response before it is sent. For example `h.response('created').code(201)` will send a payload of created with an HTTP status code of 201. You may also set headers, content type, content length, send a redirection response, and many other things that are documented in the [API reference](/api#response-toolkit). +| Handler returns | Response | +| -------------------------------------- | ------------------------------------------------------------------ | +| a string | `200`, `text/html; charset=utf-8` | +| an object or array | `200`, `application/json; charset=utf-8` | +| a `Buffer` | `200`, `application/octet-stream` | +| a readable stream | `200`, the stream piped to the client | +| `h.response(value)` with chained calls | full control, for example `h.response('created').code(201)` | +| a thrown error | an error response, `500` unless it is a [boom](/module/boom) error | -The `handler` option must return a value, a promise, or throw an error. +A handler must return a value, a promise, or throw. [Response Marshalling](/tutorials/en_US/response-marshalling) covers how hapi turns these values into the bytes it sends. -Note: handlers using a fat arrow style function cannot be bound to any `server.bind()` property. Instead, the bound context is available under [`h.context`](/api#h.context). +Arrow functions cannot use `this`, so a handler written as an arrow function reads the value set by [`server.bind()`](/api#server-bind-context) from [`h.context`](/api#h-context) instead. ## Options -Aside from these three basic elements, you may also specify an `options` parameter for each route. This is where you configure things like validation, authentication, prerequisites, payload processing, and caching options. More details can be found in the linked tutorials, as well as the [API reference](/api#route-options). +Everything beyond method, path, and handler goes in the route's `options`: -Here we will look at some options of validating with Joi. +```ts +import Joi from 'joi'; -```js -server.route({ +server.route<{ Payload: { username: string; password: string } }>({ method: 'POST', path: '/signup', - handler: function (request, h) { - const payload = request.payload; - - return `Welcome ${payload.username}!`; - }, options: { auth: false, validate: { - payload: { - username: Joi.string().min(1).max(20), - password: Joi.string().min(7), - }, + payload: Joi.object({ + username: Joi.string().min(1).max(20).required(), + password: Joi.string().min(7).required(), + }), }, }, + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, }); ``` -The first property under `options` is `auth`. `auth` will set the authentication configuration for the route. Since this route is for a new user signing up, you will disable authentication. - -The second property is `validate`. This allows you to set validation rules for various request components, such as `headers`, `params`, `payload`, and `failAction`. You use the [joi](https://joi.dev) package to validate the `request.payload`. For more info, please check the validation tutorial. - -## 404 Handling - -404 errors will happen whenever your server can't find what was the resource that was requested. It is best practice to handle these errors the proper way. This is easy to do in hapi, by just employing a route that will catch everything your other routes will not. The following example shows how to setup a route to return a custom `404` response. +| Option | What it controls | Learn more | +| ---------- | ----------------------------------------- | --------------------------------------------------- | +| `auth` | which authentication strategy applies | [Authentication](/tutorials/en_US/auth) | +| `validate` | checks on params, query, payload, headers | [Validation](/tutorials/en_US/validation) | +| `cache` | `Cache-Control` headers for the response | [Caching](/tutorials/en_US/caching) | +| `payload` | body parsing, size limits, uploads | [route payload options](/api#route-options-payload) | +| `pre` | methods that run before the handler | [route pre options](/api#route-options-pre) | +| `ext` | lifecycle hooks for this route only | [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks) | -```js -'use strict'; +The full list is in the [route options reference](/api#route-options). -const Hapi = require('@hapi/hapi'); +## 404 handling -const internals = {}; - -const init = async () => { - const server = Hapi.server({ - port: 3000, - host: 'localhost', - }); - - server.route({ - method: '*', - path: '/{any*}', - handler: function (request, h) { - return h.response('404 Error! Page Not Found!').code(404); - }, - }); +When no route matches, hapi replies with a `404` JSON error. To send your own response instead, add a catch-all route. The `{any*}` wildcard is the least specific match, so it only answers requests no other route takes: - await server.start(); - console.log('Server running on %s', server.info.uri); -}; - -init(); +```ts +server.route({ + method: '*', + path: '/{any*}', + handler: (request, h) => { + return h.response('404 Error! Page Not Found!').code(404); + }, +}); ``` -First, you configure our server. - -Next, you setup your route that return your custom 404 response. You use a wildcard, `*`, for the method, so it covers all available methods. Then, you use a very broad, generic path, `'/{any*}`. This will catch any route that our other routes do not. hapi routes will go the the most specific path first, then get broader, till it finds a match. For example, `localhost:3000/login` will go to the `/login` route and not the `/{any*}` route. - -Lastly, you return a custom 404 response in your handler, letting your users know that the resource they are asking for could not be found. +The catch-all route only covers unmatched paths. A `404` thrown by a matched route, such as a handler that throws `Boom.notFound()`, never reaches it. To customize every `404`, whatever produced it, change the response in an `onPreResponse` hook; see [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks). diff --git a/docs/tutorials/es_ES/routing.md b/docs/tutorials/es_ES/routing.md new file mode 100644 index 00000000..31d517e4 --- /dev/null +++ b/docs/tutorials/es_ES/routing.md @@ -0,0 +1,253 @@ +--- +title: Enrutamiento +--- + + + +# Enrutamiento + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Una ruta necesita tres cosas: un `method`, un `path` y un `handler`. Se añade con [`server.route()`](/api#server-route-route): + +```ts +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return 'Hello World!'; + }, +}); +``` + +`server.route()` también acepta un array de objetos de ruta, así que puedes registrar muchas rutas en una sola llamada. + +## Métodos + +`method` es cualquier método HTTP salvo `HEAD` (las rutas GET responden a las peticiones HEAD), un array de métodos, o `'*'` para todos ellos: + +```ts +server.route({ + method: ['PUT', 'POST'], + path: '/', + handler: (request, h) => { + return 'I did something!'; + }, +}); +``` + +Una ruta para `GET` también responde a las peticiones `HEAD`, así que nunca necesitas una ruta `HEAD` aparte. + +## Path + +Encierra un segmento del path entre `{}` para capturarlo como parámetro con nombre. Su valor está disponible en `request.params`: + +```ts +import * as Hoek from '@hapi/hoek'; + +server.route<{ Params: { user: string } }>({ + method: 'GET', + path: '/hello/{user}', + handler: (request, h) => { + return `Hello ${Hoek.escapeHtml(request.params.user)}!`; + }, +}); +``` + +Una petición a `/hello/ferris` responde `Hello ferris!`. + +- hapi decodifica los valores de los parámetros como URI, así que `/hello/a%20b` da `request.params.user === 'a b'`. +- El valor viene directamente de la URL. Escápalo antes de meterlo en HTML, como hace arriba la llamada a `escapeHtml()` de [Hoek](/module/hoek), o valídalo con [joi](/tutorials/es_ES/validation). +- Desde hapi 21.4.10, los valores de `request.params` tienen tipo `unknown` por defecto. El argumento de tipo `server.route<{ Params: { user: string } }>()` declara la forma, de modo que `request.params.user` es un `string` en el handler. El mismo argumento de tipo acepta `Query`, `Payload`, `Headers` y las demás partes de la petición que se ven más abajo. + +### Tipos de parámetros + +| Path | Coincide con | `request.params` | +| ------------------- | --------------------------------------------- | ----------------------------------- | +| `/hello/{user}` | `/hello/ferris` | `{ user: 'ferris' }` | +| `/hello/{user?}` | `/hello`, `/hello/ferris` | `{}` o `{ user: 'ferris' }` | +| `/hello/{user*2}` | `/hello/john/doe` (exactamente dos segmentos) | `{ user: 'john/doe' }` | +| `/files/{path*}` | `/files`, `/files/a`, `/files/a/b/c` | `{}` o `{ path: 'a/b/c' }` | +| `/{filename}.{ext}` | `/photo.jpg` | `{ filename: 'photo', ext: 'jpg' }` | + +Reglas que hapi aplica al añadir la ruta: + +- `{user*}` y un parámetro opcional que ocupa todo el segmento, `{user?}`, tienen que ser el último segmento del path. `{user*2}` y un opcional que ocupa parte del segmento, como `/a{user?}/b`, pueden aparecer en cualquier sitio. +- Dos parámetros en un mismo segmento necesitan un separador literal entre ellos: `{filename}.{ext}` es válido, `{filename}{ext}` no. +- Un nombre de parámetro solo puede aparecer una vez por path. +- Dos rutas con el mismo método y un path equivalente entran en conflicto, y `server.route()` lanza una excepción. `/{name}` y `/{other}` son equivalentes. + +### Parámetros de varios segmentos + +Un parámetro de varios segmentos contiene los segmentos que coinciden unidos con `/`, así que divídelo para obtener las partes: + +```ts +server.route<{ Params: { user: string } }>({ + method: 'GET', + path: '/hello/{user*2}', + handler: (request, h) => { + const [first, last] = request.params.user.split('/'); + + return `Hello ${first} ${last}!`; + }, +}); +``` + +Una petición a `/hello/john/doe` responde `Hello john doe!`. + +### Qué ruta gana + +hapi no prueba las rutas en el orden en que las añadiste. Para cada segmento del path prueba los tipos de coincidencia del más específico al menos específico, y pasa al siguiente tipo cuando el resto del path no coincide: + +```mermaid +flowchart TD + L["1. literal
/filename.jpg"] -->|no coincide| M["2. mixto
/{name}.jpg"] + M -->|no coincide| P["3. parámetro
/{name}"] + P -->|no coincide| W["4. comodín
/{any*}"] + W -->|no coincide| N[404 Not Found] +``` + +Con estas cuatro rutas registradas: + +| Petición | Ruta que responde | +| --------------- | ----------------- | +| `/filename.jpg` | `/filename.jpg` | +| `/photo.jpg` | `/{name}.jpg` | +| `/photo` | `/{name}` | +| `/a/b` | `/{any*}` | + +Los paths distinguen entre mayúsculas y minúsculas por defecto: con las rutas anteriores, `/Filename.jpg` lo responde `/{name}.jpg`. Pon [`router.isCaseSensitive`](/api#server-options-router) a `false` en el servidor para cambiarlo. + +## Parámetros de query + +`request.query` contiene la query string ya parseada. Para `/?name=ferris&location=chicago`: + +```ts +server.route<{ Query: { name: string } }>({ + method: 'GET', + path: '/', + handler: (request, h) => { + return `Hello ${request.query.name}!`; + }, +}); +``` + +El parser integrado de hapi no entiende las claves anidadas: `/?foo[bar]=baz` da `{ 'foo[bar]': 'baz' }`. Para parsear claves anidadas, asigna a `query.parser` del servidor un parser como [qs](https://github.com/ljharb/qs): + +```ts +import Hapi from '@hapi/hapi'; +import Qs from 'qs'; + +const server = Hapi.server({ + port: 3000, + host: 'localhost', + query: { + parser: (query) => Qs.parse(query), + }, +}); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return request.query; + }, +}); + +await server.start(); +console.log('Server running on %s', server.info.uri); +``` + +Ahora `/?foo[bar]=baz` responde `{ "foo": { "bar": "baz" } }`. El parser recibe la query que hapi ya ha parseado, como `{ 'foo[bar]': 'baz' }`, no el string original. Sirve cualquier función, siempre que devuelva un objeto que asocie nombres de parámetro con valores. + +## Payload de la petición + +El cuerpo de la petición ya parseado está en `request.payload`. hapi parsea cuerpos JSON, de formulario y de texto según la cabecera `Content-Type`. Los cuerpos multipart necesitan la opción de ruta `payload: { multipart: true }`; sin ella reciben un `415 Unsupported Media Type`: + +```ts +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` + +El argumento de tipo solo le indica la forma a TypeScript. Nada lo comprueba en tiempo de ejecución hasta que añades [validación del payload](/tutorials/es_ES/validation). + +## Handler + +El handler recibe dos argumentos: + +- `request` contiene todo lo relativo a la petición entrante: `params`, `query`, `payload`, `headers`, `auth` y más. Consulta el [objeto request](/api#request). +- `h` es el [response toolkit](/api#response-toolkit), con métodos como `h.response()` y `h.redirect()`. + +Lo que devuelve el handler se convierte en la respuesta: + +| El handler devuelve | Respuesta | +| -------------------------------------------- | ---------------------------------------------------------------------------- | +| un string | `200`, `text/html; charset=utf-8` | +| un objeto o array | `200`, `application/json; charset=utf-8` | +| un `Buffer` | `200`, `application/octet-stream` | +| un stream legible | `200`, el stream se envía al cliente | +| `h.response(value)` con llamadas encadenadas | control total, por ejemplo `h.response('created').code(201)` | +| un error lanzado | una respuesta de error, `500` salvo que sea un error de [boom](/module/boom) | + +Un handler tiene que devolver un valor o una promesa, o lanzar una excepción. [Serialización de respuestas](/tutorials/es_ES/response-marshalling) explica cómo convierte hapi estos valores en los bytes que envía. + +Las funciones flecha no pueden usar `this`, así que un handler escrito como función flecha lee el valor que fija [`server.bind()`](/api#server-bind-context) desde [`h.context`](/api#h-context). + +## Opciones + +Todo lo que va más allá de method, path y handler se pone en las `options` de la ruta: + +```ts +import Joi from 'joi'; + +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', + options: { + auth: false, + validate: { + payload: Joi.object({ + username: Joi.string().min(1).max(20).required(), + password: Joi.string().min(7).required(), + }), + }, + }, + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` + +| Opción | Qué controla | Más información | +| ---------- | ---------------------------------------------------- | ------------------------------------------------------------ | +| `auth` | qué strategy de autenticación se aplica | [Autenticación](/tutorials/es_ES/auth) | +| `validate` | comprobaciones de params, query, payload y cabeceras | [Validación](/tutorials/es_ES/validation) | +| `cache` | cabeceras `Cache-Control` de la respuesta | [Caché](/tutorials/es_ES/caching) | +| `payload` | parseo del cuerpo, límites de tamaño, subidas | [opciones de payload de la ruta](/api#route-options-payload) | +| `pre` | métodos que se ejecutan antes del handler | [opciones pre de la ruta](/api#route-options-pre) | +| `ext` | hooks del ciclo de vida solo para esta ruta | [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) | + +La lista completa está en la [referencia de opciones de ruta](/api#route-options). + +## Gestión de los 404 + +Cuando ninguna ruta coincide, hapi responde con un error `404` en JSON. Para enviar tu propia respuesta, añade una ruta comodín. El comodín `{any*}` es la coincidencia menos específica, así que solo responde a las peticiones que ninguna otra ruta atiende: + +```ts +server.route({ + method: '*', + path: '/{any*}', + handler: (request, h) => { + return h.response('404 Error! Page Not Found!').code(404); + }, +}); +``` + +La ruta comodín solo cubre los paths sin coincidencia. Un `404` lanzado por una ruta que sí coincide, como un handler que lanza `Boom.notFound()`, nunca llega a ella. Para personalizar todos los `404`, los produzca lo que los produzca, cambia la respuesta en un hook `onPreResponse`; consulta [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks). diff --git a/docs/tutorials/ko_KR/routing.md b/docs/tutorials/ko_KR/routing.md index 2072d02a..2a946f53 100644 --- a/docs/tutorials/ko_KR/routing.md +++ b/docs/tutorials/ko_KR/routing.md @@ -1,123 +1,253 @@ --- -title: Routing +title: 라우팅 --- -## Routing +# 라우팅 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -hapi에서 라우트를 정의할 때 다른 프레임워크와 마찬가지로 3개의 기본 요소가 필요합니다.: 경로, 메소드, 처리기. 서버에 객체로 전달되고 다음처럼 간단할 수 있습니다.: +## 개요 -```javascript +라우트에는 `method`, `path`, `handler` 세 가지가 필요합니다. 라우트는 [`server.route()`](/api#server-route-route)로 추가합니다: + +```ts server.route({ method: 'GET', path: '/', - handler: function (request, h) { - return 'Hello!'; + handler: (request, h) => { + return 'Hello World!'; }, }); ``` -## 메소드 +`server.route()`는 라우트 객체의 배열도 받으므로 한 번의 호출로 여러 라우트를 등록할 수 있습니다. + +## 메서드 -이 라우트는 문자열 `Hello!`로 `/`에 `GET` 요청에 응답합니다. 메소드 옵션은 유효한 HTTP 메소드 또는 메소드의 배열일 수 있습니다. 사용자가 `PUT` 또는 `POST` 요청에 같은 응답을 원한다고 가정하면 다음과 같이 할 수 있습니다.: +`method`는 `HEAD`를 제외한 HTTP 메서드(GET 라우트가 HEAD 요청에 응답합니다), 메서드의 배열, 또는 모든 메서드를 뜻하는 `'*'`입니다: -```javascript +```ts server.route({ method: ['PUT', 'POST'], path: '/', - handler: function (request, h) { + handler: (request, h) => { return 'I did something!'; }, }); ``` -## 경로 +`GET` 라우트는 `HEAD` 요청에도 응답하므로 별도의 `HEAD` 라우트는 필요 없습니다. -경로 옵션은 문자열이어야 하지만 명명된 인자를 포함할 수 있습니다. 경로 안에 인자에 이름을 주기 위해서는 단순히 `{}`로 감싸주면 됩니다. 예: +## 경로 -```javascript -server.route({ +경로 세그먼트를 `{}`로 감싸면 이름 있는 파라미터로 캡처됩니다. 값은 `request.params`에서 사용할 수 있습니다: + +```ts +import * as Hoek from '@hapi/hoek'; + +server.route<{ Params: { user: string } }>({ method: 'GET', path: '/hello/{user}', - handler: function (request, h) { - return `Hello ${encodeURIComponent(request.params.user)}!`; + handler: (request, h) => { + return `Hello ${Hoek.escapeHtml(request.params.user)}!`; }, }); ``` -위에서 볼수 있듯이 경로에 `{user}` 문자열이 있습니다. 경로의 해당 부분을 명명된 인자에 할당하도록 요청하는 것입니다. 이 인자는 처리기 안에 `request.params` 객체에 저장됩니다. 인자를 `user`라 이름 붙였기 때문에 콘텐츠 주입 공격을 막기 위한 URI 인코딩 후 `request.params.user` 속성에 값에 접근할 수 있습니다. +`/hello/ferris`로 요청하면 `Hello ferris!`로 응답합니다. -### 선택적 인자 +- hapi는 파라미터 값을 URI 디코딩하므로 `/hello/a%20b`는 `request.params.user === 'a b'`가 됩니다. +- 값은 URL에서 그대로 옵니다. HTML에 넣기 전에 위의 [Hoek](/module/hoek) `escapeHtml()` 호출처럼 이스케이프하거나, [joi](/tutorials/ko_KR/validation)로 검증하세요. +- hapi 21.4.10부터 `request.params` 값의 타입은 기본적으로 `unknown`입니다. 타입 인자 `server.route<{ Params: { user: string } }>()`로 형태를 선언하면 핸들러에서 `request.params.user`가 `string`이 됩니다. 같은 타입 인자에 `Query`, `Payload`, `Headers`와 아래에 나오는 다른 요청 부분도 지정할 수 있습니다. -이 예에서 user 인자가 필요합니다.: `/hello/bob` 또는 `/hello/susan`에 대한 요청은 잘 동작하지만 `/hello`에 대한 요청은 동작하지 않습니다. 인자를 선택적으로 만들려면 인자 이름 끝에 물음표를 붙여줍니다. 여기에 같은 라우트이지만 `user` 인자를 선택적으로 만들기 위해 수정했습니다.: +### 파라미터 종류 -```javascript -server.route({ +| 경로 | 일치하는 요청 | `request.params` | +| ------------------- | -------------------------------------- | ----------------------------------- | +| `/hello/{user}` | `/hello/ferris` | `{ user: 'ferris' }` | +| `/hello/{user?}` | `/hello`, `/hello/ferris` | `{}` 또는 `{ user: 'ferris' }` | +| `/hello/{user*2}` | `/hello/john/doe` (정확히 두 세그먼트) | `{ user: 'john/doe' }` | +| `/files/{path*}` | `/files`, `/files/a`, `/files/a/b/c` | `{}` 또는 `{ path: 'a/b/c' }` | +| `/{filename}.{ext}` | `/photo.jpg` | `{ filename: 'photo', ext: 'jpg' }` | + +라우트를 추가할 때 hapi가 강제하는 규칙: + +- `{user*}`와 세그먼트 전체를 차지하는 선택적 파라미터 `{user?}`는 경로의 마지막 세그먼트여야 합니다. `{user*2}`와 `/a{user?}/b` 같은 세그먼트 일부의 선택적 파라미터는 어디에나 올 수 있습니다. +- 한 세그먼트에 파라미터가 두 개 있으면 그 사이에 리터럴 구분자가 있어야 합니다. `{filename}.{ext}`는 유효하지만 `{filename}{ext}`는 유효하지 않습니다. +- 파라미터 이름은 경로당 한 번만 쓸 수 있습니다. +- 메서드가 같고 경로가 동등한 두 라우트는 충돌하며 `server.route()`가 예외를 던집니다. `/{name}`과 `/{other}`는 동등합니다. + +### 여러 세그먼트 파라미터 + +여러 세그먼트 파라미터에는 일치한 세그먼트가 `/`로 이어진 값이 들어 있으므로, 나눠서 각 부분을 얻습니다: + +```ts +server.route<{ Params: { user: string } }>({ method: 'GET', - path: '/hello/{user?}', - handler: function (request, h) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'stranger'; + path: '/hello/{user*2}', + handler: (request, h) => { + const [first, last] = request.params.user.split('/'); - return `Hello ${user}!`; + return `Hello ${first} ${last}!`; }, }); ``` -이제 `/hello/mary`에 대한 요청은 `Hello mary!`로 응답하고 `/hello`에 대한 요청은 `Hello stranger!`로 응답할 것입니다. 경로에서 _마지막_ 명명된 인자만이 선택적으로 될 수 있습니다. 선택적 인자 뒤에 다른 인자가 있기 때문에 `/{one?}/{two}`는 무효한 경로임을 의미합니다. 경로의 부분에서 일부분을 다루는 명명된 인자를 가질 수 있습니다. 예를 들어 `/{filename}.jpg`는 유효합니다. 한 부분에서 인자 사이에 인자가 아닌 구분자가 제공되면 여러 인자를 가질 수 있습니다. 이는 `/{filename}.{ext}`는 유효하지만 `/{filename}{ext}`는 유효하지 않음을 의미합니다. +`/hello/john/doe`로 요청하면 `Hello john doe!`로 응답합니다. + +### 어떤 라우트가 선택되는가 + +hapi는 라우트를 추가한 순서대로 시도하지 않습니다. 경로 세그먼트마다 가장 구체적인 종류의 일치부터 가장 덜 구체적인 종류까지 시도하고, 나머지 경로가 일치하지 않으면 다음 종류로 넘어갑니다: + +```mermaid +flowchart TD + L["1. 리터럴
/filename.jpg"] -->|불일치| M["2. 혼합
/{name}.jpg"] + M -->|불일치| P["3. 파라미터
/{name}"] + P -->|불일치| W["4. 와일드카드
/{any*}"] + W -->|불일치| N[404 Not Found] +``` + +이 네 라우트가 등록되어 있을 때: + +| 요청 | 응답하는 라우트 | +| --------------- | --------------- | +| `/filename.jpg` | `/filename.jpg` | +| `/photo.jpg` | `/{name}.jpg` | +| `/photo` | `/{name}` | +| `/a/b` | `/{any*}` | + +경로는 기본적으로 대소문자를 구분합니다. 위 라우트에서 `/Filename.jpg`는 `/{name}.jpg`가 응답합니다. 이를 바꾸려면 서버의 [`router.isCaseSensitive`](/api#server-options-router)를 `false`로 설정하세요. + +## 쿼리 파라미터 + +`request.query`에는 파싱된 쿼리 문자열이 들어 있습니다. `/?name=ferris&location=chicago`의 경우: + +```ts +server.route<{ Query: { name: string } }>({ + method: 'GET', + path: '/', + handler: (request, h) => { + return `Hello ${request.query.name}!`; + }, +}); +``` -### 복수 부분 인자 +hapi의 내장 파서는 중첩 키를 이해하지 못합니다. `/?foo[bar]=baz`는 `{ 'foo[bar]': 'baz' }`가 됩니다. 중첩 키를 파싱하려면 서버의 `query.parser`를 [qs](https://github.com/ljharb/qs) 같은 파서로 설정하세요: -선택적 경로 인자와 함께 복수 부분에 일치하는 인자를 사용할 수 있습니다. 그렇게 하려면 별표와 숫자를 사용합니다. 예: +```ts +import Hapi from '@hapi/hapi'; +import Qs from 'qs'; + +const server = Hapi.server({ + port: 3000, + host: 'localhost', + query: { + parser: (query) => Qs.parse(query), + }, +}); -```javascript server.route({ method: 'GET', - path: '/hello/{user*2}', - handler: function (request, h) { - const userParts = request.params.user.split('/'); - return `Hello ${encodeURIComponent(userParts[0])} ${encodeURIComponent(userParts[1])}!`; + path: '/', + handler: (request, h) => { + return request.query; }, }); + +await server.start(); +console.log('Server running on %s', server.info.uri); ``` -이 설정으로 `/hello/john/doe`에 대한 요청은 문자열 `Hello john doe!` 응답을 받을 것입니다. 중요한 것은 인자는 `"john/doe"` 문자열이라는 것입니다. 그래서 두 개의 분리된 부분을 얻기 위해 그 글자로 나누었습니다. 별표 뒤에 숫자는 인자에 할당될 경로 부분의 개수를 나타냅니다. 숫자를 생략할 수도 있습니다. 그러면 인자는 가능한 부분의 개수와 일치합니다. 선택적 인자같이 복수 부분 인자는(예 `/{files*}`) _오직_ 경로의 마지막 인자로만 나타날 수 있습니다. +이제 `/?foo[bar]=baz`는 `{ "foo": { "bar": "baz" } }`로 응답합니다. 파서는 원본 문자열이 아니라 hapi가 이미 파싱한 쿼리(예: `{ 'foo[bar]': 'baz' }`)를 받습니다. 파라미터 이름과 값의 객체를 반환하기만 하면 어떤 함수든 쓸 수 있습니다. -특정 요청에 대한 처리기를 결정할 때 hapi는 가장 구체적인 것부터 덜 구체적인 순서로 경로를 찾습니다. 만약 하나는 `/filename.jpg`와 다른 하나는 `/filename.{ext}` 이렇게 2개의 라우트를 가지고 있다면 `filename.jpg`에 대한 요청은 첫 번째 라우트에 일치하고 두 번째 라우트에는 일치하지 않습니다. `/{files*}` 경로의 라우트는 테스트 되는 _마지막_ 라우트가 될 것이고 다른 라우트가 모두 실패할 경우에만 일치한다는 것을 의미합니다. +## 요청 페이로드 -## Handler 메소드 +파싱된 요청 본문은 `request.payload`에 있습니다. hapi는 `Content-Type` 헤더에 따라 JSON, 폼 인코딩, 텍스트 본문을 파싱합니다. 멀티파트 본문에는 라우트 옵션 `payload: { multipart: true }`가 필요하며, 이 옵션이 없으면 `415 Unsupported Media Type`을 받습니다: -handler 옵션은 `request`와 `h` 2개의 인자를 받습니다. +```ts +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` -`request` 인자는 경로 인자,연관된 페이로드, 인증 정보, 헤더 같은 최종 사용자에 대한 자세한 정보를 가진 객체입니다. `request` 객체에 대한 전체 문서는 [API reference](/api#request-properties)에서 찾을 수 있습니다. +타입 인자는 TypeScript에게 형태를 알려 줄 뿐입니다. [페이로드 검증](/tutorials/ko_KR/validation)을 추가하기 전까지 런타임에는 아무것도 검사하지 않습니다. -두 번째 인자인 `h`는 요청에 응답하기 위해 사용하는 몇 가지 메소드를 가지고 있는 객체인 응답 도구입니다. 앞의 예제에서 본 것처럼 요청에 어떤 값으로 응답하기를 원하면 처리기에서 단순히 그 값을 반환하면 됩니다. payload는 문자열, 버퍼, 직렬화된 JSON 객체, 스트림 또는 Promise 이어야 합니다. +## 핸들러 -또는 같은 값을 `h.response(value)`에 전달하고 처리기에서 반환하면 됩니다. 이 호출의 결과는 응답 객체이고 이 객체는 응답을 전송하기 전에 응답을 변경하는 추가적인 메소드로 연결될 수 있습니다. 예를 들어 `h.response('created').code(201)`은 HTTP 상태 코드 `201`과 함께 `created`의 payload를 전송할 것입니다. 헤더, 내용물 종류, 내용물 길이를 설정하거나 리다이렉션 응답을 전송할 수 있습니다. 많은 다른 것들에 대해서는 [API reference](/api#response-toolkit)에 정리되어 있습니다. +핸들러는 두 인자를 받습니다: -## 설정 +- `request`에는 `params`, `query`, `payload`, `headers`, `auth` 등 들어온 요청에 대한 모든 정보가 들어 있습니다. [요청 객체](/api#request)를 참고하세요. +- `h`는 `h.response()`, `h.redirect()` 같은 메서드를 가진 [응답 툴킷](/api#response-toolkit)입니다. -이 세가지 기본 요소를 제외하고 각 라우트에 `options` 인자를 지정할 수 있습니다. 이 인자로 [validation](/tutorials/validation), [authentication](/tutorials/auth), 전처리, 페이로드 처리, 캐시 옵션 같은 것을 설정합니다. 더 자세한 것은 연결된 튜터리얼 및 [API reference](/api#route-options)에서 찾을 수 있습니다. +핸들러가 반환한 값이 응답이 됩니다: -여기에서는 문서를 생성하는데 도움이 되는 몇 가지 옵션을 살펴봅니다. +| 핸들러 반환 값 | 응답 | +| -------------------------------------- | --------------------------------------------------- | +| 문자열 | `200`, `text/html; charset=utf-8` | +| 객체 또는 배열 | `200`, `application/json; charset=utf-8` | +| `Buffer` | `200`, `application/octet-stream` | +| readable 스트림 | `200`, 스트림을 클라이언트로 파이프 | +| 체이닝 호출을 붙인 `h.response(value)` | 완전한 제어. 예: `h.response('created').code(201)` | +| 던진 에러 | 에러 응답. [boom](/module/boom) 에러가 아니면 `500` | -```javascript -server.route({ - method: 'GET', - path: '/hello/{user?}', - handler: function (request, h) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'stranger'; +핸들러는 값이나 프로미스를 반환하거나 예외를 던져야 합니다. hapi가 이 값을 전송할 바이트로 바꾸는 과정은 [응답 마샬링](/tutorials/ko_KR/response-marshalling)에서 다룹니다. - return `Hello ${user}!`; - }, +화살표 함수는 `this`를 쓸 수 없으므로, 화살표 함수로 작성한 핸들러는 [`server.bind()`](/api#server-bind-context)로 설정한 값을 [`h.context`](/api#h-context)에서 읽습니다. + +## 옵션 + +메서드, 경로, 핸들러 외의 모든 것은 라우트의 `options`에 들어갑니다: + +```ts +import Joi from 'joi'; + +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', options: { - description: 'Say hello!', - notes: "The user parameter defaults to 'stranger' if unspecified", - tags: ['api', 'greeting'], + auth: false, + validate: { + payload: Joi.object({ + username: Joi.string().min(1).max(20).required(), + password: Joi.string().min(7).required(), + }), + }, + }, + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` + +| 옵션 | 제어하는 것 | 자세히 보기 | +| ---------- | -------------------------------------- | --------------------------------------------------- | +| `auth` | 적용할 인증 전략 | [인증](/tutorials/ko_KR/auth) | +| `validate` | 파라미터, 쿼리, 페이로드, 헤더 검사 | [검증](/tutorials/ko_KR/validation) | +| `cache` | 응답의 `Cache-Control` 헤더 | [캐싱](/tutorials/ko_KR/caching) | +| `payload` | 본문 파싱, 크기 제한, 업로드 | [라우트 페이로드 옵션](/api#route-options-payload) | +| `pre` | 핸들러보다 먼저 실행되는 메서드 | [라우트 pre 옵션](/api#route-options-pre) | +| `ext` | 이 라우트에만 적용되는 라이프사이클 훅 | [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks) | + +전체 목록은 [라우트 옵션 레퍼런스](/api#route-options)에 있습니다. + +## 404 처리 + +일치하는 라우트가 없으면 hapi는 `404` JSON 에러로 응답합니다. 대신 직접 만든 응답을 보내려면 모든 경로를 받는 라우트를 추가하세요. `{any*}` 와일드카드는 가장 덜 구체적인 일치이므로 다른 라우트가 받지 않는 요청에만 응답합니다: + +```ts +server.route({ + method: '*', + path: '/{any*}', + handler: (request, h) => { + return h.response('404 Error! Page Not Found!').code(404); }, }); ``` -이 옵션에 대해 기능적으로 말하자면 아무런 효과가 없지만 API에 대한 문서를 생성하는 [lout](https://github.com/hapijs/lout)같은 플러그인을 사용할 때는 매우 유용할 수 있습니다. 라우트와 관련된 메타데이는 나중에 검사 또는 표시가 가능해집니다. +이 라우트는 일치하지 않는 경로만 처리합니다. 일치한 라우트가 던진 `404`(예: `Boom.notFound()`를 던지는 핸들러)는 여기에 도달하지 않습니다. 어디서 생겼든 모든 `404`를 바꾸려면 `onPreResponse` 훅에서 응답을 변경하세요. [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks)을 참고하세요. diff --git a/docs/tutorials/pt_BR/routing.md b/docs/tutorials/pt_BR/routing.md index 43ab710a..6f2baa14 100644 --- a/docs/tutorials/pt_BR/routing.md +++ b/docs/tutorials/pt_BR/routing.md @@ -1,117 +1,253 @@ --- -title: Routing +title: Roteamento --- -## Rotas +# Roteamento -Quando definimos uma rota no Hapi, assim como em outros frameworks, são necessários três elementos básicos: o caminho, o método e o manipulador. Estes elementos são passados para o servidor como um objeto, e pode ser tão simples quanto o exemplo seguinte: +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ -```javascript +## Visão geral + +Uma rota precisa de três coisas: um `method`, um `path` e um `handler`. Você a adiciona com [`server.route()`](/api#server-route-route): + +```ts server.route({ method: 'GET', path: '/', - handler: function (request, reply) { - reply('Hello!'); + handler: (request, h) => { + return 'Hello World!'; }, }); ``` -## Métodos +`server.route()` também aceita um array de objetos de rota, então você pode registrar várias rotas em uma única chamada. -Está rota responde a um método `GET` com a `string` 'Hello!'. A opção `method` pode ser qualquer qualquer método http válido, ou uma `array` de métodos. Você dizer que você deseja a mesma resposta quando o usuário envia tanto um pedido `PUT` quanto o `POST`, você poderia fazer isso da seguinte forma: +## Métodos -```javascript +`method` é qualquer método HTTP exceto `HEAD` (rotas GET respondem a requisições HEAD), um array de métodos, ou `'*'` para todos eles: + +```ts server.route({ method: ['PUT', 'POST'], path: '/', - handler: function (request, reply) { - reply('I did something!'); + handler: (request, h) => { + return 'I did something!'; }, }); ``` -## Caminho +Uma rota `GET` também responde a requisições `HEAD`, então você nunca precisa de uma rota `HEAD` separada. -A opção `path` deve ser uma `string` e também poderá conter parâmetros nomeados. Para indicar o parâmetro na `string` basta involve-lo com `{}`. Por exemplo: +## Caminho -```javascript -server.route({ +Envolva um segmento do caminho com `{}` para capturá-lo como um parâmetro nomeado. O valor fica disponível em `request.params`: + +```ts +import * as Hoek from '@hapi/hoek'; + +server.route<{ Params: { user: string } }>({ method: 'GET', path: '/hello/{user}', - handler: function (request, reply) { - reply('Hello ' + encodeURIComponent(request.params.user) + '!'); + handler: (request, h) => { + return `Hello ${Hoek.escapeHtml(request.params.user)}!`; }, }); ``` -Como você pode ver acima, temos o parâmetro `{user}` no nosso caminho, isso significa que estamos atribuindo este segmento do caminho a um parâmetro nomeado. Estes parâmetros são armazenados no objeto `request.params` junto com o manipulador. Desde que nomeamos nosso parâmetro `user` podemos acessar seu valor com a propriedade `request.params.user` depois da codificação da URI de modo a evitar ataques de injeção de conteúdo. +Uma requisição para `/hello/ferris` responde `Hello ferris!`. -### Parâmetros Opcionais +- O hapi faz o URI-decode dos valores dos parâmetros, então `/hello/a%20b` resulta em `request.params.user === 'a b'`. +- O valor vem direto da URL. Faça o escape antes de colocá-lo em HTML, como a chamada `escapeHtml()` do [Hoek](/module/hoek) faz acima, ou valide-o com [joi](/tutorials/pt_BR/validation). +- Desde o hapi 21.4.10, os valores de `request.params` têm o tipo `unknown` por padrão. O argumento de tipo `server.route<{ Params: { user: string } }>()` declara o formato, então `request.params.user` é uma `string` no handler. O mesmo argumento de tipo aceita `Query`, `Payload`, `Headers` e as outras partes da requisição mostradas abaixo. -Neste exemplo, o parâmetro `user` é obrigatório: uma requisição a `/hello/bob` ou `/hello/susan` irá funcionar, porém uma requisição a `/hello` não funcionará. Para criar um parâmetro opcional, basta colocar um ponto de interrogação no final do nome do parâmetro. Veja o mesmo exemplo anterior alterado para o parâmetro `user` ser adicional: +### Tipos de parâmetro -```javascript -server.route({ +| Caminho | Corresponde a | `request.params` | +| ------------------- | --------------------------------------------- | ----------------------------------- | +| `/hello/{user}` | `/hello/ferris` | `{ user: 'ferris' }` | +| `/hello/{user?}` | `/hello`, `/hello/ferris` | `{}` ou `{ user: 'ferris' }` | +| `/hello/{user*2}` | `/hello/john/doe` (exatamente dois segmentos) | `{ user: 'john/doe' }` | +| `/files/{path*}` | `/files`, `/files/a`, `/files/a/b/c` | `{}` ou `{ path: 'a/b/c' }` | +| `/{filename}.{ext}` | `/photo.jpg` | `{ filename: 'photo', ext: 'jpg' }` | + +Regras que o hapi aplica quando você adiciona a rota: + +- `{user*}` e um opcional de segmento inteiro `{user?}` precisam ser o último segmento do caminho. `{user*2}` e um opcional de segmento parcial, como `/a{user?}/b`, podem aparecer em qualquer lugar. +- Dois parâmetros no mesmo segmento precisam de um separador literal entre eles: `{filename}.{ext}` é válido, `{filename}{ext}` não é. +- O nome de um parâmetro só pode aparecer uma vez por caminho. +- Duas rotas com o mesmo método e um caminho equivalente entram em conflito, e `server.route()` lança um erro. `/{name}` e `/{other}` são equivalentes. + +### Parâmetros de múltiplos segmentos + +Um parâmetro de múltiplos segmentos guarda os segmentos correspondentes unidos por `/`, então divida-o para obter as partes: + +```ts +server.route<{ Params: { user: string } }>({ method: 'GET', - path: '/hello/{user?}', - handler: function (request, reply) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'stranger'; - reply('Hello ' + user + '!'); + path: '/hello/{user*2}', + handler: (request, h) => { + const [first, last] = request.params.user.split('/'); + + return `Hello ${first} ${last}!`; }, }); ``` -Agora uma requisição para `/hello/mary` irá responder com `Hello mary!` e a requisição para `/hello` irá responder com `Hello stranger!`. É importante estar ciente que apenas o _último_ parâmetro em um caminho pode ser opcional. Isso significa que `/{one?}/{two}/` é um caminho inválido, isso porque existe outro parâmetro após um opcional. Você também pode ter um parâmetro nomeado parcial em um segmento do caminho, mas você só pode ter um parâmetro por segmento. Isso significa que `/{filename}.jpg` é valido, enquando `/{filename}.{ext}` é inválido. +Uma requisição para `/hello/john/doe` responde `Hello john doe!`. + +### Qual rota vence + +O hapi não testa as rotas na ordem em que você as adicionou. Para cada segmento do caminho, ele testa os tipos de correspondência do mais específico para o menos específico, e passa para o próximo tipo quando o resto do caminho não corresponde: + +```mermaid +flowchart TD + L["1. literal
/filename.jpg"] -->|sem correspondência| M["2. misto
/{name}.jpg"] + M -->|sem correspondência| P["3. parâmetro
/{name}"] + P -->|sem correspondência| W["4. curinga
/{any*}"] + W -->|sem correspondência| N[404 Not Found] +``` + +Com estas quatro rotas registradas: + +| Requisição | Rota que responde | +| --------------- | ----------------- | +| `/filename.jpg` | `/filename.jpg` | +| `/photo.jpg` | `/{name}.jpg` | +| `/photo` | `/{name}` | +| `/a/b` | `/{any*}` | + +Os caminhos diferenciam maiúsculas de minúsculas por padrão: com as rotas acima, `/Filename.jpg` é respondido por `/{name}.jpg`. Defina [`router.isCaseSensitive`](/api#server-options-router) como `false` no servidor para mudar isso. + +## Parâmetros de query + +`request.query` contém a query string já interpretada. Para `/?name=ferris&location=chicago`: + +```ts +server.route<{ Query: { name: string } }>({ + method: 'GET', + path: '/', + handler: (request, h) => { + return `Hello ${request.query.name}!`; + }, +}); +``` -### Parâmetros de multisegmentos +O parser embutido do hapi não entende chaves aninhadas: `/?foo[bar]=baz` resulta em `{ 'foo[bar]': 'baz' }`. Para interpretar chaves aninhadas, defina o `query.parser` do servidor com um parser como o [qs](https://github.com/ljharb/qs): -Junto com o parâmetro de caminho opcional, você também pode permitir que esses parâmetros correspodam com vários segmentos. Para fazer isso, utilizamos um asterisco e um número. Por Exemplo: +```ts +import Hapi from '@hapi/hapi'; +import Qs from 'qs'; + +const server = Hapi.server({ + port: 3000, + host: 'localhost', + query: { + parser: (query) => Qs.parse(query), + }, +}); -```javascript server.route({ method: 'GET', - path: '/hello/{user*2}', - handler: function (request, reply) { - const userParts = request.params.user.split('/'); - reply('Hello ' + encodeURIComponent(userParts[0]) + ' ' + encodeURIComponent(userParts[1]) + '!'); + path: '/', + handler: (request, h) => { + return request.query; }, }); + +await server.start(); +console.log('Server running on %s', server.info.uri); ``` -Com essa configuração, uma requisição para `/hello/john/doe` responderá com a string `Hello john doe!`. É importante notar que o parâmetro possui uma sequencia de caracteres com `/`. E por isso que fizemos um split no parâmetro para obter o usuário em duas partes separadas. O número após o asterisco indica quantos segmentos de caminho deve ser atribuído ao parâmetro. Você também pode omitir o numero, nesse caso o parâmetro irá coincidir com qualquer numero de segmentos. Como os parâmetros opcionais, um parâmetro curinga pode apenas aparecer no último parâmetro em seu caminho. +Agora `/?foo[bar]=baz` responde `{ "foo": { "bar": "baz" } }`. O parser recebe a query que o hapi já interpretou, como `{ 'foo[bar]': 'baz' }`, e não a string bruta. Qualquer função serve, desde que retorne um objeto que mapeie nomes de parâmetros para valores. + +## Payload da requisição -Ao determinar o manipulador a ser utilizado em um requisição em particular, o hapi pesquisa caminhos do menos específico para o mais específico. Isso significa que caso você tenha duas rotas, uma com o caminho `/filename.jpg` e a outra com o caminho `/filename.{ext}` a requisição para `/filename.jpg` irá coincidir com o primeiro caminho e não o segundo. Da mesma forma que uma rota para `/{files*}` será a ultima rota testada, e só corresponderá se todas as outras rotas falharem. +O corpo da requisição, já interpretado, fica em `request.payload`. O hapi interpreta corpos JSON, form-encoded e de texto com base no header `Content-Type`. Corpos multipart precisam da opção de rota `payload: { multipart: true }`; sem ela, recebem `415 Unsupported Media Type`: -## Método manipulador +```ts +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` -A opção `handler` é uma função que aceita dois parâmetros, `request` e `reply`. +O argumento de tipo só informa o formato ao TypeScript. Nada é verificado em tempo de execução até você adicionar a [validação do payload](/tutorials/pt_BR/validation). -O parâmetro `request` é um objeto com detalhes sobre a solicitação do usuário final, como parâmetros de caminho, carga associada, informações de autenticação, cabeçalhos, etc... Toda a documentação sobre o que o objeto `request` contem pode ser encontrado na [referência de API](/api#request-properties). +## Handler -O segundo parâmetro, `reply`, é um método usado para responder as requisições. Como você viu nos exemplos anteriores, se você deseja responder com uma carga útil, você precisa simplesmente passar como um parâmetro para o `reply`. A resposta pode ser uma string, um buffer um objeto serializado em json ou uma stream. O resultado de `reply` é um objeto de resposta, que pode ser encadeado com métodos adicionais para alterar a resposta antes de serem enviados. Por exemplo `reply('created').code(201)` enviará uma carga de trabalho `created` com um código de status `201`. Você também pode atribuir valores aos `header`, `content-type`, `content-lenght`, enviar uma resposta direta, e muitas outras coisas que são documentadas na [referência de API](/api#response-object). +O handler recebe dois argumentos: -## Configuração +- `request` contém tudo sobre a requisição recebida: `params`, `query`, `payload`, `headers`, `auth` e mais. Veja o [objeto de requisição](/api#request). +- `h` é o [response toolkit](/api#response-toolkit), com métodos como `h.response()` e `h.redirect()`. -Além desses três elementos basicos, você também pode especificar um parâmetro `options` para cada rota. É onde você configura coisas como [validação](/tutorials/validation), [autenticação](/tutorials/auth), pré-requisitos, processo de carga, e opções de `cache`. Mais detalhes podem ser encontrados nos tutoriais, assim como na [referência de API](/api#route-options). +O que o handler retorna vira a resposta: -Aqui vamos exemplificar um par de opções concebidas para ajudar a gerar a documentação: +| O handler retorna | Resposta | +| ------------------------------------------- | ---------------------------------------------------------------------------- | +| uma string | `200`, `text/html; charset=utf-8` | +| um objeto ou array | `200`, `application/json; charset=utf-8` | +| um `Buffer` | `200`, `application/octet-stream` | +| um readable stream | `200`, o stream encaminhado ao cliente | +| `h.response(value)` com chamadas encadeadas | controle total, por exemplo `h.response('created').code(201)` | +| um erro lançado | uma resposta de erro, `500` a menos que seja um erro do [boom](/module/boom) | -```javascript -server.route({ - method: 'GET', - path: '/hello/{user?}', - handler: function (request, reply) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'stranger'; - reply('Hello ' + user + '!'); - }, +Um handler precisa retornar um valor, uma promise, ou lançar um erro. [Marshalling de respostas](/tutorials/pt_BR/response-marshalling) explica como o hapi transforma esses valores nos bytes que envia. + +Arrow functions não podem usar `this`, então um handler escrito como arrow function lê o valor definido por [`server.bind()`](/api#server-bind-context) a partir de [`h.context`](/api#h-context). + +## Opções + +Tudo além de method, path e handler vai nas `options` da rota: + +```ts +import Joi from 'joi'; + +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', options: { - description: 'Say hello!', - notes: "The user parameter defaults to 'stranger' if unspecified", - tags: ['api', 'greeting'], + auth: false, + validate: { + payload: Joi.object({ + username: Joi.string().min(1).max(20).required(), + password: Joi.string().min(7).required(), + }), + }, + }, + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` + +| Opção | O que controla | Saiba mais | +| ---------- | --------------------------------------------------- | ------------------------------------------------------- | +| `auth` | qual strategy de autenticação se aplica | [Autenticação](/tutorials/pt_BR/auth) | +| `validate` | verificações de params, query, payload e headers | [Validação](/tutorials/pt_BR/validation) | +| `cache` | headers `Cache-Control` da resposta | [Cache](/tutorials/pt_BR/caching) | +| `payload` | interpretação do corpo, limites de tamanho, uploads | [opções de payload da rota](/api#route-options-payload) | +| `pre` | métodos que rodam antes do handler | [opções pre da rota](/api#route-options-pre) | +| `ext` | lifecycle hooks só para esta rota | [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks) | + +A lista completa está na [referência de opções de rota](/api#route-options). + +## Tratamento de 404 + +Quando nenhuma rota corresponde, o hapi responde com um erro `404` em JSON. Para enviar sua própria resposta, adicione uma rota pega-tudo. O curinga `{any*}` é a correspondência menos específica, então ele só responde às requisições que nenhuma outra rota atende: + +```ts +server.route({ + method: '*', + path: '/{any*}', + handler: (request, h) => { + return h.response('404 Error! Page Not Found!').code(404); }, }); ``` -Em aspecto de funcionalidades isso não tem nenhum efeito, entretando eles podem ser muito valiosos ao utilizar plugins como [lout](https://github.com/hapijs/lout) para gerar a documentação de sua API. O `metadate` é associado com esta rota, e logo depois se torna disponível para inspeção ou visualização. +A rota pega-tudo só cobre caminhos sem correspondência. Um `404` lançado por uma rota que correspondeu, como um handler que lança `Boom.notFound()`, nunca chega até ela. Para personalizar todo `404`, seja qual for a origem, altere a resposta em um hook `onPreResponse`; veja [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks). diff --git a/docs/tutorials/tr_TR/routing.md b/docs/tutorials/tr_TR/routing.md index 0931bc13..df10aa61 100644 --- a/docs/tutorials/tr_TR/routing.md +++ b/docs/tutorials/tr_TR/routing.md @@ -4,120 +4,250 @@ title: Routing -## Yol Tayin Etmek (routing) +# Routing -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -Aynı diğer çatılarda (framework) olduğu gibi, hapide de bir yol tanımlarken üç değiştirgeye ihtiyacın var: path (güzergah), method (yöntem) ve handler (işleç). Bunlar sunucuna bir nesne olarak gönderilir ve bu kadar da basittir: +## Genel Bakış -```javascript +Bir route'un üç şeye ihtiyacı vardır: bir `method`, bir `path` ve bir `handler`. Route'u [`server.route()`](/api#server-route-route) ile eklersin: + +```ts server.route({ method: 'GET', path: '/', - handler: function (request, h) { - return 'Selam!'; + handler: (request, h) => { + return 'Hello World!'; }, }); ``` -## Yöntemler (method) +`server.route()` bir route nesneleri dizisini de kabul eder, böylece tek çağrıda birçok route kaydedebilirsin. + +## Metotlar -Yukarıdaki yol (route) `/` güzergahına yapılan bir `GET` isteğine `Selam!` metinyla cevap verir. method (yöntem) seçeneği geçerli bir HTTP yöntemi ya da bir yöntem dizisi olabilir. Diyelim ki kullanıcıların `PUT` yöntemiyle de `POST` yöntemiyle de istekte bulunsa aynı cevabı vermek istiyorsun. Öyleyse şöyle yapabilirsin: +`method`, `HEAD` dışındaki herhangi bir HTTP metodu (GET route'ları HEAD isteklerine de yanıt verir), bir metot dizisi ya da hepsi için `'*'` olabilir: -```javascript +```ts server.route({ method: ['PUT', 'POST'], path: '/', - handler: function (request, h) { - return 'Bir şey yaptım!'; + handler: (request, h) => { + return 'I did something!'; }, }); ``` -## Güzergah (path) +`GET` için tanımlanan bir route `HEAD` isteklerine de yanıt verir, bu yüzden ayrı bir `HEAD` route'una hiç ihtiyacın olmaz. -Güzergah seçeneği bir metin olmalı. Bununla birlikte içerisinde adlandırılmış değiştirgeler bulundurabilir. Bir güzergahtaki bir değiştirgeyi adlandırmak için onu `{}` içerisine alman yeterli. Örneğin: +## Path -```javascript -server.route({ +Bir path segmentini adlandırılmış bir parametre olarak yakalamak için onu `{}` içine al. Değeri `request.params` üzerinden erişilebilir olur: + +```ts +import * as Hoek from '@hapi/hoek'; + +server.route<{ Params: { user: string } }>({ method: 'GET', - path: '/selam/{user}', - handler: function (request, h) { - return `Selam ${encodeURIComponent(request.params.user)}!`; + path: '/hello/{user}', + handler: (request, h) => { + return `Hello ${Hoek.escapeHtml(request.params.user)}!`; }, }); ``` -Yukarıda gördüğün üzere, güzergahımızda `{user}` metin bulunuyor. Bu da demek oluyor ki güzergahın bu kısmında adlandırılmış bir değiştirge istiyoruz. Bu değiştirgeler işleyicide `request.params` içerisinde tutulur. `user` değiştirgemizi adlandırdığımız için `request.params.user`dan erişebiliriz. Tabi içerik yerleştirme saldırılarına karşı URI kodlamadan geçirdikten sonra. +`/hello/ferris` isteği `Hello ferris!` yanıtını döner. -### Secime bagli degistirgeler +- hapi parametre değerlerinin URI kodlamasını çözer, yani `/hello/a%20b` için `request.params.user === 'a b'` olur. +- Değer doğrudan URL'den gelir. HTML'e koymadan önce, yukarıdaki [Hoek](/module/hoek) `escapeHtml()` çağrısında olduğu gibi escape et ya da [joi](/tutorials/tr_TR/validation) ile doğrula. +- hapi 21.4.10'dan itibaren `request.params` değerleri varsayılan olarak `unknown` tipindedir. `server.route<{ Params: { user: string } }>()` tip argümanı yapıyı bildirir, böylece handler içinde `request.params.user` bir `string` olur. Aynı tip argümanı `Query`, `Payload`, `Headers` ve aşağıdaki diğer istek parçalarını da kabul eder. -Yukarıdaki örnekte, user değiştirgesi gereklidir: `/selam/hilmi` ya da `/selam/erdem` çalışır ancak `/Selam` çalışmaz. Bir değiştirgeyi seçime bağlı kılmak için; değiştirge adının sonuna bir soru işareti koyman yeterli olur. Aşağıda aynı yol, bu kez `user` değiştirgesi seçime bağlı olarak gösteriliyor: +### Parametre tipleri -```javascript -server.route({ +| Path | Eşleşir | `request.params` | +| ------------------- | ------------------------------------------ | ----------------------------------- | +| `/hello/{user}` | `/hello/ferris` | `{ user: 'ferris' }` | +| `/hello/{user?}` | `/hello`, `/hello/ferris` | `{}` ya da `{ user: 'ferris' }` | +| `/hello/{user*2}` | `/hello/john/doe` (tam olarak iki segment) | `{ user: 'john/doe' }` | +| `/files/{path*}` | `/files`, `/files/a`, `/files/a/b/c` | `{}` ya da `{ path: 'a/b/c' }` | +| `/{filename}.{ext}` | `/photo.jpg` | `{ filename: 'photo', ext: 'jpg' }` | + +Route'u eklediğinde hapi'nin uyguladığı kurallar: + +- `{user*}` ve tüm segmenti kaplayan isteğe bağlı `{user?}` path'in son segmenti olmalıdır. `{user*2}` ve `/a{user?}/b` gibi segmentin bir kısmını kaplayan isteğe bağlı parametreler her yerde bulunabilir. +- Aynı segmentteki iki parametrenin arasında sabit bir ayırıcı olmalıdır: `{filename}.{ext}` geçerlidir, `{filename}{ext}` değildir. +- Bir parametre adı bir path içinde yalnızca bir kez kullanılabilir. +- Aynı metoda ve eşdeğer bir path'e sahip iki route çakışır ve `server.route()` hata fırlatır. `/{name}` ve `/{other}` eşdeğerdir. + +### Çok segmentli parametreler + +Çok segmentli bir parametre, eşleşen segmentleri `/` ile birleştirilmiş olarak tutar; parçaları almak için onu böl: + +```ts +server.route<{ Params: { user: string } }>({ method: 'GET', - path: '/selam/{user?}', - handler: function (request, h) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'yabancı'; + path: '/hello/{user*2}', + handler: (request, h) => { + const [first, last] = request.params.user.split('/'); - return `Selam ${user}!`; + return `Hello ${first} ${last}!`; }, }); ``` -Bu kez `/selam/keren` adresine yapılan bir istek `Selam keren!` olarak, `/Selam` adresine yapılan bir istekse; `Selam yabancı!` olarak cevaplanacaktır. Yalnız farkında olunması önemli bir husus var: yalnızca yolun sonundaki değiştirge seçime bağlı olabilir. Yani `/{bir?}/{iki}/` şeklindeki bir güzergah geçerli olmayacaktır çünkü bu durumda seçime bağlı olandan sonra bir değiştirge daha var. Bölmenin yalnızca bir kısmını etkileyen adlandırılmış bir değiştirgen olması da `/{dosyaadi}.jpg` uygundur. Hatta aralarında değiştirge olmayan bir ayraç olduğu sürece bir bölme içerisinde birden fazla adlandırılmış değiştirge de kullanabilirsin. Yani `/{dosyaadi}.{uzanti}` geçerliyken `/{dosyaadi}{uzanti}` geçersizdir. +`/hello/john/doe` isteği `Hello john doe!` yanıtını döner. + +### Hangi route kazanır + +hapi route'ları eklediğin sırayla denemez. Her path segmenti için eşleşme türlerini en özelden en genele doğru dener ve path'in geri kalanı eşleşmediğinde bir sonraki türe geçer: + +```mermaid +flowchart TD + L["1. sabit
/filename.jpg"] -->|eşleşme yok| M["2. karma
/{name}.jpg"] + M -->|eşleşme yok| P["3. parametre
/{name}"] + P -->|eşleşme yok| W["4. joker
/{any*}"] + W -->|eşleşme yok| N[404 Not Found] +``` + +Bu dört route kayıtlıyken: + +| İstek | Yanıt veren route | +| --------------- | ----------------- | +| `/filename.jpg` | `/filename.jpg` | +| `/photo.jpg` | `/{name}.jpg` | +| `/photo` | `/{name}` | +| `/a/b` | `/{any*}` | + +Path'ler varsayılan olarak büyük/küçük harfe duyarlıdır: yukarıdaki route'larla `/Filename.jpg` isteğine `/{name}.jpg` yanıt verir. Bunu değiştirmek için sunucuda [`router.isCaseSensitive`](/api#server-options-router) değerini `false` yap. + +## Query parametreleri + +`request.query`, ayrıştırılmış query string'i tutar. `/?name=ferris&location=chicago` için: + +```ts +server.route<{ Query: { name: string } }>({ + method: 'GET', + path: '/', + handler: (request, h) => { + return `Hello ${request.query.name}!`; + }, +}); +``` -### Cok bolmeli degistirgeler +hapi'nin yerleşik ayrıştırıcısı iç içe anahtarları anlamaz: `/?foo[bar]=baz` sonucu `{ 'foo[bar]': 'baz' }` olur. İç içe anahtarları ayrıştırmak için sunucunun `query.parser` ayarına [qs](https://github.com/ljharb/qs) gibi bir ayrıştırıcı ver: -Seçime bağlı güzergah değiştirgelerinin yanı sıra, birden çok bölmeyle eşleşen değiştirgelerin de olabilir. Bunun için bir yıldız imi ve bir de sayı kullanıyoruz. Örneğin: +```ts +import Hapi from '@hapi/hapi'; +import Qs from 'qs'; + +const server = Hapi.server({ + port: 3000, + host: 'localhost', + query: { + parser: (query) => Qs.parse(query), + }, +}); -```javascript server.route({ method: 'GET', - path: '/selam/{user*2}', - handler: function (request, h) { - const userParts = request.params.user.split('/'); - return `Selam ${encodeURIComponent(userParts[0])} ${encodeURIComponent(userParts[1])}!`; + path: '/', + handler: (request, h) => { + return request.query; }, }); + +await server.start(); +console.log('Server running on %s', server.info.uri); ``` -Bu yapılandırmayla, `/selam/ali/veli` güzergahına yapılan bir istek `Selam ali veli!` metinyla cevaplanır. Burada önemli olan değişgenin `"ali/veli"` metinnın tamamı olmasıdır. Bu yüzden metinna ayrıştırma uygulayarak iki parça elde ettik. Yıldız içinden sonraki sayı değiştirgeye kaç bölme atanacağını belirler. Numarayı hiç yazmaya da bilirsin. Bu durumda değiştirge var olan bütün bölmelerle eşlenir. +Artık `/?foo[bar]=baz` isteği `{ "foo": { "bar": "baz" } }` yanıtını döner. Ayrıştırıcı ham string'i değil, hapi'nin zaten ayrıştırdığı query'yi alır; örneğin `{ 'foo[bar]': 'baz' }`. Parametre adlarını değerlere eşleyen bir nesne döndürdüğü sürece herhangi bir fonksiyon iş görür. -Belirli bir istek için hangi işleç kullanılacağına karar vermek için hapi, en kesin olandan en az kesin olana doğru güzergahları arar. Örneğin `/dosyaadi.jpg` ve `/dosyaadi.{uzanti}` şeklinde iki güzergah tanımlarsanız; `/dosyaadi.jpg`ye yapılan bir istek ilk güzergahla eşleşecek, ikincisi ile eşleşmeyecektir. Bu aynı zamanda `/{dosyalar*}` şeklinde tanımlanmış bir güzergahın denenen _son_ güzergah olacağı ve yalnızca diğer güzergahların eşleşme denemeleri sonuçsuz kaldığında eşleşeceği anlamına gelir. +## İstek payload'u -## Islec yöntem +Ayrıştırılmış istek gövdesi `request.payload` üzerindedir. hapi, `Content-Type` header'ına göre JSON, form-encoded ve metin gövdelerini ayrıştırır. Multipart gövdeler için `payload: { multipart: true }` route seçeneği gerekir; bu seçenek yoksa `415 Unsupported Media Type` alırlar: -İşleç seçeneği iki değiştirge kabul eden bir işlevdir: `request` ve `h`. +```ts +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` -`request` değiştirgesi son kullanıcının isteğinin güzergah değiştirgesi, iliştirilen yararlı veri, kimlik doğrulama bilgisi, başlık ve benzeri detaylarını taşıyan bir nesnedir. `request` (istek) nesnesinin içeriğinin ne olduğuna dair tam bir dokümantasyon [API referansı](/api#request-properties)nda bulunabilir. +Tip argümanı yalnızca TypeScript'e yapıyı söyler. [Payload doğrulama](/tutorials/tr_TR/validation) ekleyene kadar çalışma zamanında hiçbir şey onu kontrol etmez. -İkinci değiştirge olan `h` ise isteğe cevap vermekte kullanılan bir çok yöntem barındıran yanıt alet takımıdır. Önceki örneklerde gördüğün üzere, eğer bir isteğe bir değerle cevap vereceksen işleçten onu dönmen yeter. Yararlı yük bir metin, tampon (buffer), JSON'a çevrilebilen bir nesne, akan bir veri (stream) ya da söz (promise) olabilir. +## Handler -Alternatif olarak aynı değerleri `h.response(value)` içerisine gönderek burdan gelen sonucu işleçten dönebilirsin. Bu çağrının sonucunda gönderilmeden önce üzerinde zincir çağrılar yapılarak içeriği değiştirebilen bir yanıt (response) nesnesi olur. Örneğin `h.response('created').code(201)` çağrısının yararlı verisi HTTP durum kodu `201` olan `created` (oluşturuldu) olur. Eğer istersen başlıkları, içerik tipini (content type), içerik uzunluğunu değiştirebilir; yönlendirme yanıtı verebilir ve [API referansı](/api#response-toolkit)nda dokümante edilen daha bir çok şey yapabilirsin. +Handler iki argüman alır: -## Yapilandirma +- `request`, gelen istekle ilgili her şeyi tutar: `params`, `query`, `payload`, `headers`, `auth` ve daha fazlası. [Request nesnesi](/api#request) referansına bak. +- `h`, `h.response()` ve `h.redirect()` gibi metotları olan [response toolkit](/api#response-toolkit)'tir. -Bu üç basit elemanın yanında her bir yola (route) bir de `options` (yapılandırma) değiştirgesi gönderebilirsin. Burada [veri doğrulama (validation)](/tutorials/validation), [kimlik doğrulama (authentication)](/tutorials/auth), ön gereksinimler, yararlı yük işlemleri, ve önbellekleme seçeneklerini ayarlayabilirsin. Bağlantıları verilen öğreticiler ve [API referansı](/api#route-options)nda daha fazla bilgi bulabilirsin. +Handler'ın döndürdüğü değer yanıt olur: -Burada dokümantasyon üretmek için tasarlanan bir kaç seçeneğe bakıyoruz: +| Handler'ın döndürdüğü | Yanıt | +| ----------------------------------------- | ---------------------------------------------------------- | +| bir string | `200`, `text/html; charset=utf-8` | +| bir nesne ya da dizi | `200`, `application/json; charset=utf-8` | +| bir `Buffer` | `200`, `application/octet-stream` | +| bir readable stream | `200`, stream istemciye aktarılır | +| zincirleme çağrılarla `h.response(value)` | tam kontrol, örneğin `h.response('created').code(201)` | +| fırlatılan bir hata | bir hata yanıtı; [boom](/module/boom) hatası değilse `500` | -```javascript -server.route({ - method: 'GET', - path: '/selam/{user?}', - handler: function (request, h) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'stranger'; +Bir handler bir değer ya da promise döndürmeli veya hata fırlatmalıdır. hapi'nin bu değerleri gönderdiği baytlara nasıl dönüştürdüğü [Yanıt Hazırlama](/tutorials/tr_TR/response-marshalling) eğitiminde anlatılıyor. - return `Selam ${user}!`; - }, +Arrow function'lar `this` kullanamaz; bu yüzden arrow function olarak yazılmış bir handler, [`server.bind()`](/api#server-bind-context) ile ayarlanan değeri bunun yerine [`h.context`](/api#h-context) üzerinden okur. + +## Seçenekler + +Method, path ve handler dışındaki her şey route'un `options` alanına girer: + +```ts +import Joi from 'joi'; + +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', options: { - description: 'Say Selam!', - notes: "The user parameter defaults to 'stranger' if unspecified", - tags: ['api', 'greeting'], + auth: false, + validate: { + payload: Joi.object({ + username: Joi.string().min(1).max(20).required(), + password: Joi.string().min(7).required(), + }), + }, + }, + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` + +| Seçenek | Neyi kontrol eder | Daha fazlası | +| ---------- | ------------------------------------------------- | ----------------------------------------------------------- | +| `auth` | hangi kimlik doğrulama stratejisinin uygulanacağı | [Kimlik Doğrulama](/tutorials/tr_TR/auth) | +| `validate` | params, query, payload ve header kontrolleri | [Doğrulama](/tutorials/tr_TR/validation) | +| `cache` | yanıtın `Cache-Control` header'ları | [Önbellekleme](/tutorials/tr_TR/caching) | +| `payload` | gövde ayrıştırma, boyut sınırları, yüklemeler | [route payload seçenekleri](/api#route-options-payload) | +| `pre` | handler'dan önce çalışan metotlar | [route pre seçenekleri](/api#route-options-pre) | +| `ext` | yalnızca bu route için yaşam döngüsü hook'ları | [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks) | + +Tam liste [route seçenekleri referansında](/api#route-options). + +## 404 yönetimi + +Hiçbir route eşleşmediğinde hapi bir `404` JSON hatasıyla yanıt verir. Bunun yerine kendi yanıtını göndermek için her şeyi yakalayan bir route ekle. `{any*}` jokeri en genel eşleşmedir, bu yüzden yalnızca başka hiçbir route'un almadığı isteklere yanıt verir: + +```ts +server.route({ + method: '*', + path: '/{any*}', + handler: (request, h) => { + return h.response('404 Error! Page Not Found!').code(404); }, }); ``` -Fonksiyonel olarak bu yapılandırmanın hiç bir etkisi yok ancak eğer [lout](https://github.com/hapijs/lout) gibi bir eklenti ile API dokümantasyonu oluştururken bu bilgiler çok değerli oluyor. Bu yardımcı veriler yol (route) hakkında bilgi veriyor ve daha sonra kontrol ederken ve görüntülerken kullanıma hazır oluyor. +Her şeyi yakalayan route yalnızca eşleşmeyen path'leri kapsar. Eşleşen bir route'un fırlattığı bir `404`, örneğin `Boom.notFound()` fırlatan bir handler, ona hiç ulaşmaz. Hangi kaynaktan gelirse gelsin her `404`'ü özelleştirmek için yanıtı bir `onPreResponse` hook'unda değiştir; [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks) eğitimine bak. diff --git a/docs/tutorials/zh_CN/routing.md b/docs/tutorials/zh_CN/routing.md index 919ef3e0..d1803ed0 100644 --- a/docs/tutorials/zh_CN/routing.md +++ b/docs/tutorials/zh_CN/routing.md @@ -1,123 +1,253 @@ --- -title: Routing +title: 路由 --- -## 路由 +# 路由 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更高版本_ -在 hapi 定义路由与其他框架类似,你需要指定三个基本的元素: method, path 以及 handler。 他们将作为一个对象传入到你的服务器对象中,如下: +## 概述 -```javascript +一个路由需要三样东西:`method`、`path` 和 `handler`。用 [`server.route()`](/api#server-route-route) 添加路由: + +```ts server.route({ method: 'GET', path: '/', - handler: function (request, h) { - return 'Hello!'; + handler: (request, h) => { + return 'Hello World!'; }, }); ``` -## 方法 +`server.route()` 也接受路由对象数组,因此你可以一次注册多个路由。 + +## 方法 -上面的代码将对 `/` 路径下的 `GET` 请求作出响应,并返回一个 `Hello!` 字符串。 method 可以接收任何有效的 HTTP 方法或者方法数组。当你希望响应 `PUT` 或`POST` 请求时,可以做如下修改: +`method` 可以是除 `HEAD` 以外的任何 HTTP 方法(GET 路由会响应 HEAD 请求),也可以是方法数组,或者用 `'*'` 表示所有方法: -```javascript +```ts server.route({ method: ['PUT', 'POST'], path: '/', - handler: function (request, h) { + handler: (request, h) => { return 'I did something!'; }, }); ``` -## 路径 +`GET` 路由同样会响应 `HEAD` 请求,所以你永远不需要单独定义 `HEAD` 路由。 -path 参数必须为一个字符串, 虽然他们可以包含一个命名的参数。 如果需要对路径中的参数进行命名,需要使用 `{}` 包围起来,例如: +## 路径 -```javascript -server.route({ +把路径中的某一段用 `{}` 包起来,就能把它捕获为命名参数。它的值可以在 `request.params` 中取得: + +```ts +import * as Hoek from '@hapi/hoek'; + +server.route<{ Params: { user: string } }>({ method: 'GET', path: '/hello/{user}', - handler: function (request, h) { - return `Hello ${encodeURIComponent(request.params.user)}!`; + handler: (request, h) => { + return `Hello ${Hoek.escapeHtml(request.params.user)}!`; }, }); ``` -如上所示,在路径中我们使用了字符串 `{user}`。 着意味着路径中这段位置的内容将赋值于一个命名的参数,这个参数将在这个 handler 内位于 `request.params` 对象中。 我们现在可以通过 `request.params.user` 访问 user 的值, 在 URI 编码后,这也可以防范内容注入攻击。 +请求 `/hello/ferris` 会返回 `Hello ferris!`。 -### 可选参数 +- hapi 会对参数值做 URI 解码,所以 `/hello/a%20b` 得到 `request.params.user === 'a b'`。 +- 这个值直接来自 URL。把它放进 HTML 之前要先转义,就像上面调用 [Hoek](/module/hoek) 的 `escapeHtml()` 那样,或者用 [joi](/tutorials/zh_CN/validation) 验证它。 +- 从 hapi 21.4.10 开始,`request.params` 的值默认类型为 `unknown`。类型参数 `server.route<{ Params: { user: string } }>()` 声明了它的结构,因此在 handler 中 `request.params.user` 是 `string`。同一个类型参数还接受 `Query`、`Payload`、`Headers` 以及下文介绍的其他请求部分。 -在上面的示例中,user 参数是必须要有的: 一个请求如 `/hello/bob` 或 `/hello/susan` 将会正常工作。 但是一个请求如 `/hello` 就不能工作了。可以通过在参数的最后添加一个问号使得参数变为可选的。以下代码为在同样的路由下使用可选参数: +### 参数类型 -```javascript -server.route({ +| 路径 | 匹配 | `request.params` | +| ------------------- | ------------------------------------ | ----------------------------------- | +| `/hello/{user}` | `/hello/ferris` | `{ user: 'ferris' }` | +| `/hello/{user?}` | `/hello`、`/hello/ferris` | `{}` 或 `{ user: 'ferris' }` | +| `/hello/{user*2}` | `/hello/john/doe`(恰好两段) | `{ user: 'john/doe' }` | +| `/files/{path*}` | `/files`、`/files/a`、`/files/a/b/c` | `{}` 或 `{ path: 'a/b/c' }` | +| `/{filename}.{ext}` | `/photo.jpg` | `{ filename: 'photo', ext: 'jpg' }` | + +添加路由时 hapi 会强制执行以下规则: + +- `{user*}` 和占据整段的可选参数 `{user?}` 必须是路径的最后一段。`{user*2}` 以及只占部分段的可选参数(例如 `/a{user?}/b`)可以出现在任意位置。 +- 同一段中的两个参数之间需要有字面分隔符:`{filename}.{ext}` 有效,`{filename}{ext}` 无效。 +- 一个参数名在同一路径中只能出现一次。 +- 方法相同且路径等价的两个路由会冲突,`server.route()` 会抛出错误。`/{name}` 和 `/{other}` 是等价的。 + +### 多段参数 + +多段参数保存的是用 `/` 连接起来的匹配段,所以要拆分它才能得到各个部分: + +```ts +server.route<{ Params: { user: string } }>({ method: 'GET', - path: '/hello/{user?}', - handler: function (request, h) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'stranger'; + path: '/hello/{user*2}', + handler: (request, h) => { + const [first, last] = request.params.user.split('/'); - return `Hello ${user}!`; + return `Hello ${first} ${last}!`; }, }); ``` -现在一个请求发往 `/hello/mary` 将会返回 `Hello mary!` 如果只是发给 `/hello` 将会返回 `Hello stranger!`。 这里需要注意的是在路径中只有 _最后_ 一个命名的参数可以为可选的。 这意味着 `/{one?}/{two}/` 是不合法的路径, 因为在可选参数后有另一个命名的参数。你可以针对路径内容的一部分进行命名,例如 `/{filename}.jpg` 是合法的。你可以指定多个命名参数,但是请注意需要用非参的分隔符进行分割。 这意味着 `/{filename}.{ext}` 是合法的而 `/{filename}{ext}` 不是。 +请求 `/hello/john/doe` 会返回 `Hello john doe!`。 + +### 哪个路由胜出 + +hapi 并不按照你添加路由的顺序逐个尝试。对路径的每一段,它按从最具体到最不具体的顺序尝试各种匹配方式,当路径的剩余部分匹配不上时,再退回到下一种方式: + +```mermaid +flowchart TD + L["1. 字面量
/filename.jpg"] -->|不匹配| M["2. 混合
/{name}.jpg"] + M -->|不匹配| P["3. 参数
/{name}"] + P -->|不匹配| W["4. 通配符
/{any*}"] + W -->|不匹配| N[404 Not Found] +``` + +注册了这四个路由后: + +| 请求 | 响应的路由 | +| --------------- | --------------- | +| `/filename.jpg` | `/filename.jpg` | +| `/photo.jpg` | `/{name}.jpg` | +| `/photo` | `/{name}` | +| `/a/b` | `/{any*}` | + +路径默认区分大小写:在上面的路由下,`/Filename.jpg` 由 `/{name}.jpg` 响应。在服务器上把 [`router.isCaseSensitive`](/api#server-options-router) 设为 `false` 可以改变这一点。 + +## 查询参数 + +`request.query` 保存解析后的查询字符串。对于 `/?name=ferris&location=chicago`: + +```ts +server.route<{ Query: { name: string } }>({ + method: 'GET', + path: '/', + handler: (request, h) => { + return `Hello ${request.query.name}!`; + }, +}); +``` -### 多段参数 +hapi 内置的解析器不认识嵌套键:`/?foo[bar]=baz` 得到的是 `{ 'foo[bar]': 'baz' }`。要解析嵌套键,请把服务器的 `query.parser` 设为 [qs](https://github.com/ljharb/qs) 这样的解析器: -除了可选的路径参数,参数也可以匹配多段内容,可以通过 \* 或数字来完成。例如: +```ts +import Hapi from '@hapi/hapi'; +import Qs from 'qs'; + +const server = Hapi.server({ + port: 3000, + host: 'localhost', + query: { + parser: (query) => Qs.parse(query), + }, +}); -```javascript server.route({ method: 'GET', - path: '/hello/{user*2}', - handler: function (request, h) { - const userParts = request.params.user.split('/'); - return `Hello ${encodeURIComponent(userParts[0])} ${encodeURIComponent(userParts[1])}!`; + path: '/', + handler: (request, h) => { + return request.query; }, }); + +await server.start(); +console.log('Server running on %s', server.info.uri); ``` -通过这个配置, 请求 `/hello/john/doe` 将会返回字符串 `Hello john doe!`。重点要注意的是,这里的参数实际上是字符串 `"john/doe"`. 所以需要通过 `split` 分割来获取里面的内容。 _ 后面的数字表示有多少段内容将会被赋予参数。 你也可以完全省略这个数字,该参数将会匹配任意数量的可用段。 正如同可选参数一样, 通配参数 (如 `/{files_}`) _只会_ 只能出现在路径的最后。 +现在 `/?foo[bar]=baz` 返回 `{ "foo": { "bar": "baz" } }`。解析器接收的是 hapi 已经解析过的查询对象,例如 `{ 'foo[bar]': 'baz' }`,而不是原始字符串。任何函数都可以,只要它返回一个参数名到值的对象。 -对于处理请求时,越是具体的路径参数,hapi将越优先使用。这意味着当你拥有两个路由时,一个路径为 `/filename.jpg` 另一个路径为 `/filename.{ext}`。当一个请求访问 `/filename.jpg` 将会匹配到第一个而不是第二个。 这意味着一个路由的路径参数为 `/{files*}`时,将会是 _最后一个_ 使用的路由,而且只会在前面的路由都无法匹配才会这样。 +## 请求 payload -## Handler 方法 +解析后的 payload 位于 `request.payload`。hapi 会根据 `Content-Type` 请求头解析 JSON、表单编码和文本格式的 payload。multipart payload需要路由选项 `payload: { multipart: true }`;没有它会得到 `415 Unsupported Media Type`: -Handler 是一个接收两个参数的函数, `request` 和 `h`。 +```ts +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` -`request` 是用户请求对象的具体信息,例如参数、请求内容数据, 授权信息以及http头部信息等。 关于 `request` 对象所包含的具体信息的文档可以在这里查阅 [API reference](/api#request-properties)。 +类型参数只是告诉 TypeScript 数据的结构。在你添加 [payload 验证](/tutorials/zh_CN/validation)之前,运行时不会做任何检查。 -第二个参数 `h` 是响应处理对象,它包含了一些处理请求的方法。 如同你之前看到的, 如果你需要一些返回值, 你可以直接通过 handler 返回。 返回的内容可以是字符串,buffer,JSON 序列化对象, stream 或者 promise。 +## Handler -另外你也可以将上述的返回值传递给 `h.response(value)` 来返回。 这个方法调用的返回值是一个响应对象, 可以在响应发出前通过链式调用增加额外的方法。例如 `h.response('created').code(201)` 将会返回 `created` 并且 HTTP 状态码为 `201`。 你也可以设置响应头、内容类型、内容长度、重定向请求和文档中记录的其余类型 [API reference](/api#response-toolkit)。 +handler 接收两个参数: -## 配置 +- `request` 包含传入请求的全部信息:`params`、`query`、`payload`、`headers`、`auth` 等。请参阅 [请求对象](/api#request)。 +- `h` 是[响应工具包](/api#response-toolkit),提供 `h.response()` 和 `h.redirect()` 等方法。 -除以上基本的三个要素之外, 你也可以为每一个路由指定 `options` 参数。 这如同你在 [数据验证](/tutorials/validation),[身份认证](/tutorials/auth)章节中的配置一样。包括预处理, 内容处理以及缓存配置。更多细节内容请参阅 [API reference](/api#route-options)。 +handler 的返回值就是响应: -以下代码是为路由添加文档的示例: +| handler 返回 | 响应 | +| -------------------------------- | -------------------------------------------------------- | +| 字符串 | `200`,`text/html; charset=utf-8` | +| 对象或数组 | `200`,`application/json; charset=utf-8` | +| `Buffer` | `200`,`application/octet-stream` | +| 可读流 | `200`,流会通过管道传给客户端 | +| 带链式调用的 `h.response(value)` | 完全控制,例如 `h.response('created').code(201)` | +| 抛出的错误 | 错误响应,除非是 [boom](/module/boom) 错误,否则为 `500` | -```javascript -server.route({ - method: 'GET', - path: '/hello/{user?}', - handler: function (request, h) { - const user = request.params.user ? encodeURIComponent(request.params.user) : 'stranger'; +handler 必须返回一个值、一个 promise,或者抛出错误。[响应编组](/tutorials/zh_CN/response-marshalling)介绍了 hapi 如何把这些值变成实际发送的字节。 - return `Hello ${user}!`; - }, +箭头函数不能使用 `this`,所以以箭头函数编写的 handler 要从 [`h.context`](/api#h-context) 读取 [`server.bind()`](/api#server-bind-context) 设置的值。 + +## 选项 + +method、path 和 handler 之外的一切都放在路由的 `options` 中: + +```ts +import Joi from 'joi'; + +server.route<{ Payload: { username: string; password: string } }>({ + method: 'POST', + path: '/signup', options: { - description: 'Say hello!', - notes: "The user parameter defaults to 'stranger' if unspecified", - tags: ['api', 'greeting'], + auth: false, + validate: { + payload: Joi.object({ + username: Joi.string().min(1).max(20).required(), + password: Joi.string().min(7).required(), + }), + }, + }, + handler: (request, h) => { + return `Welcome ${request.payload.username}!`; + }, +}); +``` + +| 选项 | 控制内容 | 了解更多 | +| ---------- | ----------------------------------------- | ------------------------------------------------ | +| `auth` | 应用哪个身份认证 strategy | [身份认证](/tutorials/zh_CN/auth) | +| `validate` | 对 params、query、payload、headers 的检查 | [验证](/tutorials/zh_CN/validation) | +| `cache` | 响应的 `Cache-Control` 响应头 | [缓存](/tutorials/zh_CN/caching) | +| `payload` | payload 解析、大小限制、文件上传 | [路由 payload 选项](/api#route-options-payload) | +| `pre` | 在 handler 之前运行的方法 | [路由 pre 选项](/api#route-options-pre) | +| `ext` | 仅作用于该路由的生命周期钩子 | [生命周期钩子](/tutorials/zh_CN/lifecycle-hooks) | + +完整列表见[路由选项参考](/api#route-options)。 + +## 404 处理 + +没有路由匹配时,hapi 返回一个 `404` JSON 错误。如果想发送自己的响应,可以添加一个兜底路由。`{any*}` 通配符是最不具体的匹配,所以它只响应其他路由都不接的请求: + +```ts +server.route({ + method: '*', + path: '/{any*}', + handler: (request, h) => { + return h.response('404 Error! Page Not Found!').code(404); }, }); ``` -从功能上讲,除非你使用了一些插件如 [lout](https://github.com/hapijs/lout) 去生成你的 API 文档,否则这些选项并不会生效。 这些元数据将会被赋予路由,你可以在之后的处理过程中使用它们。 +兜底路由只覆盖未匹配的路径。已匹配的路由抛出的 `404`,例如 handler 抛出 `Boom.notFound()`,永远不会到达它。如果要自定义所有 `404`,不论它由谁产生,请在 `onPreResponse` 钩子中修改响应;请参阅[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)。 From 7eee73e47768d8f4fa6fbc3e9c1158836288dfcc Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:57 -0400 Subject: [PATCH 12/22] docs: rework server methods tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/server-methods.md | 228 ++++++++++++++---------- docs/tutorials/es_ES/server-methods.md | 217 +++++++++++++++++++++++ docs/tutorials/ko_KR/server-methods.md | 234 ++++++++++++++++--------- docs/tutorials/pt_BR/server-methods.md | 221 +++++++++++++++++------ docs/tutorials/tr_TR/server-methods.md | 234 ++++++++++++++++--------- docs/tutorials/zh_CN/server-methods.md | 234 ++++++++++++++++--------- 6 files changed, 972 insertions(+), 396 deletions(-) create mode 100644 docs/tutorials/es_ES/server-methods.md diff --git a/docs/tutorials/en_US/server-methods.md b/docs/tutorials/en_US/server-methods.md index 2c7de925..a449cbcf 100644 --- a/docs/tutorials/en_US/server-methods.md +++ b/docs/tutorials/en_US/server-methods.md @@ -6,32 +6,49 @@ title: Server Methods # Server Methods -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -Server methods are a useful way of sharing functions by attaching them to your server object rather than requiring a common module everywhere it is needed. Server methods are also used heavily for caching purposes. Since server methods leverage hapi's native caching, they can help reduce your boilerplate to a minimum. See the caching tutorial for more. To register a server method, you call [`server.method()`](). There are two different ways to call this function. You can call it with the signature `server.method(name, method, [options])`, or you can call it with the signature `server.method(method)`, where `method` is an object with `name`, `method`, and `options` parameters (note that you may also pass an array of these objects). +A server method is a function you register on the server with [`server.method()`](/api#server-method-name-method-options), and call from anywhere that has the server: route handlers, plugins, extensions. Use one when: -## server.method() +- several routes or plugins need the same function, and you don't want each to import it. +- the function is expensive, and its result can be cached. One option turns on hapi's [caching](/tutorials/en_US/caching) for it. -The first way to call `server.method()` is with the signature `server.method(name, method, [options])`: +```ts +import Hapi from '@hapi/hapi'; -```js -const add = function (x, y) { - return x + y; -}; +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + } +} -server.method('add', add, {}); +const server = Hapi.server({ port: 3000 }); + +server.method('add', (x: number, y: number) => x + y); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return { sum: request.server.methods.add(1, 2) }; + }, +}); + +await server.start(); ``` -Here, you create a function called `add`, which takes two parameters and adds them together. Then you call `server.method()` with the name of the method being `add`, the method you are using, the one we just created called `add`, and no options. +- `server.methods.add` is the registered function. In a handler, `request.server.methods` reaches it without needing the `server` variable in scope. +- hapi does not know which methods you register, so TypeScript needs them in hapi's `ServerMethods` interface. The `declare module` block can live in any module of your project. +- Methods are global to the server: a method registered inside a plugin is available to every other plugin and route. + +## server.method() -The second way to call `server.method()` is with the signature `server.method(method)`: +`server.method()` takes either positional arguments, or one object (or an array of objects, to register several at once): -```js -const add = function (x, y) { - return x + y; -}; +```ts +server.method('add', add, {}); server.method({ name: 'add', @@ -40,140 +57,161 @@ server.method({ }); ``` -You create the same function again, called `add`. When you register it this time, configure the `method` object. This case, `name` is the name of the method, `method` is the method you are using, and `options` is an object to configure various options. - ### Name -The `name` parameter is a string used to retrieve the method from the server later, via [`server.methods[name]`](#server.methods). Note that if you specify a `name` with a `.` character, it is registered as a nested object rather than the literal string. As in: +A name with dots registers a nested method: -```javascript +```ts server.method('math.add', add); + +server.methods.math.add(1, 2); ``` -This server method can then be called via `server.methods.math.add()`. +Declare it as a nested object in `ServerMethods`: `math: { add: (x: number, y: number) => number }`. Each segment of the name must be a valid JavaScript identifier. ### Method -The `method` parameter is the actual function to call when the method is invoked. It can take any number of arguments. It can be an `async` function, for example: - -```js -const add = async function (x, y) { - const result = await someLongRunningFunction(x, y); - return result; -}; +The method can take any arguments, and can be `async`. Return the result, or throw an error. -server.method('add', add, {}); +```ts +server.method('user.get', async (id: string) => { + return await db.users.findById(id); +}); ``` -Your server method function should return a valid result or throw an error if one occurs. - ## Options -When registering `server.method()`, you can configure three `options`: `cache`, `generateKey`, and `bind`. +| Option | Use it to | +| --------------------- | ---------------------------------------------------------------------------- | +| [`cache`](#cache) | cache the method's results | +| [`generateKey`](#key) | build the cache key when the arguments are not strings, numbers, or booleans | +| [`bind`](#bind) | set `this` inside the method | ### Cache -A major advantage of server methods is that they may leverage hapi's native caching. The default is to not cache, however if a valid configuration is passed when registering the method, the return value will be cached and served from the cache instead of re-running your method every time it is called. The configuration looks like the following: +With a `cache` option, calling the method goes through a cache first: + +```mermaid +flowchart TD + A["server.methods.user.get('42')"] --> K["generateKey('42') returns '42'
look it up in segment '#user.get'"] + K --> C{cached?} + C -->|yes| R[return the cached value] + C -->|no| M["run your method"] + M --> S[store the result, then return it] +``` -```javascript -server.method('add', add, { +```ts +server.method('user.get', getUser, { cache: { - expiresIn: 60000, - expiresAt: '20:30', - staleIn: 30000, - staleTimeout: 10000, - generateTimeout: 100, + expiresIn: 60 * 1000, + staleIn: 30 * 1000, + staleTimeout: 100, + generateTimeout: 2000, }, }); ``` -The parameters mean: - -- `expiresIn`: relative expiration expressed in the number of milliseconds since the item was saved in the cache. Cannot be used together with `expiresAt`. -- `expiresAt`: time of day expressed in 24h notation using the 'HH:MM' format, at which point all cache records for the route expire. Uses local time. Cannot be used together with `expiresIn`. -- `staleIn`: number of milliseconds to mark an item stored in cache as stale and attempt to regenerate it. Must be less than `expiresIn`. -- `staleTimeout`: number of milliseconds to wait before returning a stale value while generateFunc is generating a fresh value. -- `generateTimeout`: number of milliseconds to wait before returning a timeout error when it takes too long to return a value. When the value is eventually returned, it is stored in the cache for future requests. -- `segment`: an optional segment name used to isolate cache items. -- `cache`: an optional string with the name of the cache connection configured on your server to use +What to know: -More information on the caching options can be found in the [API Reference](/api#server.methods) as well as the documentation for [catbox](/module/catbox#policy). +- `generateTimeout` is required. It is how long a caller waits for your method before getting a `503`. +- A cached method always returns a promise, even when the method itself is synchronous. Declare it in `ServerMethods` with a `Promise` return type, and `await` it. +- hapi stores the items in a segment named `'#'` plus the method name. Set `cache: 'my_cache'` to use a [named cache client](/tutorials/en_US/caching#client) instead of the default memory cache. +- The other options (`expiresAt`, `staleIn`, `staleTimeout`, `segment`) are the same as for [`server.cache()`](/api#server-cache-options). The [caching tutorial](/tutorials/en_US/caching#stale) shows how stale values are served. -You can override the `ttl` (time-to-live) of a server method result per-invocation by setting the `ttl` flag. Let's see how this works with the earlier example: +#### Changing the TTL per result -```js -const add = async function (x, y, flags) { - const result = await someLongRunningFunction(x, y); +hapi passes one extra argument after yours: a `flags` object. Set `flags.ttl` to change how long this one result is cached, or to `0` to not cache it at all: - flags.ttl = 5 * 60 * 1000; // 5 mins +```ts +const getUser = async function (id: string, flags: { ttl: number }) { + const user = await db.users.findById(id); + if (!user) { + flags.ttl = 0; // don't cache "not found" + } - return result; + return user; }; - -server.method('add', add, { - cache: { - expiresIn: 2000, - generateTimeout: 100, - }, -}); ``` -Here you defined your server method function to have one more parameter than you're expecting to pass to it, the additional `flags` parameter is passed by hapi. You then simply set the `ttl` flag to however long you want the result to be cached for (in milliseconds); if it is set to `0` then the value will never be cached. If you set no flag then the `ttl` will be taken from the cache configuration. +hapi does not export a type for `flags`, so the example types it inline. -### Generate a Custom Key +#### Removing a cached result -In addition to the above options, you may also define a custom function used to generate a cache key based on the parameters passed to your method. If your method only accepts some combination of string, number, and boolean values hapi will generate a sane key for you. However, if your method accepts an object parameter, you should specify a function that will generate a cache key similar to the following: +Every cached method gets a `cache.drop()` function. Call it with the same arguments to remove that result, for example after an update: -```javascript -const sum = function (array) { - let total = 0; +```ts +await db.users.update(id, changes); +await server.methods.user.get.cache?.drop(id); +``` + +hapi types `cache` as optional because uncached methods don't have it, hence the `?.`. + +### Generate a Custom Key - array.forEach((item) => { - total += item; - }); +hapi builds the cache key by joining the arguments with `:`, which only works for strings, numbers, and booleans. Calling a cached method with any other argument type fails with a `500` error. Provide `generateKey` for other arguments: - return total; +```ts +const sum = function (numbers: number[]) { + return numbers.reduce((total, item) => total + item, 0); }; server.method('sum', sum, { - generateKey: (array) => array.join(','), + cache: { + expiresIn: 60 * 1000, + generateTimeout: 100, + }, + generateKey: (numbers: number[]) => numbers.join(','), }); ``` -Any arguments that you pass to your method are available to the `generateKey` method. +`generateKey` receives the same arguments as the method, and must return a string. ### Bind -The last option available to server methods is `bind`. The `bind` option changes the `this` context within the method. It defaults to the current active context when the method is added. This can be useful for passing in a database client without needing to pass it as a parameter and requiring a custom `generateKey` function, as in: - -```javascript -const lookup = async function (id) { - // calls myDB.getOne +`bind` sets `this` inside the method. Use it to hand the method a dependency, such as a database client, without adding it to the arguments (where it would also become part of the cache key): - return await this.getOne({ id }); +```ts +const lookup = async function (this: typeof db, id: string) { + return await this.users.findById(id); }; -server.method('lookup', lookup, { bind: myDB }); +server.method('lookup', lookup, { bind: db }); ``` -## server.methods +- The TypeScript `this` parameter declares the type of `this`. It is erased at compile time and is not one of the method's arguments. +- `bind` has no effect on arrow functions, which keep their own `this`. +- Without `bind`, `this` is the context set with [`server.bind()`](/api#server-bind-context) in the current plugin or server. -To call the server methods we registered above, you would use `server.methods()`. Consider our add function: +## server.methods -```js -const add = function (x, y) { - return x + y; -}; +All registered methods are on [`server.methods`](/api#server-methods), which is also available as `request.server.methods` in handlers: -server.method({ - name: 'add', - method: add, - options: {}, +```ts +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + return await request.server.methods.user.get(request.params.id); + }, }); ``` -To use this method, simply call `server.methods()` - -```js -server.methods.add(1, 2); // 3 +Each method used in this tutorial needs an entry in `ServerMethods`. Wrap cached methods in hapi's `CachedServerMethod` type, which adds `cache.drop()` and `cache.stats`: + +```ts +import type { CachedServerMethod } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + math: { + add: (x: number, y: number) => number; + }; + lookup: (id: string) => Promise; + sum: CachedServerMethod<(numbers: number[]) => Promise>; + user: { + get: CachedServerMethod<(id: string) => Promise>; + }; + } +} ``` diff --git a/docs/tutorials/es_ES/server-methods.md b/docs/tutorials/es_ES/server-methods.md new file mode 100644 index 00000000..c11cd441 --- /dev/null +++ b/docs/tutorials/es_ES/server-methods.md @@ -0,0 +1,217 @@ +--- +title: Métodos de servidor +--- + + + +# Métodos de servidor + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Un método de servidor (server method) es una función que registras en el servidor con [`server.method()`](/api#server-method-name-method-options) y que puedes llamar desde cualquier sitio que tenga acceso al servidor: handlers de rutas, plugins, extensiones. Úsalo cuando: + +- varias rutas o plugins necesitan la misma función y no quieres que cada uno la importe. +- la función es costosa y su resultado se puede cachear. Una sola opción activa la [caché](/tutorials/es_ES/caching) de hapi para ella. + +```ts +import Hapi from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + } +} + +const server = Hapi.server({ port: 3000 }); + +server.method('add', (x: number, y: number) => x + y); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return { sum: request.server.methods.add(1, 2) }; + }, +}); + +await server.start(); +``` + +- `server.methods.add` es la función registrada. En un handler, `request.server.methods` llega a ella sin necesidad de tener la variable `server` en el ámbito. +- hapi no sabe qué métodos registras, así que TypeScript necesita que estén en la interfaz `ServerMethods` de hapi. El bloque `declare module` puede estar en cualquier módulo de tu proyecto. +- Los métodos son globales para el servidor: un método registrado dentro de un plugin está disponible para todos los demás plugins y rutas. + +## server.method() + +`server.method()` acepta argumentos posicionales o un único objeto (o un array de objetos, para registrar varios a la vez): + +```ts +server.method('add', add, {}); + +server.method({ + name: 'add', + method: add, + options: {}, +}); +``` + +### Nombre + +Un nombre con puntos registra un método anidado: + +```ts +server.method('math.add', add); + +server.methods.math.add(1, 2); +``` + +Decláralo como un objeto anidado en `ServerMethods`: `math: { add: (x: number, y: number) => number }`. Cada segmento del nombre tiene que ser un identificador de JavaScript válido. + +### Método + +El método puede recibir cualquier argumento y puede ser `async`. Devuelve el resultado o lanza un error. + +```ts +server.method('user.get', async (id: string) => { + return await db.users.findById(id); +}); +``` + +## Opciones + +| Opción | Úsala para | +| --------------------- | -------------------------------------------------------------------------------------- | +| [`cache`](#cache) | cachear los resultados del método | +| [`generateKey`](#key) | construir la clave de caché cuando los argumentos no son strings, números ni booleanos | +| [`bind`](#bind) | fijar `this` dentro del método | + +### Caché + +Con la opción `cache`, llamar al método pasa primero por una caché: + +```mermaid +flowchart TD + A["server.methods.user.get('42')"] --> K["generateKey('42') devuelve '42'
se busca en el segmento '#user.get'"] + K --> C{¿en caché?} + C -->|sí| R[devuelve el valor cacheado] + C -->|no| M["ejecuta tu método"] + M --> S[guarda el resultado y lo devuelve] +``` + +```ts +server.method('user.get', getUser, { + cache: { + expiresIn: 60 * 1000, + staleIn: 30 * 1000, + staleTimeout: 100, + generateTimeout: 2000, + }, +}); +``` + +Lo que debes saber: + +- `generateTimeout` es obligatorio. Es el tiempo que espera quien llama a tu método antes de recibir un `503`. +- Un método cacheado siempre devuelve una promesa, aunque el método en sí sea síncrono. Decláralo en `ServerMethods` con un tipo de retorno `Promise` y usa `await`. +- hapi guarda los elementos en un segmento llamado `'#'` más el nombre del método. Pon `cache: 'my_cache'` para usar un [cliente de caché con nombre](/tutorials/es_ES/caching#client) en lugar de la caché en memoria por defecto. +- Las demás opciones (`expiresAt`, `staleIn`, `staleTimeout`, `segment`) son las mismas que las de [`server.cache()`](/api#server-cache-options). El [tutorial de caché](/tutorials/es_ES/caching#stale) muestra cómo se sirven los valores caducados (stale). + +#### Cambiar el TTL de cada resultado + +hapi pasa un argumento extra después de los tuyos: un objeto `flags`. Pon `flags.ttl` para cambiar cuánto tiempo se cachea ese resultado concreto, o a `0` para no cachearlo: + +```ts +const getUser = async function (id: string, flags: { ttl: number }) { + const user = await db.users.findById(id); + if (!user) { + flags.ttl = 0; // don't cache "not found" + } + + return user; +}; +``` + +hapi no exporta un tipo para `flags`, así que el ejemplo lo tipa en línea. + +#### Eliminar un resultado cacheado + +Todo método cacheado tiene una función `cache.drop()`. Llámala con los mismos argumentos para eliminar ese resultado, por ejemplo después de una actualización: + +```ts +await db.users.update(id, changes); +await server.methods.user.get.cache?.drop(id); +``` + +hapi tipa `cache` como opcional porque los métodos sin caché no la tienen; de ahí el `?.`. + +### Generar una clave personalizada + +hapi construye la clave de caché uniendo los argumentos con `:`, lo que solo funciona con strings, números y booleanos. Llamar a un método cacheado con cualquier otro tipo de argumento falla con un error `500`. Proporciona `generateKey` para otros argumentos: + +```ts +const sum = function (numbers: number[]) { + return numbers.reduce((total, item) => total + item, 0); +}; + +server.method('sum', sum, { + cache: { + expiresIn: 60 * 1000, + generateTimeout: 100, + }, + generateKey: (numbers: number[]) => numbers.join(','), +}); +``` + +`generateKey` recibe los mismos argumentos que el método y tiene que devolver un string. + +### Bind + +`bind` fija `this` dentro del método. Úsalo para pasarle al método una dependencia, como un cliente de base de datos, sin añadirla a los argumentos (donde además pasaría a formar parte de la clave de caché): + +```ts +const lookup = async function (this: typeof db, id: string) { + return await this.users.findById(id); +}; + +server.method('lookup', lookup, { bind: db }); +``` + +- El parámetro `this` de TypeScript declara el tipo de `this`. Se elimina al compilar y no es uno de los argumentos del método. +- `bind` no tiene efecto en las funciones flecha, que conservan su propio `this`. +- Sin `bind`, `this` es el contexto fijado con [`server.bind()`](/api#server-bind-context) en el plugin o servidor actual. + +## server.methods + +Todos los métodos registrados están en [`server.methods`](/api#server-methods), que también está disponible como `request.server.methods` en los handlers: + +```ts +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + return await request.server.methods.user.get(request.params.id); + }, +}); +``` + +Cada método usado en este tutorial necesita una entrada en `ServerMethods`. Envuelve los métodos cacheados en el tipo `CachedServerMethod` de hapi, que añade `cache.drop()` y `cache.stats`: + +```ts +import type { CachedServerMethod } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + math: { + add: (x: number, y: number) => number; + }; + lookup: (id: string) => Promise; + sum: CachedServerMethod<(numbers: number[]) => Promise>; + user: { + get: CachedServerMethod<(id: string) => Promise>; + }; + } +} +``` diff --git a/docs/tutorials/ko_KR/server-methods.md b/docs/tutorials/ko_KR/server-methods.md index 14f4cb53..0d494e62 100644 --- a/docs/tutorials/ko_KR/server-methods.md +++ b/docs/tutorials/ko_KR/server-methods.md @@ -1,29 +1,54 @@ --- -title: Server Methods +title: 서버 메서드 --- -## Server methods +# 서버 메서드 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -서버 메소드는 필요한 곳에서 공통 모듈을 요구하지 않고 서버 객체에 함수를 첨부하여 공유하는 유용한 방법입니다. 서버 메소드를 등록하려면 [`server.method()`]()를 호출합니다. 이 함수를 호출하는 두 가지 다른 방법이 있습니다. `server.method(name, method, [options])` 형식으로 호출할 수 있습니다. 예를 들면: +## 개요 -```javascript -const add = function (x, y) { - return x + y; -}; +서버 메서드는 [`server.method()`](/api#server-method-name-method-options)로 서버에 등록하고, 서버에 접근할 수 있는 곳(라우트 핸들러, 플러그인, 확장)이면 어디서든 호출하는 함수입니다. 다음과 같은 경우에 사용합니다: -server.method('add', add, {}); +- 여러 라우트나 플러그인이 같은 함수를 필요로 하는데, 각각에서 import하고 싶지 않을 때 +- 함수의 비용이 크고 결과를 캐시할 수 있을 때. 옵션 하나로 hapi의 [캐싱](/tutorials/ko_KR/caching)을 켤 수 있습니다 + +```ts +import Hapi from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + } +} + +const server = Hapi.server({ port: 3000 }); + +server.method('add', (x: number, y: number) => x + y); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return { sum: request.server.methods.add(1, 2) }; + }, +}); + +await server.start(); ``` -또는 `server.method(method)` 형식으로 호출할 수 있습니다. `method`는 `name`, `method`, `options` 인자를 가지는 객체입니다.(이 객체의 배열을 전달할 수 도 있습니다.) +- `server.methods.add`가 등록된 함수입니다. 핸들러에서는 `server` 변수가 스코프에 없어도 `request.server.methods`로 접근할 수 있습니다. +- hapi는 여러분이 어떤 메서드를 등록하는지 모르므로, TypeScript에서는 hapi의 `ServerMethods` 인터페이스에 메서드를 선언해야 합니다. `declare module` 블록은 프로젝트의 어느 모듈에 두어도 됩니다. +- 메서드는 서버 전역입니다. 플러그인 안에서 등록한 메서드도 다른 모든 플러그인과 라우트에서 사용할 수 있습니다. -```javascript -const add = function (x, y) { - return x + y; -}; +## server.method() + +`server.method()`는 위치 인자를 받거나, 객체 하나(여러 개를 한 번에 등록하려면 객체의 배열)를 받습니다: + +```ts +server.method('add', add, {}); server.method({ name: 'add', @@ -32,114 +57,161 @@ server.method({ }); ``` -### Name +### 이름 -`name` 인자는 이 후에 서버에서 `server.methods[name]`을 통해 메소드를 찾을 때 사용되는 문자열입니다. `.` 문자로 `name`을 지정하면 문자열 그대로가 아닌 중첩된 객체로 등록됩니다. 마찬가지로: +이름에 점이 있으면 중첩된 메서드로 등록됩니다: -```javascript +```ts server.method('math.add', add); -``` -이 서버 메소드는 `server.methods.math.add()`로 호출 가능하게 됩니다. +server.methods.math.add(1, 2); +``` -### Function +`ServerMethods`에는 중첩 객체로 선언합니다: `math: { add: (x: number, y: number) => number }`. 이름의 각 세그먼트는 유효한 JavaScript 식별자여야 합니다. -`method` 인자는 메소드가 호출될 때 불리는 실제로 함수입니다. 여러개의 인자를 가질 수 있습니다. `async` 함수일 수 있습니다. 예를 들어: +### 메서드 -```js -const add = async function (x, y) { - const result = await someLongRunningFunction(x, y); - return result; -}; +메서드는 어떤 인자든 받을 수 있고 `async`여도 됩니다. 결과를 반환하거나 에러를 던지세요. -server.method('add', add, {}); +```ts +server.method('user.get', async (id: string) => { + return await db.users.findById(id); +}); ``` -서버 메소드 함수는 유효한 결과를 반환하거나 에러가 발생하면 에러를 던져야 합니다. +## 옵션 + +| 옵션 | 용도 | +| --------------------- | ---------------------------------------------------- | +| [`cache`](#cache) | 메서드의 결과를 캐시 | +| [`generateKey`](#key) | 인자가 문자열, 숫자, 불리언이 아닐 때 캐시 키를 생성 | +| [`bind`](#bind) | 메서드 안의 `this`를 설정 | -## Caching +### 캐시 -서버 메소드의 다른 주요 장점인 캐시에 대해 말하면 hapi의 기본 캐싱을 사용할 수 있다는 것입니다. 기본은 캐시하지 않는 것이지만 메소드를 등록할 때 유효한 설정이 전달되면 반환 값은 캐시되고 매번 호출될 때마다 재수행 대신 제공합니다. 설정은 다음과 같습니다.: +`cache` 옵션이 있으면 메서드 호출은 먼저 캐시를 거칩니다: + +```mermaid +flowchart TD + A["server.methods.user.get('42')"] --> K["generateKey('42')가 '42'를 반환
세그먼트 '#user.get'에서 조회"] + K --> C{캐시에 있음?} + C -->|예| R[캐시된 값을 반환] + C -->|아니요| M["메서드 실행"] + M --> S[결과를 저장한 뒤 반환] +``` -```javascript -server.method('add', add, { +```ts +server.method('user.get', getUser, { cache: { - expiresIn: 60000, - expiresAt: '20:30', - staleIn: 30000, - staleTimeout: 10000, - generateTimeout: 100, + expiresIn: 60 * 1000, + staleIn: 30 * 1000, + staleTimeout: 100, + generateTimeout: 2000, }, }); ``` -인자의 의미: - -- `expiresIn`: 캐시에 항목이 저장된 이후 밀리 초 단위로 표현된 상대적인 만료시간. `expiresAt`과 같이 사용할 수 없습니다. -- `expiresAt`: route의 모든 캐시 레코드가 만료될 'HH:MM' 형식의 24시간 표기법으로 표현된 시간. 지역 시간을 사용합니다. `expiresIn`과 같이 사용할 수 없습니다. -- `staleIn`: 캐시에 저장된 항목을 신선하지 않음을 표시하고 다시 생성하려는 밀리 초 단위 시간. `expiresIn`보다 작아야 합니다. -- `staleTimeout`: generateFunc가 새로운 값을 생성하는 동안 신선하지 않은 값을 반환하기 전에 기다리는 밀리 초 단위 시간. -- `generateTimeout`: 값을 반환하는데 너무 오랜 시간이 걸려 시간 초과 에러를 반환하기 전에 기다리는 밀리 초 단위 시간. 값이 최종적으로 반환되면 이후 요청을 위해 캐시에 저장합니다. -- `segment`: 캐시 항목을 분리하는데 사용되는 선택적 부분 이름입니다. -- `cache`: 사용할 서버에 설정된 캐시 연결의 이름을 가진 선택적 문자열입니다. +알아 둘 점: -캐싱 옵션에 대한 자세한 정보는 [API Reference](/api#servermethodmethod)와 [catbox](https://github.com/hapijs/catbox#policy)의 문서를 참고해주세요. +- `generateTimeout`은 필수입니다. 호출자가 `503`을 받기 전까지 메서드를 기다리는 시간입니다. +- 캐시되는 메서드는 메서드 자체가 동기이더라도 항상 프로미스를 반환합니다. `ServerMethods`에 `Promise` 반환 타입으로 선언하고 `await`하세요. +- hapi는 항목을 `'#'`에 메서드 이름을 붙인 이름의 세그먼트에 저장합니다. 기본 메모리 캐시 대신 [이름 있는 캐시 클라이언트](/tutorials/ko_KR/caching#client)를 쓰려면 `cache: 'my_cache'`를 설정하세요. +- 나머지 옵션(`expiresAt`, `staleIn`, `staleTimeout`, `segment`)은 [`server.cache()`](/api#server-cache-options)와 같습니다. 오래된(stale) 값을 제공하는 방식은 [캐싱 튜토리얼](/tutorials/ko_KR/caching#stale)에서 보여 줍니다. -`ttl` flag 설정으로 호출마다 서버 메소드 결과의 `ttl`(time-to-live) 값을 덮어 쓸 수 있습니다. 앞의 예제에서 어떻게 동작하는지 보겠습니다. +#### 결과별로 TTL 바꾸기 -```js -const add = async function (x, y, flags) { - const result = await someLongRunningFunction(x, y); +hapi는 여러분의 인자 뒤에 `flags` 객체를 인자로 하나 더 넘깁니다. `flags.ttl`을 설정하면 이 결과 하나가 캐시되는 시간을 바꿀 수 있고, `0`으로 설정하면 아예 캐시하지 않습니다: - flags.ttl = 5 * 60 * 1000; // 5 mins +```ts +const getUser = async function (id: string, flags: { ttl: number }) { + const user = await db.users.findById(id); + if (!user) { + flags.ttl = 0; // don't cache "not found" + } - return result; + return user; }; +``` -server.method('add', add, { - cache: { - expiresIn: 2000, - generateTimeout: 100, - }, -}); +hapi는 `flags`의 타입을 export하지 않으므로 예제에서는 인라인으로 타입을 지정합니다. -server.methods.add(5, 12); -``` +#### 캐시된 결과 제거하기 -여기에서는 기대한 것보다 추가 인자를 하나 더 가지고 있는 서버 메소드 함수를 정의했습니다. 추가적인 `flags` 인자는 hapi에 전달됩니다. (밀리 초 단위로) 결과가 캐싱되기는 원하는 시간으로 `ttl` flag를 간단하게 설정합니다. 만약 `0`으로 설정되면 그 값은 캐싱되지 않습니다. 설정하지 않으면 캐시 설정에서 `ttl` 값을 가지고 옵니다. +캐시되는 메서드에는 모두 `cache.drop()` 함수가 있습니다. 같은 인자로 호출하면 해당 결과가 제거됩니다. 예를 들어 업데이트 후에 호출합니다: -### 사용자 키 생성하기 +```ts +await db.users.update(id, changes); +await server.methods.user.get.cache?.drop(id); +``` -위의 옵션 외에도 메소드에 전달된 인자를 기반으로 키를 생성하는데 사용되는 사용자 함수를 전달할 수 있습니다. 메소드가 단지 문자열 숫자, 논리값의 조합 만 받는다면 hapi가 정상적인 키를 생성할 수 있습니다. 그러나 메소드가 객체 인자를 받는다면 다음처럼 키를 생성하는 함수를 지정해야 합니다. +캐시되지 않는 메서드에는 `cache`가 없기 때문에 hapi는 이를 선택적 속성으로 타입을 지정하며, 그래서 `?.`를 씁니다. -```javascript -const sum = function (array) { - let total = 0; +### 커스텀 키 생성 - array.forEach((item) => { - total += item; - }); +hapi는 인자를 `:`로 이어서 캐시 키를 만드는데, 이 방식은 문자열, 숫자, 불리언에만 통합니다. 캐시되는 메서드를 다른 타입의 인자로 호출하면 `500` 에러로 실패합니다. 다른 인자에는 `generateKey`를 제공하세요: - return total; +```ts +const sum = function (numbers: number[]) { + return numbers.reduce((total, item) => total + item, 0); }; server.method('sum', sum, { - generateKey: (array) => array.join(','), + cache: { + expiresIn: 60 * 1000, + generateTimeout: 100, + }, + generateKey: (numbers: number[]) => numbers.join(','), }); ``` -메소드에 전달된 인자는 generateKey 메소드에서는 사용 가능합니다. - -### Bind +`generateKey`는 메서드와 같은 인자를 받고 문자열을 반환해야 합니다. -서버 메소드에서 사용가능한 마지막 옵션은 `bind`입니다. `bind` 옵션은 메소드 안에서 `this` 값을 변경합니다. 메소드가 추가될 때 현재 활성 컨텍스트를 기본 설정됩니다. 이는 사용자 `generateKey` 함수에서 필요한 데이터베이스 클라이언트를 인자로 전달하지 않고 데이터베이스 클라이언트를 다음처럼 전달할 때 유용합니다.: +### 바인드 -```javascript -const lookup = async function (id) { - // calls myDB.getOne +`bind`는 메서드 안의 `this`를 설정합니다. 데이터베이스 클라이언트 같은 의존성을 인자에 추가하지 않고(인자에 넣으면 캐시 키의 일부가 되기도 합니다) 메서드에 넘길 때 사용하세요: - return await this.getOne({ id }); +```ts +const lookup = async function (this: typeof db, id: string) { + return await this.users.findById(id); }; -server.method('lookup', lookup, { bind: myDB }); +server.method('lookup', lookup, { bind: db }); +``` + +- TypeScript의 `this` 파라미터는 `this`의 타입을 선언합니다. 컴파일 시점에 지워지며 메서드의 인자가 아닙니다. +- 화살표 함수는 자신의 `this`를 유지하므로 `bind`가 효과가 없습니다. +- `bind`가 없으면 `this`는 현재 플러그인이나 서버에서 [`server.bind()`](/api#server-bind-context)로 설정한 컨텍스트입니다. + +## server.methods + +등록된 모든 메서드는 [`server.methods`](/api#server-methods)에 있으며, 핸들러에서는 `request.server.methods`로도 사용할 수 있습니다: + +```ts +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + return await request.server.methods.user.get(request.params.id); + }, +}); +``` + +이 튜토리얼에서 사용한 각 메서드에는 `ServerMethods` 항목이 필요합니다. 캐시되는 메서드는 `cache.drop()`과 `cache.stats`를 추가해 주는 hapi의 `CachedServerMethod` 타입으로 감싸세요: + +```ts +import type { CachedServerMethod } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + math: { + add: (x: number, y: number) => number; + }; + lookup: (id: string) => Promise; + sum: CachedServerMethod<(numbers: number[]) => Promise>; + user: { + get: CachedServerMethod<(id: string) => Promise>; + }; + } +} ``` diff --git a/docs/tutorials/pt_BR/server-methods.md b/docs/tutorials/pt_BR/server-methods.md index 5b71d505..39747a14 100644 --- a/docs/tutorials/pt_BR/server-methods.md +++ b/docs/tutorials/pt_BR/server-methods.md @@ -1,28 +1,54 @@ --- -title: Server Methods +title: Métodos de servidor --- -## Métodos de Servidor +# Métodos de servidor -Métodos de servidor são uma maneira útil para compartilhar funções. Eles são anexados no seu objeto servidor em vez de exigirem um módulo comum em todos os lugares necessários. Para registrar um método de servidor, você precisa acessar o objeto `server`. Há duas formas diferentes de fazer isso, uma delas é a passagem de parâmetros separados: +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ -```javascript -const add = function (x, y, next) { - // note que a função 'next' é utilizada para retornar valores - next(null, x + y); -}; +## Visão geral -server.method('add', add, {}); +Um método de servidor é uma função que você registra no servidor com [`server.method()`](/api#server-method-name-method-options) e chama de qualquer lugar que tenha acesso ao servidor: handlers de rota, plugins, extensões. Use um quando: + +- várias rotas ou plugins precisam da mesma função, e você não quer que cada um a importe. +- a função é cara e o resultado pode ser guardado em cache. Uma única opção ativa o [cache](/tutorials/pt_BR/caching) do hapi para ela. + +```ts +import Hapi from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + } +} + +const server = Hapi.server({ port: 3000 }); + +server.method('add', (x: number, y: number) => x + y); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return { sum: request.server.methods.add(1, 2) }; + }, +}); + +await server.start(); ``` -Ou um objeto com os parâmetros `name`, `method`, and `options` (perceba que você também pode passar uma coleção desses objetos): +- `server.methods.add` é a função registrada. Em um handler, `request.server.methods` chega até ela sem precisar da variável `server` no escopo. +- O hapi não sabe quais métodos você registra, então o TypeScript precisa deles na interface `ServerMethods` do hapi. O bloco `declare module` pode ficar em qualquer módulo do seu projeto. +- Os métodos são globais no servidor: um método registrado dentro de um plugin fica disponível para todos os outros plugins e rotas. -```javascript -const add = function (x, y, next) { - next(null, x + y); -}; +## server.method() + +`server.method()` recebe argumentos posicionais ou um único objeto (ou um array de objetos, para registrar vários de uma vez): + +```ts +server.method('add', add, {}); server.method({ name: 'add', @@ -31,82 +57,161 @@ server.method({ }); ``` -## Nome +### Nome -O parâmetro `name` é uma string usada para recuperar o método a ser utilizado pelo servidor, através de `server.methods[name]`. Note que se você nomear o parâmetro `name` com um carácter `.`, ele será registrado como um objeto aninhado em vez de uma string literal. Por exemplo: +Um nome com pontos registra um método aninhado: -```javascript +```ts server.method('math.add', add); + +server.methods.math.add(1, 2); +``` + +Declare-o como um objeto aninhado em `ServerMethods`: `math: { add: (x: number, y: number) => number }`. Cada segmento do nome precisa ser um identificador JavaScript válido. + +### Método + +O método pode receber quaisquer argumentos e pode ser `async`. Retorne o resultado ou lance um erro. + +```ts +server.method('user.get', async (id: string) => { + return await db.users.findById(id); +}); ``` -será acessível como server.methods.math.add +## Opções -## Função +| Opção | Use para | +| --------------------- | ---------------------------------------------------------------------------------- | +| [`cache`](#cache) | guardar em cache os resultados do método | +| [`generateKey`](#key) | montar a chave de cache quando os argumentos não são strings, números ou booleanos | +| [`bind`](#bind) | definir `this` dentro do método | -O parâmetro `method` é na verdade a função que será chamada quando o método é invocado. Ele pode receber qualquer quantidade de argumentos, mas o seu _último_ parâmetro deve ser a função de retorno. A função de retorno aceita três parâmetros: `err`, `result`, and `ttl`. Caso ocorra algum erro no seu método, ele será passado como o primeiro argumento. Se não ocorrer erro, o primeiro argumento deve ser indefinido ou nulo e o valor de retorno será passado como o segundo argumento. O argumento `ttl` é usado para informar ao hapi quanto tempo o valor de retorno pode ser armazenado em cache; se ele for especificado como `0`, então o valor nunca será armazenado em cache. +### Cache -## Caching +Com a opção `cache`, a chamada ao método passa primeiro por um cache: -Falando de caching, outra grande vantagem dos métodos de servidor é que eles podem incrementar o cache nativo do hapi. O padrão é sem cache, entretanto se uma configuração válida é passada ao registrar um método, o valor de retorno será armazenado em cache e disponibilizado a partir daí em vez de re-executar o seu método todas as vezes que ele for chamado. A configuração é semelhante a seguinte: +```mermaid +flowchart TD + A["server.methods.user.get('42')"] --> K["generateKey('42') retorna '42'
busca no segmento '#user.get'"] + K --> C{em cache?} + C -->|sim| R[retorna o valor em cache] + C -->|não| M["executa o seu método"] + M --> S[guarda o resultado e o retorna] +``` -```javascript -server.method('add', add, { +```ts +server.method('user.get', getUser, { cache: { - expiresIn: 60000, - expiresAt: '30:22', - staleIn: 30000, - staleTimeout: 10000, + expiresIn: 60 * 1000, + staleIn: 30 * 1000, + staleTimeout: 100, + generateTimeout: 2000, }, }); ``` -Os parâmetros são: +O que você precisa saber: + +- `generateTimeout` é obrigatório. É quanto tempo quem chama espera pelo seu método antes de receber um `503`. +- Um método com cache sempre retorna uma promise, mesmo quando o próprio método é síncrono. Declare-o em `ServerMethods` com um tipo de retorno `Promise` e use `await`. +- O hapi guarda os itens em um segment chamado `'#'` mais o nome do método. Defina `cache: 'my_cache'` para usar um [cliente de cache nomeado](/tutorials/pt_BR/caching#client) em vez do cache em memória padrão. +- As outras opções (`expiresAt`, `staleIn`, `staleTimeout`, `segment`) são as mesmas de [`server.cache()`](/api#server-cache-options). O tutorial [Cache](/tutorials/pt_BR/caching#stale) mostra como os valores stale são servidos. -- `expiresIn`: o tempo em milisegundos para ser mantido em cache, a partir do momento que foi criado -- `expiresAt`: notação em MM:HH, serve para informar o prazo para invalidar o cache. Não pode ser utilizado ao mesmo tempo que expiresIn -- `staleIn`: informa em milisegundos quanto tempo o item deve aguardar para ser marcado como velho no cache, deve ser _menor_ que o expiresIn -- `staleTimeout`: milissegundos para aguardar uma resposta antes de informar um valor obsoleto -- `generateTimeout`: milisegundos para aguardar antes de retornar um erro de tempo esgotado quando demora muito para retornar um valor. Eventualmente, quando o valor é retornado, é guardado no cache para as requisições futuras. -- `segment`: um nome de segmento opcional usado para isolar itens do cache. -- `cache`: um nome da conexão do cache que foi configurada para uso no servidor, esse parâmetro é opcional +#### Mudando o TTL por resultado -Mais informações sobre as opções de cache podem ser encontradas na [API de Referência](/api#servermethodmethod), bem como na documentação do [catbox](https://github.com/hapijs/catbox#policy). +O hapi passa um argumento extra depois dos seus: um objeto `flags`. Defina `flags.ttl` para mudar por quanto tempo esse resultado específico fica em cache, ou `0` para não guardá-lo: -## Gerando uma chave customizada +```ts +const getUser = async function (id: string, flags: { ttl: number }) { + const user = await db.users.findById(id); + if (!user) { + flags.ttl = 0; // don't cache "not found" + } + + return user; +}; +``` + +O hapi não exporta um tipo para `flags`, então o exemplo o tipa inline. + +#### Removendo um resultado do cache + +Todo método com cache ganha uma função `cache.drop()`. Chame-a com os mesmos argumentos para remover aquele resultado, por exemplo depois de uma atualização: + +```ts +await db.users.update(id, changes); +await server.methods.user.get.cache?.drop(id); +``` -Além das opções acima, você também pode passar uma função personalizada. Ela será usada para gerar uma chave com base nos parâmetros passados para seu método. Se o seu método só aceita uma combinação de string, número e valores booleanos, o hapi irá gerar uma chave para você. Porém, se o seu método aceita um objeto como parâmetro, você deve informar uma função que irá gerar a chave similar ao exemplo abaixo: +O hapi tipa `cache` como opcional porque métodos sem cache não o têm, por isso o `?.`. -```javascript -const sum = function (array, next) { - let total = 0; +### Gerando uma chave própria - array.forEach((item) => { - total += item; - }); +O hapi monta a chave de cache juntando os argumentos com `:`, o que só funciona para strings, números e booleanos. Chamar um método com cache usando qualquer outro tipo de argumento falha com um erro `500`. Forneça `generateKey` para outros argumentos: - next(null, total); +```ts +const sum = function (numbers: number[]) { + return numbers.reduce((total, item) => total + item, 0); }; server.method('sum', sum, { - generateKey: function (array) { - return array.join(','); + cache: { + expiresIn: 60 * 1000, + generateTimeout: 100, }, + generateKey: (numbers: number[]) => numbers.join(','), }); ``` -Todos os argumentos passados para o seu método são acessíveis através do método generateKey, com _exceção_ da função de retorno. +`generateKey` recebe os mesmos argumentos do método e precisa retornar uma string. -## Bind +### Bind -A última opção disponvível para métodos de servidor é o `bind`. Essa opção altera o contexto `this` dentro do método. O padrão é o contexto atual ativo quando o método é criado. Isto pode ser útil para a passagem de um cliente de banco de dados sem a necessidade de passá-lo como um parâmetro e exigindo uma função customizada de `generateKey`, como em: +`bind` define `this` dentro do método. Use-o para entregar uma dependência ao método, como um cliente de banco de dados, sem adicioná-la aos argumentos (onde ela também passaria a fazer parte da chave de cache): -```javascript -const lookup = function (id, next) { - // calls myDB.getOne - this.getOne({ id: id }, (err, value) => { - next(err, value); - }); +```ts +const lookup = async function (this: typeof db, id: string) { + return await this.users.findById(id); }; -server.method('lookup', lookup, { bind: myDB }); +server.method('lookup', lookup, { bind: db }); +``` + +- O parâmetro `this` do TypeScript declara o tipo de `this`. Ele é apagado na compilação e não é um dos argumentos do método. +- `bind` não tem efeito em arrow functions, que mantêm o próprio `this`. +- Sem `bind`, `this` é o contexto definido com [`server.bind()`](/api#server-bind-context) no plugin ou servidor atual. + +## server.methods + +Todos os métodos registrados ficam em [`server.methods`](/api#server-methods), que também está disponível como `request.server.methods` nos handlers: + +```ts +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + return await request.server.methods.user.get(request.params.id); + }, +}); +``` + +Cada método usado neste tutorial precisa de uma entrada em `ServerMethods`. Envolva os métodos com cache no tipo `CachedServerMethod` do hapi, que adiciona `cache.drop()` e `cache.stats`: + +```ts +import type { CachedServerMethod } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + math: { + add: (x: number, y: number) => number; + }; + lookup: (id: string) => Promise; + sum: CachedServerMethod<(numbers: number[]) => Promise>; + user: { + get: CachedServerMethod<(id: string) => Promise>; + }; + } +} ``` diff --git a/docs/tutorials/tr_TR/server-methods.md b/docs/tutorials/tr_TR/server-methods.md index 03d1046b..91e35d98 100644 --- a/docs/tutorials/tr_TR/server-methods.md +++ b/docs/tutorials/tr_TR/server-methods.md @@ -1,29 +1,54 @@ --- -title: Server Methods +title: Server Method'lar --- -## Sunucu yöntemleri +# Server Method'lar -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -Sunucu yöntemleri birden fazla yerde kullanılan modüllerin lazım olan her talep edilmesindense sunucu nesnene iliştirebildiğin kullanışlı bir yol sunar. Bir sunucu yöntemi kaydetmek için [`server.method()`]() çağrısı yap. Bu yöntemi çağırmanın iki farklı yöntemi var. `server.method(name, method, [options])` imzasıyla çağırabilirsin. Örneğin: +## Genel Bakış -```javascript -const add = function (x, y) { - return x + y; -}; +Server method, sunucuya [`server.method()`](/api#server-method-name-method-options) ile kaydettiğin ve sunucuya erişimi olan her yerden çağırabildiğin bir fonksiyondur: route handler'ları, plugin'ler, extension'lar. Şu durumlarda kullan: -server.method('add', add, {}); +- birkaç route ya da plugin aynı fonksiyona ihtiyaç duyuyor ve her birinin onu ayrı ayrı import etmesini istemiyorsun. +- fonksiyon pahalı ve sonucu önbelleğe alınabilir. Tek bir seçenek, onun için hapi'nin [önbelleğini](/tutorials/tr_TR/caching) açar. + +```ts +import Hapi from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + } +} + +const server = Hapi.server({ port: 3000 }); + +server.method('add', (x: number, y: number) => x + y); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return { sum: request.server.methods.add(1, 2) }; + }, +}); + +await server.start(); ``` -`server.method(method)` imzasıyla da çağırabilirsin. Burada `method` (yöntem) `name` (isim), `method` (yöntem) ve `options` (seçenekler) değiştirgelerine sahip bir nesnedir (Ayrıca bu nesnelerden oluşan bir dizi de gönderebilirsin). +- `server.methods.add` kaydedilen fonksiyondur. Bir handler içinde `request.server.methods` ile, kapsamda `server` değişkeni olmadan ona ulaşırsın. +- hapi hangi metotları kaydettiğini bilmez, bu yüzden TypeScript'in onları hapi'nin `ServerMethods` arayüzünde görmesi gerekir. `declare module` bloğu projendeki herhangi bir modülde olabilir. +- Metotlar sunucu genelindedir: bir plugin içinde kaydedilen bir metot diğer tüm plugin'ler ve route'lar tarafından kullanılabilir. -```javascript -const add = function (x, y) { - return x + y; -}; +## server.method() + +`server.method()` ya sıralı argümanlar ya da tek bir nesne alır (birden fazlasını aynı anda kaydetmek için bir nesne dizisi de olabilir): + +```ts +server.method('add', add, {}); server.method({ name: 'add', @@ -32,114 +57,161 @@ server.method({ }); ``` -### Isim +### Ad -`name` (isim) değiştirgesi daha sonra sunucudan yöntemi `server.methods[name]` yöntemi ile çağırmak için kullanılacak bir metindır. Eğer `name` (isim) değiştirgesini bir `.` (nokta) karakteri ile gönderirsen; bir metin yerine aşağıda olduğu gibi iç içe bir nesne olarak kaydedilir. +Nokta içeren bir ad, iç içe bir metot kaydeder: -```javascript +```ts server.method('math.add', add); -``` -Bu sunucu yöntemi `server.methods.math.add()` ile çağrılır. +server.methods.math.add(1, 2); +``` -### Islev +Bunu `ServerMethods` içinde iç içe bir nesne olarak bildir: `math: { add: (x: number, y: number) => number }`. Adın her segmenti geçerli bir JavaScript tanımlayıcısı olmalıdır. -`method` (yöntem) değiştirgesi yöntem çağrısı yapıldığında gerçekten çağırılacak olan işlevdir. İstediği kadar argüman alabilir. `async` (asenkron) bir fonksiyon olabilir. Örneğin: +### Metot -```js -const add = async function (x, y) { - const result = await someLongRunningFunction(x, y); - return result; -}; +Metot herhangi bir argüman alabilir ve `async` olabilir. Sonucu döndür ya da bir hata fırlat. -server.method('add', add, {}); +```ts +server.method('user.get', async (id: string) => { + return await db.users.findById(id); +}); ``` -Sunucu yöntem işlevin geçerli bir sonuç dönmeli ya da bir hata oluştu ise fırlatmalıdır. +## Seçenekler + +| Seçenek | Ne için kullanılır | +| --------------------- | ---------------------------------------------------------------------------------- | +| [`cache`](#cache) | metodun sonuçlarını önbelleğe almak | +| [`generateKey`](#key) | argümanlar string, number ya da boolean olmadığında önbellek anahtarını oluşturmak | +| [`bind`](#bind) | metot içinde `this` değerini ayarlamak | -## Onbellekleme +### Önbellek -Sunucu yöntemlerinin başlıca avantajlarından biri hapinin ana önbelleklemesini geliştirebilmeleridir. Varsayılan olarak önbellekleme yapmaz ancak yöntem kaydedilirken geçerli bir yapılandırma verildiğinde dönen sonuç önbelleklenecek ve her çağrıldığında yöntemini tekrar çalıştırmak yerine önbellekten sunulacaktır. Yapılandırma şuna benzer: +`cache` seçeneği varsa metodu çağırmak önce bir önbellekten geçer: + +```mermaid +flowchart TD + A["server.methods.user.get('42')"] --> K["generateKey('42') '42' döndürür
'#user.get' segmentinde ara"] + K --> C{önbellekte mi?} + C -->|evet| R[önbellekteki değeri döndür] + C -->|hayır| M["metodunu çalıştır"] + M --> S[sonucu sakla, sonra döndür] +``` -```javascript -server.method('add', add, { +```ts +server.method('user.get', getUser, { cache: { - expiresIn: 60000, - expiresAt: '20:30', - staleIn: 30000, - staleTimeout: 10000, - generateTimeout: 100, + expiresIn: 60 * 1000, + staleIn: 30 * 1000, + staleTimeout: 100, + generateTimeout: 2000, }, }); ``` -değiştirgelerin anlamları: - -- `expiresIn`: öğe son kez önbelleklendiği andan itibaren süresinin dolması için geçmesi gereken milisaniye cinsinden süre. `expiresAt` ile birlikte kullanılamaz. -- `expiresAt`: Yol (route) ile ilgili önbelleklerin süresinin dolması için 24 saatlik gösterimle 'HH:MM' formatında ifade edilen günün saati. Yerel zamanı kullanır. `expiresIn` ile birlikte kullanılamaz. -- `staleIn`: Önbellekteki bir öğenin bayat olarak işaretlenerek yeniden oluşturmaya kalkışmadan önce geçmesi gerek milisaniye cinsinden süre. En az `expiresIn` kadar olmalıdır. -- `staleTimeout`: Bayatlamış bir öğeyi dönmeden önce generateFunc oluşturma fonksiyonunun taze bir değer üretmesi için beklenilecek maksimum süre. -- `generateTimeout`: Bir değeri üretmek uzun sürdüğünde zaman aşımı hatası vermeden önce beklenecek milisaniye cinsinden süre. Değer gerçekten döndüğünde gelecek istekler için önbelleklenir. -- `segment`: Önbellek öğelerini ayrıştırmak (isolate) için kullanılan isteğe bağlı bölme adı -- `cache`: Sunucuda kullanılmak için yapılandırılmış bir önbellek bağlantı adını taşıyan isteğe bağlı bir metin +Bilmen gerekenler: -Önbellekleme hakkında daha fazla bilgi [catbox](https://github.com/hapijs/catbox#policy) dokümantasyonu ve [API referansında](/api#servermethodmethod) bulunabilir. +- `generateTimeout` zorunludur. Çağıranın `503` almadan önce metodunu ne kadar bekleyeceğini belirler. +- Önbelleğe alınan bir metot, metodun kendisi senkron olsa bile her zaman bir promise döndürür. Onu `ServerMethods` içinde `Promise` dönüş tipiyle bildir ve `await` ile çağır. +- hapi öğeleri `'#'` ile metot adının birleşiminden oluşan bir segmentte saklar. Varsayılan bellek önbelleği yerine [adlandırılmış bir önbellek client'ı](/tutorials/tr_TR/caching#client) kullanmak için `cache: 'my_cache'` ayarla. +- Diğer seçenekler (`expiresAt`, `staleIn`, `staleTimeout`, `segment`) [`server.cache()`](/api#server-cache-options) ile aynıdır. Bayat değerlerin nasıl sunulduğunu [Önbellekleme](/tutorials/tr_TR/caching#stale) eğitimi gösteriyor. -Bir sunucu yönteminin her bir çağrı sonucunun `ttl` (time-to-live (yaşanacak zaman))inin üzerine `ttl` bayrağını ayarlayarak yazabilirsiniz. Önceki örnekte bu nasıl olurdu bir bakalım: +#### TTL'i sonuç bazında değiştirmek -```js -const add = async function (x, y, flags) { - const result = await someLongRunningFunction(x, y); +hapi seninkilerden sonra fazladan bir argüman geçer: bir `flags` nesnesi. Bu tek sonucun ne kadar süre önbellekte kalacağını değiştirmek için `flags.ttl` değerini ayarla; hiç önbelleğe alınmasın istiyorsan `0` yap: - flags.ttl = 5 * 60 * 1000; // 5 mins +```ts +const getUser = async function (id: string, flags: { ttl: number }) { + const user = await db.users.findById(id); + if (!user) { + flags.ttl = 0; // don't cache "not found" + } - return result; + return user; }; +``` -server.method('add', add, { - cache: { - expiresIn: 2000, - generateTimeout: 100, - }, -}); +hapi `flags` için bir tip export etmez, bu yüzden örnek onu satır içinde tipliyor. -server.methods.add(5, 12); -``` +#### Önbellekteki bir sonucu kaldırmak -Burada sunucu yöntem işlevimizi normalde göndermeyi planladığımızdan bir fazla değiştirge alacak şekilde tanımlayarak hapi tarafından gönderilecek `flags` (bayraklar) değiştirgesine yer açtık. Daha sonra `ttl` bayrağının üzerine sonucun (milisaniye cinsinden) ne kadar önbelleklenmesini istiyorsak onu yazdık. Eğer yazdığımız değer `0` olsaydı bu değer asla önbelleklenmeyecekti. Eğer bayrak ayarlamasaydık önbellek yapılandırmasındaki `ttl` kullanılacaktı. +Önbelleğe alınan her metot bir `cache.drop()` fonksiyonu alır. O sonucu kaldırmak için onu aynı argümanlarla çağır; örneğin bir güncellemeden sonra: -### Ozel bir anahtar uretmek +```ts +await db.users.update(id, changes); +await server.methods.user.get.cache?.drop(id); +``` -Yukarıdaki seçeneklere ek olarak, yöntemine gönderilen değiştirgeleri kullanarak önbellek anahtarı üretmek için kullanılacak özel bir işlev de tanımlayabilirsin. Eğer yöntemin yalnızca metin, sayı ve mantıksal içeriyorsa hapi senin için akıllı bir anahtar üretecektir. Ancak yönteminin değiştirgesi bir nesneyse önbellek anahtarını üretecek aşağıdaki gibi bir işlem sağlaman gerekir: +Önbelleksiz metotlarda olmadığı için hapi `cache`'i isteğe bağlı olarak tipler; `?.` bu yüzden var. -```javascript -const sum = function (array) { - let total = 0; +### Özel bir anahtar üretmek - array.forEach((item) => { - total += item; - }); +hapi önbellek anahtarını argümanları `:` ile birleştirerek oluşturur; bu yalnızca string, number ve boolean için işe yarar. Önbelleğe alınan bir metodu başka tipte bir argümanla çağırmak `500` hatasıyla sonuçlanır. Diğer argümanlar için `generateKey` ver: - return total; +```ts +const sum = function (numbers: number[]) { + return numbers.reduce((total, item) => total + item, 0); }; server.method('sum', sum, { - generateKey: (array) => array.join(','), + cache: { + expiresIn: 60 * 1000, + generateTimeout: 100, + }, + generateKey: (numbers: number[]) => numbers.join(','), }); ``` -Yöntemine gönderdiğin tüm argümanlar `generateKey` yöntemi tarafından kullanılabilir. - -### Bagla (bind) +`generateKey` metotla aynı argümanları alır ve bir string döndürmelidir. -Sunucu yöntemine gönderilen son isteğe bağlı yöntem `bind` (bağla)dır. `bind` seçeneği yöntem içerisinde geçerli olan `this` (bu) kapsamını değiştirir. Varsayılan olarak bu yöntem eklendiği andaki aktif kapsamdır. Bu özellik kullanılarak, ayrıca bir değiştirge olarak gönderilen veritabanı istemcisi sebebiyle bir de `generateKey` (anahtar üret) işlevi tanımlama sorununu ortadan kaldırabilir. Örneğin: +### Bind -```javascript -const lookup = async function (id) { - // myDB.getOne çağrısı yapar +`bind`, metot içinde `this` değerini ayarlar. Bunu metoda, örneğin bir veritabanı client'ı gibi bir bağımlılığı, argümanlara eklemeden vermek için kullan (argümanlara eklersen önbellek anahtarının da parçası olur): - return await this.getOne({ id }); +```ts +const lookup = async function (this: typeof db, id: string) { + return await this.users.findById(id); }; -server.method('lookup', lookup, { bind: myDB }); +server.method('lookup', lookup, { bind: db }); +``` + +- TypeScript'in `this` parametresi `this`'in tipini bildirir. Derleme sırasında silinir ve metodun argümanlarından biri değildir. +- `bind`, kendi `this` değerini koruyan arrow function'larda etkisizdir. +- `bind` yoksa `this`, mevcut plugin ya da sunucuda [`server.bind()`](/api#server-bind-context) ile ayarlanan context'tir. + +## server.methods + +Kaydedilen tüm metotlar [`server.methods`](/api#server-methods) üzerindedir; handler'larda bu `request.server.methods` olarak da erişilebilir: + +```ts +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + return await request.server.methods.user.get(request.params.id); + }, +}); +``` + +Bu eğitimde kullanılan her metodun `ServerMethods` içinde bir girdisi olmalı. Önbelleğe alınan metotları, `cache.drop()` ve `cache.stats` ekleyen hapi'nin `CachedServerMethod` tipiyle sarmala: + +```ts +import type { CachedServerMethod } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + math: { + add: (x: number, y: number) => number; + }; + lookup: (id: string) => Promise; + sum: CachedServerMethod<(numbers: number[]) => Promise>; + user: { + get: CachedServerMethod<(id: string) => Promise>; + }; + } +} ``` diff --git a/docs/tutorials/zh_CN/server-methods.md b/docs/tutorials/zh_CN/server-methods.md index d1d9f0ff..2c8e4c4a 100644 --- a/docs/tutorials/zh_CN/server-methods.md +++ b/docs/tutorials/zh_CN/server-methods.md @@ -1,29 +1,54 @@ --- -title: Server Methods +title: 服务器方法 --- -## 服务器方法 +# 服务器方法 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更高版本_ -服务器方法是一种非常有用的函数共享方式,与引入公用的模块不同,它只需附加在服务器对象上。注册一个服务器方法,你只需调用 [`server.method()`]()。 有两种方式调用这个函数,你可以通过方法 `server.method(name, method, [options])` 调用, 如: +## 概述 -```javascript -const add = function (x, y) { - return x + y; -}; +服务器方法是用 [`server.method()`](/api#server-method-name-method-options) 注册到服务器上的函数,凡是能拿到服务器的地方都可以调用它:路由 handler、插件、扩展。以下情况适合使用服务器方法: -server.method('add', add, {}); +- 多个路由或插件需要同一个函数,而你不想让每个地方都去导入它。 +- 函数开销较大,其结果可以缓存。只需一个选项就能为它启用 hapi 的[缓存](/tutorials/zh_CN/caching)。 + +```ts +import Hapi from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + } +} + +const server = Hapi.server({ port: 3000 }); + +server.method('add', (x: number, y: number) => x + y); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return { sum: request.server.methods.add(1, 2) }; + }, +}); + +await server.start(); ``` -又或者你可以通过方法 `server.method(method)`调用, 这里 `method` 是一个拥有 `name`, `method` 以及 `options` 参数的对象 (注意你也可以传递以上对象的数组): +- `server.methods.add` 就是注册的函数。在 handler 中,通过 `request.server.methods` 就能访问它,无需在作用域内持有 `server` 变量。 +- hapi 不知道你注册了哪些方法,所以 TypeScript 需要在 hapi 的 `ServerMethods` 接口中声明它们。`declare module` 代码块可以放在项目的任意模块中。 +- 方法对整个服务器是全局的:在某个插件中注册的方法,其他所有插件和路由都能使用。 -```javascript -const add = function (x, y) { - return x + y; -}; +## server.method() + +`server.method()` 既可以接收位置参数,也可以接收一个对象(或对象数组,用于一次注册多个方法): + +```ts +server.method('add', add, {}); server.method({ name: 'add', @@ -32,114 +57,161 @@ server.method({ }); ``` -### 名称 +### 名称 -`name` 参数是一个字符串,用于之后在服务器中通过 `server.methods[name]` 访问这个方法。注意如果你指定了一个 `name` 含有一个 `.` 字符, 它注册了一个内嵌对象,而不是一个字符串字面值,如: +名称中带点号会注册一个嵌套方法: -```javascript +```ts server.method('math.add', add); -``` -这个服务器方法可以通过 `server.methods.math.add()` 来调用。 +server.methods.math.add(1, 2); +``` -### 函数 +在 `ServerMethods` 中把它声明为嵌套对象:`math: { add: (x: number, y: number) => number }`。名称的每一段都必须是合法的 JavaScript 标识符。 -`method` 参数是当服务器方法被执行时实际调用的函数,它可以携带任意数量的参数,也可以是一个 `async` 函数,例如: +### 方法 -```js -const add = async function (x, y) { - const result = await someLongRunningFunction(x, y); - return result; -}; +方法可以接收任意参数,也可以是 `async` 函数。返回结果,或者抛出错误。 -server.method('add', add, {}); +```ts +server.method('user.get', async (id: string) => { + return await db.users.findById(id); +}); ``` -你的服务器方法函数应该返回一个有效的结果,又或者当异常发生时抛出一个错误。 +## 选项 + +| 选项 | 用途 | +| --------------------- | ------------------------------------------------ | +| [`cache`](#cache) | 缓存方法的结果 | +| [`generateKey`](#key) | 当参数不是字符串、数字或布尔值时,用它构建缓存键 | +| [`bind`](#bind) | 设置方法内部的 `this` | -## 缓存 +### 缓存 -服务器方法的一个主要优点是它们可以利用 hapi 的本机缓存。默认情况下不缓存,但是如果在注册方法时传递了相应的配置,则返回值将会被缓存。返回的结果将从缓存中获取这个值,而不是每次调用时重新运行方法。配置如下所示: +设置了 `cache` 选项后,调用方法时会先经过缓存: + +```mermaid +flowchart TD + A["server.methods.user.get('42')"] --> K["generateKey('42') 返回 '42'
在 segment '#user.get' 中查找"] + K --> C{已缓存?} + C -->|是| R[返回缓存的值] + C -->|否| M["运行你的方法"] + M --> S[保存结果,然后返回] +``` -```javascript -server.method('add', add, { +```ts +server.method('user.get', getUser, { cache: { - expiresIn: 60000, - expiresAt: '20:30', - staleIn: 30000, - staleTimeout: 10000, - generateTimeout: 100, + expiresIn: 60 * 1000, + staleIn: 30 * 1000, + staleTimeout: 100, + generateTimeout: 2000, }, }); ``` -参数可以是: - -- `expiresIn`: 以项目保存在缓存中的时间起,超过这个时间将失效。其中时间以毫秒数表示,不能与 `expiresAt`一同使用。 -- `expiresAt`: 使用以 'HH:MM' 格式以24小时为指定时间, 这个时间之后路由所有的缓存记录都将失效。这个时间使用本地时间,不能与 `expiresIn` 一同使用。 -- `staleIn`: 缓存失效时,尝试重新生成它毫秒数,必须小于 `expiresIn`。 -- `staleTimeout`: 在 generateFunc 生成新值时返回之前,可以允许返回过期值的毫秒数。 -- `generateTimeout`: 当返回时间太长时,如返回超时错误之前,等待的毫秒数。但当这个值最终被返回时,它将储存在缓存中以便将来的请求使用。 -- `segment`: 用于隔离缓存项的可选分段名称。 -- `cache`: 一个可选字符串,其中包含要使用的服务器上配置的缓存连接的名称。 +需要了解的几点: -更多关于缓存的选项的信息可以通过 [API Reference](/api#servermethodmethod) 以及 [catbox](https://github.com/hapijs/catbox#policy) 中找到。 +- `generateTimeout` 是必填项。它是调用方等待你的方法的时长,超时后会得到 `503`。 +- 带缓存的方法总是返回 promise,即使方法本身是同步的。在 `ServerMethods` 中把它的返回类型声明为 `Promise`,并用 `await` 调用。 +- hapi 把缓存项存放在名为 `'#'` 加方法名的 segment 中。设置 `cache: 'my_cache'` 可以改用[具名缓存客户端](/tutorials/zh_CN/caching#client),而不是默认的内存缓存。 +- 其他选项(`expiresAt`、`staleIn`、`staleTimeout`、`segment`)与 [`server.cache()`](/api#server-cache-options) 相同。[缓存教程](/tutorials/zh_CN/caching#stale)介绍了过期值是如何提供的。 -通过设置 `ttl` 标记,可以修改每次调用服务器方法结果的 `ttl` (生存时间)。我们来看一下如何让它与之前的例子工作: +#### 按结果修改 TTL -```js -const add = async function (x, y, flags) { - const result = await someLongRunningFunction(x, y); +hapi 会在你的参数之后多传一个参数:`flags` 对象。设置 `flags.ttl` 可以修改这一个结果的缓存时长,设为 `0` 则完全不缓存: - flags.ttl = 5 * 60 * 1000; // 5 mins +```ts +const getUser = async function (id: string, flags: { ttl: number }) { + const user = await db.users.findById(id); + if (!user) { + flags.ttl = 0; // don't cache "not found" + } - return result; + return user; }; +``` -server.method('add', add, { - cache: { - expiresIn: 2000, - generateTimeout: 100, - }, -}); +hapi 没有导出 `flags` 的类型,所以示例中直接内联声明了类型。 -server.methods.add(5, 12); -``` +#### 移除缓存的结果 -这里,我们定义了我们的服务器方法函数,这里多传递了一个参数,这个额外的 `flags` 参数由 hapi 提供。然后,只需将 `ttl` 标记的值设置为我们希望结果被缓存的时间 (以毫秒为单位)即可。 如果这个值被设为 `0` 那么返回的结果将永远不会被缓存。如果我们没有设置,那么 `ttl` 将从缓存的配置中获取。 +每个带缓存的方法都有一个 `cache.drop()` 函数。用相同的参数调用它即可移除对应的结果,例如在更新数据之后: -### 自定义缓存键 +```ts +await db.users.update(id, changes); +await server.methods.user.get.cache?.drop(id); +``` -除上述选项外,你还可以指定一个自定义函数,用于根据方法的参数来生成缓存的键。如果你的方法参数是由字符串、数字和布尔值构成的组合,那么 hapi 将会为你生成适用的键。但是,如果你的方法接受对象参数,则你需要指定一个如下类似用于生产键的函数: +hapi 把 `cache` 定义为可选属性,因为不带缓存的方法没有它,所以这里要用 `?.`。 -```javascript -const sum = function (array) { - let total = 0; +### 生成自定义键 - array.forEach((item) => { - total += item; - }); +hapi 通过用 `:` 连接各个参数来构建缓存键,这只对字符串、数字和布尔值有效。用其他类型的参数调用带缓存的方法会以 `500` 错误失败。对于其他参数,请提供 `generateKey`: - return total; +```ts +const sum = function (numbers: number[]) { + return numbers.reduce((total, item) => total + item, 0); }; server.method('sum', sum, { - generateKey: (array) => array.join(','), + cache: { + expiresIn: 60 * 1000, + generateTimeout: 100, + }, + generateKey: (numbers: number[]) => numbers.join(','), }); ``` -这样传递给方法的任何参数都可用于 `generateKey` 方法。 - -### 绑定 +`generateKey` 接收与方法相同的参数,并且必须返回字符串。 -服务器方法可用的最后一个选项是 `bind`。`bind` 选项可以修改方法中的 `this` 上下文。添加方法时,它默认为当前上下文。这对于传入数据库数据库客户端非常有用,因为无需将其作为参数传递,并且不用自定义 `generateKey` 函数,如下: +### 绑定 -```javascript -const lookup = async function (id) { - // 调用 myDB.getOne +`bind` 设置方法内部的 `this`。用它可以把数据库客户端之类的依赖交给方法,而不必把它加到参数里(放在参数里它还会成为缓存键的一部分): - return await this.getOne({ id }); +```ts +const lookup = async function (this: typeof db, id: string) { + return await this.users.findById(id); }; -server.method('lookup', lookup, { bind: myDB }); +server.method('lookup', lookup, { bind: db }); +``` + +- TypeScript 的 `this` 参数声明了 `this` 的类型。它在编译时会被擦除,不属于方法的参数。 +- `bind` 对箭头函数无效,箭头函数保留自己的 `this`。 +- 不设置 `bind` 时,`this` 是当前插件或服务器中用 [`server.bind()`](/api#server-bind-context) 设置的上下文。 + +## server.methods + +所有已注册的方法都在 [`server.methods`](/api#server-methods) 上,在 handler 中也可以通过 `request.server.methods` 访问: + +```ts +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/users/{id}', + handler: async (request, h) => { + return await request.server.methods.user.get(request.params.id); + }, +}); +``` + +本教程中用到的每个方法都需要在 `ServerMethods` 中有对应条目。带缓存的方法要用 hapi 的 `CachedServerMethod` 类型包装,它会加上 `cache.drop()` 和 `cache.stats`: + +```ts +import type { CachedServerMethod } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + add: (x: number, y: number) => number; + math: { + add: (x: number, y: number) => number; + }; + lookup: (id: string) => Promise; + sum: CachedServerMethod<(numbers: number[]) => Promise>; + user: { + get: CachedServerMethod<(id: string) => Promise>; + }; + } +} ``` From cb3e0832b0bf3549b8818af3d5bfc359385cb3c8 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:57 -0400 Subject: [PATCH 13/22] docs: rework serving files tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/serving-files.md | 255 ++++++++++++-------------- docs/tutorials/es_ES/serving-files.md | 210 +++++++++++++++++++++ docs/tutorials/ko_KR/serving-files.md | 213 ++++++++++++--------- docs/tutorials/pt_BR/serving-files.md | 229 +++++++++++++---------- docs/tutorials/tr_TR/serving-files.md | 215 +++++++++++++--------- docs/tutorials/zh_CN/serving-files.md | 215 +++++++++++++--------- 6 files changed, 830 insertions(+), 507 deletions(-) create mode 100644 docs/tutorials/es_ES/serving-files.md diff --git a/docs/tutorials/en_US/serving-files.md b/docs/tutorials/en_US/serving-files.md index 63343368..ac946d7f 100644 --- a/docs/tutorials/en_US/serving-files.md +++ b/docs/tutorials/en_US/serving-files.md @@ -6,92 +6,82 @@ title: Serving Files # Serving Static Content -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -Inevitably while building any web application, the need arises to serve a simple file from disk. There is a hapi plugin called [inert](/module/inert) that adds this functionality to hapi through the use of additional handlers. +hapi serves files from disk through the [inert](/module/inert) plugin. Install it: -First you need to install and add `inert` as a dependency to your project: - -`npm install @hapi/inert` +```sh +npm install @hapi/inert +``` ## Inert -The `inert` plugin provides new handler methods for serving static files and directories, as well as adding a `h.file()` method to the toolkit, which can respond with file based resources. - -## Relative paths +Registering inert adds three ways to send files: -To simplify things, especially if you have multiple routes that respond with files, you can configure a base path in your server and only pass relative paths to `h.file()`: +| What | Use it for | +| ------------------- | ---------------------------------------------------------- | +| `h.file(path)` | sending a file from inside a handler, after your own logic | +| `file` handler | a route that always serves one file | +| `directory` handler | a route that serves every file in a folder | -```javascript -'use strict'; +Importing `@hapi/inert` also adds these to hapi's TypeScript types, so `h.file()` and the `file` and `directory` handlers type-check. -const Hapi = require('@hapi/hapi'); -const Path = require('path'); +## Relative paths -const start = async () => { - const server = Hapi.server({ - routes: { - files: { - relativeTo: Path.join(__dirname, 'public'), - }, - }, - }); +Set `routes.files.relativeTo` on the server once, and every file path in your routes is resolved from that folder: - await server.register(require('@hapi/inert')); +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('picture.jpg'); +const server = Hapi.server({ + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), }, - }); + }, +}); - await server.start(); +await server.register(Inert); - console.log('Server running at:', server.info.uri); -}; +server.route({ + method: 'GET', + path: '/picture.jpg', + handler: (request, h) => { + return h.file('picture.jpg'); + }, +}); -start(); +await server.start(); +console.log('Server running at:', server.info.uri); ``` -When you set an option under `server.options.routes`, such as above, it will apply to _all_ routes. You can also set these options, including the `relativeTo` option on a per-route level. +`routes.files.relativeTo` defaults to the process's working directory. Any option under the server's `routes` applies to every route, and a route can override it in its own `options.files`. ## `h.file(path, [options])` -Now, let's see how to use the [`h.file()`](/module/inert/api#hfilepath-options) method: - -```javascript -const start = async () => { - const server = Hapi.server(); +[`h.file()`](/module/inert#h-file-path-options) sends a file as the response. Paths are confined to the `relativeTo` folder: a path that resolves outside it, such as `'../secrets.json'` or an absolute path elsewhere on disk, replies `403 Forbidden`. That stops a request parameter from reading arbitrary files: - await server.register(require('@hapi/inert')); - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('/path/to/picture.jpg'); - }, - }); - - await server.start(); - - console.log('Server running at:', server.info.uri); -}; - -start(); +```ts +server.route<{ Params: { filename: string } }>({ + method: 'GET', + path: '/download/{filename}', + handler: (request, h) => { + return h.file(request.params.filename, { mode: 'attachment' }); + }, +}); ``` -By requiring the `inert` plugin, you get access `h.file()` method. Here, you tell `h.file()` the path of the image you want to return. In this case, `'/path/to/picture.jpg'`. +`/download/report.pdf` sends `public/report.pdf` as a download, and `/download/..%2Fserver.ts` gets `403`. To send a file from outside the folder on purpose, pass `confine: false`, and never with a path built from user input. ## File handler -An alternative to using the `h.file()` method would be to use the `file` handler: +When a route always serves the same file, use the `file` handler instead of writing a handler function: -```javascript +```ts server.route({ method: 'GET', path: '/picture.jpg', @@ -103,42 +93,39 @@ server.route({ ### File handler options -You can also specify the parameter as a function that accepts the `request` object and returns a string representing the file's path (absolute or relative): - -```javascript -server.route({ - method: 'GET', - path: '/{filename}', - handler: { - file: function (request) { - return request.params.filename; - }, - }, -}); -``` - -It can also be an object with a `path` property. When using the object form of the handler, you can do a few additional things, like setting the `Content-Disposition` header and allowing compressed files like so: +The object form takes the same options as `h.file()`: -```javascript +```ts server.route({ method: 'GET', path: '/script.js', handler: { file: { path: 'script.js', - filename: 'client.js', // override the filename in the Content-Disposition header - mode: 'attachment', // specify the Content-Disposition is an attachment - lookupCompressed: true, // allow looking for script.js.gz if the request allows it + filename: 'client.js', + mode: 'attachment', + lookupCompressed: true, }, }, }); ``` +| Option | Effect | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path` | the file to send; also accepts a function `(request) => path` | +| `mode` | `'attachment'` (download) or `'inline'`; sets the `Content-Disposition` header | +| `filename` | the file name in `Content-Disposition`, here `client.js` instead of `script.js` | +| `lookupCompressed` | serves `script.js.gz` when it exists and the client accepts gzip; `lookupMap` replaces the default `{ gzip: '.gz' }`, so list every encoding, such as `{ gzip: '.gz', br: '.br' }` (`br` also needs [`server.encoder()`](/tutorials/en_US/response-marshalling#compression)) | +| `confine` | `true` (default) keeps paths inside `relativeTo`; `false` allows any path | +| `etagMethod` | `'hash'` (default), `'simple'` (size and modified time, cheaper), or `false` | + +The full list is in the [inert API](/module/inert#the-file-handler). + ## Directory handler -In addition to the `file` handler, inert also adds a `directory` handler that allows you to specify one route to serve multiple files. In order to use it, you must specify a route path with a parameter. The name of the parameter does not matter, however. You can use the asterisk extension on the parameter to restrict file depth as well. The most basic usage of the directory handler looks like: +The `directory` handler serves a whole folder from one route. The path must end with a parameter, and the parameter's value is the file to look up. The parameter sets how deep the route serves: `{param}` serves one level, `{param?}` also serves the folder root, `{param*}` serves any depth, and `{param*2}` serves exactly two levels: -```javascript +```ts server.route({ method: 'GET', path: '/{param*}', @@ -152,80 +139,72 @@ server.route({ ### Directory handler options -The above route will respond to any request by looking for a matching filename in the `directory-path-here` directory. Note that a request to `/` in this configuration will reply with an HTTP `403` response. You can fix this by adding an index file. By default hapi will search in the directory for a file called `index.html`. You can disable serving an index file by setting the index option to `false`, or alternatively you can specify an array of files that inert should look for as index files: +For each request, inert resolves the file like this: + +```mermaid +flowchart TD + R["request /docs"] --> F{"is it a file?"} + F -->|yes| S[send the file] + F -->|"not found"| E{"defaultExtension set?"} + E -->|yes| X["try /docs.html"] + E -->|no| N[404] + F -->|"a directory"| T{"trailing slash?"} + T -->|no| RD["302 to /docs/"] + T -->|yes| I{"index file exists?"} + I -->|yes| SI[send index.html] + I -->|no| L{"listing: true?"} + L -->|yes| HL[send HTML listing] + L -->|no| FB[403] +``` -```javascript -server.route({ - method: 'GET', - path: '/{param*}', - handler: { - directory: { - path: 'directory-path-here', - index: ['index.html', 'default.html'], +| Option | Default | Effect | +| ------------------ | -------- | -------------------------------------------------------------------------------------------------- | +| `path` | required | the folder, an array of folders tried in order, or a function `(request) => path` | +| `index` | `true` | serve `index.html` for a directory; a string or array names other index files; `false` disables it | +| `listing` | `false` | show an HTML listing for a directory with no index file | +| `showHidden` | `false` | include hidden files in listings and serve them | +| `redirectToSlash` | `true` | redirect `/docs` to `/docs/` when `docs` is a directory | +| `defaultExtension` | none | when `/about` is not found, try `/about.html` | +| `lookupCompressed` | `false` | serve precompressed `.gz` files, same as the `file` handler option | + +With `index: ['index.html', 'default.html']`, a directory request tries `index.html` first, then `default.html`. + +## Static file server + +A complete server that serves the `public` folder at the site root: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -``` -A request to `/` will now first try to load `/index.html`, then `/default.html`. When there is no index file available, inert can display the contents of the directory as a listing page. You can enable that by setting the `listing` property to `true` like so: +await server.register(Inert); -```javascript server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'directory-path-here', - listing: true, + path: '.', + defaultExtension: 'html', }, }, }); -``` - -Now a request to `/` will reply with HTML showing the contents of the directory. When using the directory handler with listing enabled, by default hidden files will not be shown in the listing. That can be changed by setting the `showHidden` option to `true`. Like the file handler, the directory handler also has a `lookupCompressed` option to serve precompressed files when possible. You can also set a `defaultExtension` that will be appended to requests if the original path is not found. This means that a request for `/bacon` will also try the file `/bacon.html`. - -## Static file server - -One common case for serving static content is setting up a file server. The following example shows how to setup a basic file serve in hapi: - -```js -const Path = require('path'); -const Hapi = require('@hapi/hapi'); -const Inert = require('@hapi/inert'); -const init = async () => { - const server = new Hapi.Server({ - port: 3000, - routes: { - files: { - relativeTo: Path.join(__dirname, 'public'), - }, - }, - }); - - await server.register(Inert); - - server.route({ - method: 'GET', - path: '/{param*}', - handler: { - directory: { - path: '.', - redirectToSlash: true, - }, - }, - }); - - await server.start(); - - console.log('Server running at:', server.info.uri); -}; - -init(); +await server.start(); +console.log('Server running at:', server.info.uri); ``` -The first thing you do is require both `inert` and `path`. As you will see, you will need both of these to get our file server up and running. - -Next, you configure `server.options.routes`. You set the location the server will look for the static files by setting the `relativeTo` option. - -After your server is configured, you then register the `inert` plugin. This will allow you to have access to the `directory` handler, which will enable you to server your files. In the `directory` handler, you configure `path`, which is required, to look in the entire `public` directory which you specified in the `relativeTo` option. The second option is the `redirectToSlash` option. By setting this to `true`, you tell the server to redirect requests without trailing slashes to the same path with those with the trailing slash. +- `path: '.'` points the handler at `relativeTo`, the `public` folder. +- `/` serves `public/index.html`, and `/docs` redirects to `/docs/`, which serves `public/docs/index.html`. +- `defaultExtension: 'html'` lets `/about` serve `public/about.html`, so your URLs need no `.html`. +- Because `/{param*}` is the least specific path possible, any other route you add, such as `/api/users`, still answers its own requests. See [which route wins](/tutorials/en_US/routing#specificity). diff --git a/docs/tutorials/es_ES/serving-files.md b/docs/tutorials/es_ES/serving-files.md new file mode 100644 index 00000000..559e9a4e --- /dev/null +++ b/docs/tutorials/es_ES/serving-files.md @@ -0,0 +1,210 @@ +--- +title: Servir ficheros +--- + + + +# Servir contenido estático + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +hapi sirve ficheros del disco a través del plugin [inert](/module/inert). Instálalo: + +```sh +npm install @hapi/inert +``` + +## Inert + +Al registrar inert tienes tres formas de enviar ficheros: + +| Qué | Para qué sirve | +| ------------------- | ------------------------------------------------------------------ | +| `h.file(path)` | enviar un fichero desde dentro de un handler, después de tu lógica | +| handler `file` | una ruta que siempre sirve un mismo fichero | +| handler `directory` | una ruta que sirve todos los ficheros de una carpeta | + +Importar `@hapi/inert` también añade todo esto a los tipos de TypeScript de hapi, así que `h.file()` y los handlers `file` y `directory` pasan la comprobación de tipos. + +## Rutas relativas + +Configura `routes.files.relativeTo` una vez en el servidor, y todas las rutas de ficheros de tus rutas se resolverán a partir de esa carpeta: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), + }, + }, +}); + +await server.register(Inert); + +server.route({ + method: 'GET', + path: '/picture.jpg', + handler: (request, h) => { + return h.file('picture.jpg'); + }, +}); + +await server.start(); +console.log('Server running at:', server.info.uri); +``` + +Por defecto, `routes.files.relativeTo` es el directorio de trabajo del proceso. Cualquier opción bajo `routes` del servidor se aplica a todas las rutas, y una ruta puede sobrescribirla en sus propias `options.files`. + +## `h.file(path, [options])` + +[`h.file()`](/module/inert#h-file-path-options) envía un fichero como respuesta. Las rutas están confinadas a la carpeta `relativeTo`: una ruta que se resuelve fuera de ella, como `'../secrets.json'` o una ruta absoluta a otro sitio del disco, responde `403 Forbidden`. Eso impide que un parámetro de la petición lea ficheros arbitrarios: + +```ts +server.route<{ Params: { filename: string } }>({ + method: 'GET', + path: '/download/{filename}', + handler: (request, h) => { + return h.file(request.params.filename, { mode: 'attachment' }); + }, +}); +``` + +`/download/report.pdf` envía `public/report.pdf` como descarga, y `/download/..%2Fserver.ts` recibe un `403`. Para enviar a propósito un fichero de fuera de la carpeta, pasa `confine: false`, y nunca con una ruta construida a partir de datos del usuario. + +## Handler file + +Cuando una ruta siempre sirve el mismo fichero, usa el handler `file` en lugar de escribir una función handler: + +```ts +server.route({ + method: 'GET', + path: '/picture.jpg', + handler: { + file: 'picture.jpg', + }, +}); +``` + +### Opciones del handler file + +La forma de objeto acepta las mismas opciones que `h.file()`: + +```ts +server.route({ + method: 'GET', + path: '/script.js', + handler: { + file: { + path: 'script.js', + filename: 'client.js', + mode: 'attachment', + lookupCompressed: true, + }, + }, +}); +``` + +| Opción | Efecto | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `path` | el fichero que se envía; también acepta una función `(request) => path` | +| `mode` | `'attachment'` (descarga) o `'inline'`; pone la cabecera `Content-Disposition` | +| `filename` | el nombre del fichero en `Content-Disposition`, aquí `client.js` en lugar de `script.js` | +| `lookupCompressed` | sirve `script.js.gz` cuando existe y el cliente acepta gzip; `lookupMap` sustituye al `{ gzip: '.gz' }` por defecto, así que enumera todas las codificaciones, como `{ gzip: '.gz', br: '.br' }` (`br` también necesita [`server.encoder()`](/tutorials/es_ES/response-marshalling#compression)) | +| `confine` | `true` (por defecto) mantiene las rutas dentro de `relativeTo`; `false` permite cualquier ruta | +| `etagMethod` | `'hash'` (por defecto), `'simple'` (tamaño y fecha de modificación, más barato) o `false` | + +La lista completa está en la [API de inert](/module/inert#the-file-handler). + +## Handler directory + +El handler `directory` sirve una carpeta entera desde una sola ruta. El path tiene que terminar en un parámetro, y el valor del parámetro es el fichero que se busca. El parámetro determina a qué profundidad sirve la ruta: `{param}` sirve un nivel, `{param?}` sirve también la raíz de la carpeta, `{param*}` sirve cualquier profundidad y `{param*2}` sirve exactamente dos niveles: + +```ts +server.route({ + method: 'GET', + path: '/{param*}', + handler: { + directory: { + path: 'directory-path-here', + }, + }, +}); +``` + +### Opciones del handler directory + +Para cada petición, inert resuelve el fichero así: + +```mermaid +flowchart TD + R["petición /docs"] --> F{"¿es un fichero?"} + F -->|sí| S[envía el fichero] + F -->|"no existe"| E{"¿hay defaultExtension?"} + E -->|sí| X["prueba /docs.html"] + E -->|no| N[404] + F -->|"es un directorio"| T{"¿barra final?"} + T -->|no| RD["302 a /docs/"] + T -->|sí| I{"¿existe el fichero índice?"} + I -->|sí| SI[envía index.html] + I -->|no| L{"¿listing: true?"} + L -->|sí| HL[envía un listado HTML] + L -->|no| FB[403] +``` + +| Opción | Por defecto | Efecto | +| ------------------ | ----------- | -------------------------------------------------------------------------------------------------------------- | +| `path` | obligatoria | la carpeta, un array de carpetas que se prueban en orden, o una función `(request) => path` | +| `index` | `true` | sirve `index.html` para un directorio; un string o un array indica otros ficheros índice; `false` lo desactiva | +| `listing` | `false` | muestra un listado HTML para un directorio sin fichero índice | +| `showHidden` | `false` | incluye los ficheros ocultos en los listados y los sirve | +| `redirectToSlash` | `true` | redirige `/docs` a `/docs/` cuando `docs` es un directorio | +| `defaultExtension` | ninguna | cuando no se encuentra `/about`, prueba `/about.html` | +| `lookupCompressed` | `false` | sirve ficheros `.gz` precomprimidos, igual que la opción del handler `file` | + +Con `index: ['index.html', 'default.html']`, una petición a un directorio prueba primero `index.html` y después `default.html`. + +## Servidor de ficheros estáticos + +Un servidor completo que sirve la carpeta `public` en la raíz del sitio: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), + }, + }, +}); + +await server.register(Inert); + +server.route({ + method: 'GET', + path: '/{param*}', + handler: { + directory: { + path: '.', + defaultExtension: 'html', + }, + }, +}); + +await server.start(); +console.log('Server running at:', server.info.uri); +``` + +- `path: '.'` hace que el handler apunte a `relativeTo`, la carpeta `public`. +- `/` sirve `public/index.html`, y `/docs` redirige a `/docs/`, que sirve `public/docs/index.html`. +- `defaultExtension: 'html'` hace que `/about` sirva `public/about.html`, así que tus URLs no necesitan `.html`. +- Como `/{param*}` es el path menos específico posible, cualquier otra ruta que añadas, como `/api/users`, sigue respondiendo a sus propias peticiones. Consulta [qué ruta gana](/tutorials/es_ES/routing#specificity). diff --git a/docs/tutorials/ko_KR/serving-files.md b/docs/tutorials/ko_KR/serving-files.md index 43046848..035baf64 100644 --- a/docs/tutorials/ko_KR/serving-files.md +++ b/docs/tutorials/ko_KR/serving-files.md @@ -1,136 +1,131 @@ --- -title: Serving Files +title: 파일 제공 --- -## 정적 파일 제공하기 +# 정적 콘텐츠 제공 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -필연적으로 웹 응용프로그램을 만드는 동안 디스크에 있는 단순 파일을 제공해야 합니다. 추가 핸들러의 사용으로 hapi에 이 기능을 추가하는 [inert](https://github.com/hapijs/inert)라는 hapi 플러그인이 있습니다. +## 개요 -먼저 inert 설치와 프로젝트에 의존성을 추가해야 합니다.: +hapi는 [inert](/module/inert) 플러그인으로 디스크의 파일을 제공합니다. 설치하세요: -`npm install --save @hapi/inert` - -## `h.file(path, [options])` - -먼저 [`h.file()`](https://github.com/hapijs/inert#hfilepath-options) 메소드를 사용하는 방법을 봅니다.: - -```javascript -const start = async () => { - await server.register(require('@hapi/inert')); - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('/path/to/picture.jpg'); - }, - }); - - await server.start(); +```sh +npm install @hapi/inert +``` - console.log('Server running at:', server.info.uri); -}; +## Inert -start(); -``` +inert를 등록하면 파일을 보내는 세 가지 방법이 추가됩니다: -위에서 볼 수 있는 것처럼 가장 기본적인 형태로 `h.file(path)`를 반환하는 것입니다. +| 방법 | 용도 | +| ------------------ | ------------------------------------------------------- | +| `h.file(path)` | 핸들러 안에서 직접 작성한 로직을 거친 뒤 파일을 보낼 때 | +| `file` 핸들러 | 항상 한 파일을 제공하는 라우트 | +| `directory` 핸들러 | 폴더 안의 모든 파일을 제공하는 라우트 | -### 상대 경로 +`@hapi/inert`를 import하면 hapi의 TypeScript 타입에도 이들이 추가되므로 `h.file()`과 `file`, `directory` 핸들러의 타입 검사가 통과합니다. -특히 파일로 응답하는 경로가 여러 개가 있다면, 단순하게 만들기 위해 서버 안에 기본 경로를 설정할 수 있고, 단지 상대 경로만 `h.file()`에 전달합니다. +## 상대 경로 -```javascript -'use strict'; +서버에 `routes.files.relativeTo`를 한 번 설정하면 라우트의 모든 파일 경로가 그 폴더를 기준으로 해석됩니다: -const Hapi = require('@hapi/hapi'); -const Path = require('path'); +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; const server = Hapi.server({ routes: { files: { - relativeTo: Path.join(__dirname, 'public'), + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -const start = async () => { - await server.register(require('@hapi/inert')); - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('picture.jpg'); - }, - }); - - await server.start(); +await server.register(Inert); - console.log('Server running at:', server.info.uri); -}; +server.route({ + method: 'GET', + path: '/picture.jpg', + handler: (request, h) => { + return h.file('picture.jpg'); + }, +}); -start(); +await server.start(); +console.log('Server running at:', server.info.uri); ``` -위와 같이 `server.options.routes` 아래의 옵션을 설정하면 _모든_ 경로에 적용될 것입니다. 경로 수준에서 `relativeTo` 옵션을 포함하여 이 옵션을 설정할 수 있습니다. +`routes.files.relativeTo`의 기본값은 프로세스의 작업 디렉터리입니다. 서버의 `routes` 아래에 있는 옵션은 모든 라우트에 적용되며, 라우트는 자신의 `options.files`에서 이를 덮어쓸 수 있습니다. -## 파일 핸들러(handler) +## `h.file(path, [options])` -위의 경로 대신 `file` 핸들러를 사용할 수 있습니다.: +[`h.file()`](/module/inert#h-file-path-options)은 파일을 응답으로 보냅니다. 경로는 `relativeTo` 폴더 안으로 제한됩니다. `'../secrets.json'`이나 디스크의 다른 위치를 가리키는 절대 경로처럼 폴더 밖으로 해석되는 경로는 `403 Forbidden`으로 응답합니다. 덕분에 요청 파라미터로 임의의 파일을 읽을 수 없습니다: -```javascript -server.route({ +```ts +server.route<{ Params: { filename: string } }>({ method: 'GET', - path: '/picture.jpg', - handler: { - file: 'picture.jpg', + path: '/download/{filename}', + handler: (request, h) => { + return h.file(request.params.filename, { mode: 'attachment' }); }, }); ``` -### 파일 핸들러 옵션 +`/download/report.pdf`는 `public/report.pdf`를 다운로드로 보내고, `/download/..%2Fserver.ts`는 `403`을 받습니다. 의도적으로 폴더 밖의 파일을 보내려면 `confine: false`를 넘기되, 사용자 입력으로 만든 경로에는 절대 쓰지 마세요. -`request` 객체를 받아 파일의 경로(절대 또는 상대)를 문자열로 넘겨주는 함수를 인자로 지정할 수 있습니다: +## 파일 핸들러 -```javascript +라우트가 항상 같은 파일을 제공한다면 핸들러 함수를 작성하는 대신 `file` 핸들러를 사용하세요: + +```ts server.route({ method: 'GET', - path: '/{filename}', + path: '/picture.jpg', handler: { - file: function (request) { - return request.params.filename; - }, + file: 'picture.jpg', }, }); ``` -`path` 속성을 가진 객체일 수도 있습니다. 객체 형식의 핸들러를 사용할 때, `Content-Disposition` 헤더 설정과 압축된 파일을 허용하는 것 같은 몇 가지 추가적인 것을 할 수 있습니다. +### 파일 핸들러 옵션 + +객체 형태는 `h.file()`과 같은 옵션을 받습니다: -```javascript +```ts server.route({ method: 'GET', path: '/script.js', handler: { file: { path: 'script.js', - filename: 'client.js', // override the filename in the Content-Disposition header - mode: 'attachment', // specify the Content-Disposition is an attachment - lookupCompressed: true, // allow looking for script.js.gz if the request allows it + filename: 'client.js', + mode: 'attachment', + lookupCompressed: true, }, }, }); ``` -## 디렉터리 핸들러 +| 옵션 | 효과 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `path` | 보낼 파일. 함수 `(request) => path`도 받음 | +| `mode` | `'attachment'`(다운로드) 또는 `'inline'`. `Content-Disposition` 헤더를 설정 | +| `filename` | `Content-Disposition`의 파일 이름. 여기서는 `script.js` 대신 `client.js` | +| `lookupCompressed` | `script.js.gz`가 있고 클라이언트가 gzip을 받으면 그 파일을 제공. `lookupMap`은 기본값 `{ gzip: '.gz' }`를 대체하므로 `{ gzip: '.gz', br: '.br' }`처럼 모든 인코딩을 나열해야 함(`br`에는 [`server.encoder()`](/tutorials/ko_KR/response-marshalling#compression)도 필요) | +| `confine` | `true`(기본값)는 경로를 `relativeTo` 안으로 제한. `false`는 모든 경로를 허용 | +| `etagMethod` | `'hash'`(기본값), `'simple'`(크기와 수정 시간, 비용이 더 낮음), 또는 `false` | -`file` 핸들러 외에 inert는 여러 파일을 제공하는 하나의 경로를 지정할 수 있는 `directory` 핸들러를 추가했습니다. 이를 사용하려면 인자를 가진 경로를 지정해야 합니다. 그러나 인자의 이름은 중요하지 않습니다. 인자에 별표(\*) 확장을 사용하여 파일 깊이도 제약할 수 있습니다. directory 핸들러의 가장 기본적인 사용법은 다음과 같습니다.: +전체 목록은 [inert API](/module/inert#the-file-handler)에 있습니다. -```javascript +## 디렉터리 핸들러 + +`directory` 핸들러는 한 라우트로 폴더 전체를 제공합니다. 경로는 파라미터로 끝나야 하며, 파라미터의 값이 찾을 파일입니다. 파라미터는 라우트가 제공하는 깊이를 정합니다. `{param}`은 한 단계, `{param?}`은 폴더 루트까지, `{param*}`은 모든 깊이, `{param*2}`는 정확히 두 단계를 제공합니다: + +```ts server.route({ method: 'GET', path: '/{param*}', @@ -142,36 +137,74 @@ server.route({ }); ``` -### 디렉터리 핸들러 옵션 +### 디렉터리 핸들러 옵션 + +inert는 요청마다 다음과 같이 파일을 찾습니다: + +```mermaid +flowchart TD + R["요청 /docs"] --> F{"파일인가?"} + F -->|예| S["파일 전송"] + F -->|"없음"| E{"defaultExtension 설정?"} + E -->|예| X["/docs.html 시도"] + E -->|아니요| N[404] + F -->|"디렉터리"| T{"끝에 슬래시?"} + T -->|아니요| RD["/docs/로 302"] + T -->|예| I{"인덱스 파일 있음?"} + I -->|예| SI["index.html 전송"] + I -->|아니요| L{"listing: true?"} + L -->|예| HL["HTML 목록 전송"] + L -->|아니요| FB[403] +``` -위의 경로는 `directory-path-here` 디렉터리 안의 일치하는 파일 이름을 찾아 요청에 응답합니다. 이 설정에서 `/`에 대한 요청은 HTTP `403`으로 응답할 것입니다. index 파일을 추가하여 이를 수정할 수 있습니다. 기본으로 hapi는 `index.html`으로 불리는 파일을 디렉터리에서 찾을 것입니다. index 옵션을 `false`로 설정하여 index 파일을 제공하지 않도록 할 수 있습니다. 또는 inert가 index 파일로 찾을 파일의 배열을 지정할 수 있습니다. +| 옵션 | 기본값 | 효과 | +| ------------------ | ------- | -------------------------------------------------------------------------------------------------- | +| `path` | 필수 | 폴더, 순서대로 시도할 폴더의 배열, 또는 함수 `(request) => path` | +| `index` | `true` | 디렉터리에 대해 `index.html`을 제공. 문자열이나 배열로 다른 인덱스 파일을 지정. `false`는 비활성화 | +| `listing` | `false` | 인덱스 파일이 없는 디렉터리에 HTML 목록을 표시 | +| `showHidden` | `false` | 숨김 파일을 목록에 포함하고 제공 | +| `redirectToSlash` | `true` | `docs`가 디렉터리이면 `/docs`를 `/docs/`로 리다이렉트 | +| `defaultExtension` | 없음 | `/about`을 찾지 못하면 `/about.html`을 시도 | +| `lookupCompressed` | `false` | 미리 압축된 `.gz` 파일을 제공. `file` 핸들러 옵션과 같음 | -```javascript -server.route({ - method: 'GET', - path: '/{param*}', - handler: { - directory: { - path: 'directory-path-here', - index: ['index.html', 'default.html'], +`index: ['index.html', 'default.html']`이면 디렉터리 요청은 `index.html`을 먼저 시도하고 그다음 `default.html`을 시도합니다. + +## 정적 파일 서버 + +사이트 루트에서 `public` 폴더를 제공하는 완전한 서버입니다: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -``` -`/`에 대한 요청은 먼저 `/index.html`을 불러오려고 시도할 것이고, 그 다음에는 `/default.html`을 시도할 것입니다. 사용가능한 index 파일이 없다면 inert는 디렉터리의 내용을 목록 페이지로 보여줄 것입니다. 다음과 같이 `listing` 속성을 `true`로 설정하여 활성화 할 수 있습니다.: +await server.register(Inert); -```javascript server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'directory-path-here', - listing: true, + path: '.', + defaultExtension: 'html', }, }, }); + +await server.start(); +console.log('Server running at:', server.info.uri); ``` -이제 `/`에 대한 요청은 디렉터리의 내용을 보여주는 HTML로 응답할 것입니다. 목록이 활성화된 directory 핸들러를 사용할 때, 기본으로 숨겨진 파일들은 목록에서 보이지 않습니다. `showHidden` 옵션을 `true`로 설정하는 것으로 변경할 수 있습니다. file 핸들러처럼 directory 핸들러 또한 가능한 경우 미리 압축된 파일을 제공하는 `lookupCompressed` 옵션을 가지고 있습니다. 원래 경로를 찾지 못할 경우 요청에 추가되는 `defaultExtension` 옵션을 설정할 수 있습니다. `/bacon`에 대한 요청은 파일 `/bacon.html`에 대해서도 시도할 것입니다. +- `path: '.'`는 핸들러가 `relativeTo`, 즉 `public` 폴더를 가리키게 합니다. +- `/`는 `public/index.html`을 제공하고, `/docs`는 `/docs/`로 리다이렉트되어 `public/docs/index.html`을 제공합니다. +- `defaultExtension: 'html'` 덕분에 `/about`이 `public/about.html`을 제공하므로 URL에 `.html`이 필요 없습니다. +- `/{param*}`는 가능한 경로 중 가장 덜 구체적이므로 `/api/users` 같은 다른 라우트를 추가해도 그 라우트가 자신의 요청에 응답합니다. [어떤 라우트가 선택되는가](/tutorials/ko_KR/routing#specificity)를 참고하세요. diff --git a/docs/tutorials/pt_BR/serving-files.md b/docs/tutorials/pt_BR/serving-files.md index 79c0b695..531a6ea7 100644 --- a/docs/tutorials/pt_BR/serving-files.md +++ b/docs/tutorials/pt_BR/serving-files.md @@ -1,175 +1,210 @@ --- -title: Serving Files +title: Servindo arquivos --- -## Servindo arquivos estáticos +# Servindo conteúdo estático -_Este tutorial é compatível com hapi v16_ +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ -Inevitavelmente quando desenvolvemos algumas aplicações web, surge a necessidade de servir simples arquivos do disco. Há um plugin chamado [inert](https://github.com/hapijs/inert) que adiciona essa funcionalidade para o hapi atráves de manipuladores adicionais. +## Visão geral -Primeiro você precisa instalar e adicinar o inert como uma dependência para seu projeto: +O hapi serve arquivos do disco por meio do plugin [inert](/module/inert). Instale-o: -`npm install --save inert` - -## reply.file() - -Primeiro, para usar o método reply: - -```javascript -server.register(require('inert'), (err) => { - if (err) { - throw err; - } - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, reply) { - reply.file('/path/to/picture.jpg'); - }, - }); +```sh +npm install @hapi/inert +``` - server.start((err) => { - if (err) { - throw err; - } +## Inert - console.log('Servidor rodando em:', server.info.uri); - }); -}); -``` +Registrar o inert adiciona três formas de enviar arquivos: -Como eu tenho certeza que você adivinhou, em sua forma mais simples você passa um caminho para `reply.file()` e está feito. +| O quê | Use para | +| ------------------- | --------------------------------------------------------------- | +| `h.file(path)` | enviar um arquivo de dentro de um handler, depois da sua lógica | +| handler `file` | uma rota que sempre serve um único arquivo | +| handler `directory` | uma rota que serve todos os arquivos de uma pasta | -### Caminhos relativos +Importar `@hapi/inert` também adiciona esses recursos aos tipos TypeScript do hapi, então `h.file()` e os handlers `file` e `directory` passam na verificação de tipos. -Para simplificar as coisas, especialmente se você tiver várias rotas que respondem com arquivos, você pode configurar um caminho base no seu servidor e passar apenas os caminhos relativos para `reply.file()` +## Caminhos relativos -```javascript -'use strict'; +Defina `routes.files.relativeTo` uma vez no servidor, e todo caminho de arquivo nas suas rotas é resolvido a partir dessa pasta: -const Path = require('path'); -const Hapi = require('hapi'); +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; -const server = new Hapi.Server({ - connections: { - routes: { - files: { - relativeTo: Path.join(__dirname, 'public'), - }, +const server = Hapi.server({ + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -server.register(require('inert'), (err) => { - if (err) { - throw err; - } - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, reply) { - reply.file('path/to/picture.jpg'); - }, - }); - - server.start((err) => { - if (err) { - throw err; - } +await server.register(Inert); - console.log('Server running at:', server.info.uri); - }); +server.route({ + method: 'GET', + path: '/picture.jpg', + handler: (request, h) => { + return h.file('picture.jpg'); + }, }); + +await server.start(); +console.log('Server running at:', server.info.uri); ``` -Como você pode ter adivinhado pela opção passada para o servidor, o parâmetro `relativeTo` também pode ser definido em um nível por conexão ou por rota. +O padrão de `routes.files.relativeTo` é o diretório de trabalho do processo. Qualquer opção em `routes` do servidor se aplica a todas as rotas, e uma rota pode sobrescrevê-la nas suas próprias `options.files`. -## Manipulador de arquivo +## `h.file(path, [options])` -Uma alternativa para a rota acima seria o uso do manipulador `file`: +[`h.file()`](/module/inert#h-file-path-options) envia um arquivo como resposta. Os caminhos ficam confinados à pasta `relativeTo`: um caminho que resolve para fora dela, como `'../secrets.json'` ou um caminho absoluto em outro lugar do disco, responde `403 Forbidden`. Isso impede que um parâmetro da requisição leia arquivos arbitrários: -```javascript -server.route({ +```ts +server.route<{ Params: { filename: string } }>({ method: 'GET', - path: '/picture.jpg', - handler: { - file: 'picture.jpg', + path: '/download/{filename}', + handler: (request, h) => { + return h.file(request.params.filename, { mode: 'attachment' }); }, }); ``` -### Opções do manipulador de arquivo +`/download/report.pdf` envia `public/report.pdf` como download, e `/download/..%2Fserver.ts` recebe `403`. Para enviar de propósito um arquivo de fora da pasta, passe `confine: false`, e nunca com um caminho montado a partir de entrada do usuário. -Nós também podemos especificar o parâmetro como uma função que aceita o objeto `request` e retorna uma string que representa o caminho do arquivo (absoluto ou relativo): +## Handler de arquivo -```javascript +Quando uma rota sempre serve o mesmo arquivo, use o handler `file` em vez de escrever uma função handler: + +```ts server.route({ method: 'GET', - path: '/{filename}', + path: '/picture.jpg', handler: { - file: function (request) { - return request.params.filename; - }, + file: 'picture.jpg', }, }); ``` -Isso também pode ser um objeto com a propriedade `path`. Ao usar o objeto formulário do manipulador, -nós podemos fazer algumas coisas adicionais, como o ajuste do cabeçalho `Content-Disposition` e permitir arquivos compactados, como: +### Opções do handler de arquivo + +A forma de objeto aceita as mesmas opções de `h.file()`: -```javascript +```ts server.route({ method: 'GET', path: '/script.js', handler: { file: { path: 'script.js', - filename: 'client.js', // sobreescreve o nome do arquivo no cabeçalho Content-Disposition - mode: 'attachment', // especifica o Content-Disposition com um anexo - lookupCompressed: true, // permite olhar para script.js.gz se a requisição permitir isso + filename: 'client.js', + mode: 'attachment', + lookupCompressed: true, }, }, }); ``` -## Diretório de manipuladores +| Opção | Efeito | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path` | o arquivo a enviar; também aceita uma função `(request) => path` | +| `mode` | `'attachment'` (download) ou `'inline'`; define o header `Content-Disposition` | +| `filename` | o nome do arquivo em `Content-Disposition`, aqui `client.js` em vez de `script.js` | +| `lookupCompressed` | serve `script.js.gz` quando ele existe e o cliente aceita gzip; `lookupMap` substitui o padrão `{ gzip: '.gz' }`, então liste todas as codificações, como `{ gzip: '.gz', br: '.br' }` (`br` também precisa de [`server.encoder()`](/tutorials/pt_BR/response-marshalling#compression)) | +| `confine` | `true` (padrão) mantém os caminhos dentro de `relativeTo`; `false` permite qualquer caminho | +| `etagMethod` | `'hash'` (padrão), `'simple'` (tamanho e data de modificação, mais barato) ou `false` | -Além do manipulador `file`, inert também adiciona um manipulador de `directory` que permite que você especifique um caminho para servir multiplos arquivos. Para usá-lo, você deve especificar um caminho como um parâmetro. O nome do paramâmetro não importa, no entanto. Você pode usar a extensão asterísco para restringir a profundidade do arquivo também. O uso mais básico do manipulador de diretório se parece com: +A lista completa está na [API do inert](/module/inert#the-file-handler). -```javascript +## Handler de diretório + +O handler `directory` serve uma pasta inteira a partir de uma única rota. O caminho precisa terminar com um parâmetro, e o valor do parâmetro é o arquivo a procurar. O parâmetro define a profundidade que a rota serve: `{param}` serve um nível, `{param?}` também serve a raiz da pasta, `{param*}` serve qualquer profundidade e `{param*2}` serve exatamente dois níveis: + +```ts server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'public', + path: 'directory-path-here', }, }, }); ``` -### Opções do manipulador de diretório +### Opções do handler de diretório + +Para cada requisição, o inert resolve o arquivo assim: + +```mermaid +flowchart TD + R["requisição /docs"] --> F{"é um arquivo?"} + F -->|sim| S[envia o arquivo] + F -->|"não encontrado"| E{"defaultExtension definida?"} + E -->|sim| X["tenta /docs.html"] + E -->|não| N[404] + F -->|"um diretório"| T{"barra no final?"} + T -->|não| RD["302 para /docs/"] + T -->|sim| I{"o arquivo index existe?"} + I -->|sim| SI[envia index.html] + I -->|não| L{"listing: true?"} + L -->|sim| HL[envia a listagem HTML] + L -->|não| FB[403] +``` + +| Opção | Padrão | Efeito | +| ------------------ | ----------- | -------------------------------------------------------------------------------------------------------- | +| `path` | obrigatório | a pasta, um array de pastas testadas em ordem, ou uma função `(request) => path` | +| `index` | `true` | serve `index.html` para um diretório; uma string ou array define outros arquivos index; `false` desativa | +| `listing` | `false` | mostra uma listagem HTML para um diretório sem arquivo index | +| `showHidden` | `false` | inclui arquivos ocultos nas listagens e os serve | +| `redirectToSlash` | `true` | redireciona `/docs` para `/docs/` quando `docs` é um diretório | +| `defaultExtension` | nenhum | quando `/about` não é encontrado, tenta `/about.html` | +| `lookupCompressed` | `false` | serve arquivos `.gz` pré-comprimidos, igual à opção do handler `file` | + +Com `index: ['index.html', 'default.html']`, uma requisição de diretório tenta primeiro `index.html` e depois `default.html`. -A rota acima responderá qualquer requisição procurando por um arquivo correspondente no diretório `public`. Note que a requisição para `/` nessa configuração, responderá com um HTTP `403` por que, por padrão, o manipulador não permitirá uma lista de arquivos. Você pode alterar isso definindo a propriedade `listing` para `true`, assim: +## Servidor de arquivos estáticos + +Um servidor completo que serve a pasta `public` na raiz do site: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), + }, + }, +}); + +await server.register(Inert); -```javascript server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'public', - listing: true, + path: '.', + defaultExtension: 'html', }, }, }); -``` -Agora a requisição para `\` responderá com um HTML exbindo com o conteúdo do diretório. Nós podemos aproveitar esse servidor estático e ir um passo adiante definindo a opção `index` para `true`, o que siginifica que uma requisição para `/` vai tentar carregar primeiro `index.html`. A opção `index` também aceita uma string ou um array de strings para especificar o arquivo(s) padrão. Por definição, a opção `index` para `['index.html', 'default.html']`, uma requisição para `/` primeiramente tenta carregar `/index.html` e então `default.html`. Isso nos dá, em uma rota, um servidor web estático muito básico e simples. +await server.start(); +console.log('Server running at:', server.info.uri); +``` -Quando usamos um manipulador de diretório com a listagem habilitada, por padrão, arquivos ocultos não serão exibidos na lista. Isso pode ser alterado definindo a opção `showHidden` para `true`. Como o manipulador de arquivo, o manipulador de diretório também tem uma opção `lookupCompressed` para servir arquivos pré compactados, quando possível. Você também pode definir um `defaultExtension` que será anexado as requisições se o caminho do arquivo original não for encontrado. Isso significa que a requisição para `/bacon` também tentará o arquivo `/bacon.html`. +- `path: '.'` aponta o handler para `relativeTo`, a pasta `public`. +- `/` serve `public/index.html`, e `/docs` redireciona para `/docs/`, que serve `public/docs/index.html`. +- `defaultExtension: 'html'` faz `/about` servir `public/about.html`, então suas URLs não precisam de `.html`. +- Como `/{param*}` é o caminho menos específico possível, qualquer outra rota que você adicionar, como `/api/users`, continua respondendo às próprias requisições. Veja [qual rota vence](/tutorials/pt_BR/routing#specificity). diff --git a/docs/tutorials/tr_TR/serving-files.md b/docs/tutorials/tr_TR/serving-files.md index 2fc4dfc9..34a27ead 100644 --- a/docs/tutorials/tr_TR/serving-files.md +++ b/docs/tutorials/tr_TR/serving-files.md @@ -1,177 +1,210 @@ --- -title: Serving Files +title: Dosya Sunma --- -## Durağan dosya sunumu +# Statik İçerik Sunma -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -Bir ağ uygulaması yazarken eninde sonunda diskten bir dosya sunmanın vakti geliyor. Ilave işleyicileriyle hapiye bu işlevselliği ekleyen [inert](https://github.com/hapijs/inert) adlı bir eklenti var. +## Genel Bakış -Önce inerti projene gereksinim olarak ekleyerek kurmalısın: +hapi diskteki dosyaları [inert](/module/inert) plugin'i aracılığıyla sunar. Kur: -`npm install --save @hapi/inert` - -## `h.file(path, [options])` - -Önce, [`h.file()`](https://github.com/hapijs/inert#hfilepath-options) yönteminin nasıl kullanıldığına bakalım: - -```javascript -const start = async () => { - await server.register(require('@hapi/inert')); - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('/path/to/picture.jpg'); - }, - }); - - await server.start(); +```sh +npm install @hapi/inert +``` - console.log('Server running at:', server.info.uri); -}; +## Inert -start(); -``` +inert'i kaydetmek dosya göndermenin üç yolunu ekler: -Yukarıda göreceğin gibi, en basit haliyle `h.file(path)` dönüyorsun. +| Ne | Ne için kullanılır | +| --------------------- | -------------------------------------------------------------------- | +| `h.file(path)` | kendi mantığını çalıştırdıktan sonra handler içinden dosya göndermek | +| `file` handler'ı | her zaman tek bir dosya sunan bir route | +| `directory` handler'ı | bir klasördeki her dosyayı sunan bir route | -### Bagil Guzergahlar (relative path) +`@hapi/inert`'i import etmek bunları hapi'nin TypeScript tiplerine de ekler, böylece `h.file()` ile `file` ve `directory` handler'ları tip kontrolünden geçer. -Özellikle dosyalarla yanıt veren birden fazla güzergahın olduğunda işleri kolaylaştırmak için sunucuna bir ana güzergah (base path) tanımlayarak `h.file()` yöntemine bağıl güzergahlar yazabilirsin. +## Göreli path'ler -```javascript -'use strict'; +Sunucuda `routes.files.relativeTo` değerini bir kez ayarla; route'larındaki her dosya path'i o klasöre göre çözümlenir: -const Hapi = require('@hapi/hapi'); -const Path = require('path'); +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; const server = Hapi.server({ routes: { files: { - relativeTo: Path.join(__dirname, 'public'), + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -const start = async () => { - await server.register(require('@hapi/inert')); - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('picture.jpg'); - }, - }); - - await server.start(); +await server.register(Inert); - console.log('Server running at:', server.info.uri); -}; +server.route({ + method: 'GET', + path: '/picture.jpg', + handler: (request, h) => { + return h.file('picture.jpg'); + }, +}); -start(); +await server.start(); +console.log('Server running at:', server.info.uri); ``` -`server.options.routes` seçeneğini yukarıdaki gibi ayarladığında _tüm_ yollara (route) uygulanır. `relativeTo` da dahil olmak üzere bu seçenekleri her bir yol (route) için de ayarlayabilsin. +`routes.files.relativeTo` varsayılan olarak process'in çalışma dizinidir. Sunucunun `routes` altındaki her seçenek tüm route'lara uygulanır ve bir route bunu kendi `options.files` alanında geçersiz kılabilir. -## Dosya isleyici +## `h.file(path, [options])` -`file` (dosya) işleyici kullanmak yukarıdaki yola (route) bir alternatif olabilir: +[`h.file()`](/module/inert#h-file-path-options) bir dosyayı yanıt olarak gönderir. Path'ler `relativeTo` klasörüyle sınırlıdır: bu klasörün dışına çözümlenen bir path, örneğin `'../secrets.json'` ya da diskte başka bir yerdeki mutlak bir path, `403 Forbidden` yanıtı alır. Bu, bir istek parametresinin rastgele dosyaları okumasını engeller: -```javascript -server.route({ +```ts +server.route<{ Params: { filename: string } }>({ method: 'GET', - path: '/picture.jpg', - handler: { - file: 'picture.jpg', + path: '/download/{filename}', + handler: (request, h) => { + return h.file(request.params.filename, { mode: 'attachment' }); }, }); ``` -### Dosya iseyici secenekleri +`/download/report.pdf`, `public/report.pdf` dosyasını indirme olarak gönderir; `/download/..%2Fserver.ts` ise `403` alır. Klasörün dışından bilerek bir dosya göndermek için `confine: false` geç, ama bunu asla kullanıcı girdisinden oluşturulmuş bir path ile yapma. + +## File handler'ı -Değiştirgeyi `request` (istek) nesnesi kabul ederek dosyanın güzergahını (tam ya da bağıl olarak) temsil eden bir metin dönen bir fonksiyon olarak da belirtebiliriz: +Bir route her zaman aynı dosyayı sunuyorsa, handler fonksiyonu yazmak yerine `file` handler'ını kullan: -```javascript +```ts server.route({ method: 'GET', - path: '/{filename}', + path: '/picture.jpg', handler: { - file: function (request) { - return request.params.filename; - }, + file: 'picture.jpg', }, }); ``` -`path` (güzergah) özelliği olan bir nesne de olabilir. İşleyicinin nesne şeklini kullanırken, bir kaç ilave şey de yapabiliriz. Mesela aşağıdaki gibi `Content-Disposition` başlığını ayarlayıp sıkıştırılmış dosyalara izin verebiliriz: +### File handler seçenekleri -```javascript +Nesne biçimi `h.file()` ile aynı seçenekleri alır: + +```ts server.route({ method: 'GET', path: '/script.js', handler: { file: { path: 'script.js', - filename: 'client.js', // Content-Disposition başlığındaki dosya adının üstüne yaz - mode: 'attachment', // Content-Dispositionın bir eklenti olduğunu belirt - lookupCompressed: true, // eğer istek izin veriyorsa script.js.gz aramasını etkinleştir + filename: 'client.js', + mode: 'attachment', + lookupCompressed: true, }, }, }); ``` -## Dizin işleyici +| Seçenek | Etkisi | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path` | gönderilecek dosya; `(request) => path` fonksiyonunu da kabul eder | +| `mode` | `'attachment'` (indirme) ya da `'inline'`; `Content-Disposition` header'ını ayarlar | +| `filename` | `Content-Disposition` içindeki dosya adı; burada `script.js` yerine `client.js` | +| `lookupCompressed` | `script.js.gz` varsa ve istemci gzip kabul ediyorsa onu sunar; `lookupMap` varsayılan `{ gzip: '.gz' }` değerinin yerine geçer, bu yüzden her encoding'i listele, örneğin `{ gzip: '.gz', br: '.br' }` (`br` ayrıca [`server.encoder()`](/tutorials/tr_TR/response-marshalling#compression) gerektirir) | +| `confine` | `true` (varsayılan) path'leri `relativeTo` içinde tutar; `false` her path'e izin verir | +| `etagMethod` | `'hash'` (varsayılan), `'simple'` (boyut ve değiştirilme zamanı, daha ucuz) ya da `false` | + +Tam liste [inert API](/module/inert#the-file-handler) sayfasında. + +## Directory handler'ı -inert, `file` (dosya) işleyicisine ek olarak birden fazla dosya sunacak şekilde yollar (route) belirleyebileceğin bir de `directory` (dizin) işleyicisi özelliği ekler. Bunu kullanmak için yol (route) güzergahını (path) bir değiştirge ile tanımlamalısın. Değiştirgenin adı farketmez. Değiştirgede yıldız imi kullanrak dosya derinliğini de kısıtlayabilirsin. Dizin işleyicisinin en basit kullanımı şöyle: +`directory` handler'ı tek bir route'tan bütün bir klasörü sunar. Path bir parametreyle bitmelidir ve parametrenin değeri aranacak dosyadır. Parametre, route'un ne kadar derine kadar sunacağını belirler: `{param}` bir seviye sunar, `{param?}` klasörün kökünü de sunar, `{param*}` her derinliği sunar, `{param*2}` ise tam olarak iki seviye sunar: -```javascript +```ts server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'public', + path: 'directory-path-here', }, }, }); ``` -### Dizin isleyici secenekleri +### Directory handler seçenekleri + +inert her istek için dosyayı şöyle çözümler: + +```mermaid +flowchart TD + R["/docs isteği"] --> F{"bir dosya mı?"} + F -->|evet| S[dosyayı gönder] + F -->|"bulunamadı"| E{"defaultExtension ayarlı mı?"} + E -->|evet| X["/docs.html dene"] + E -->|hayır| N[404] + F -->|"bir dizin"| T{"sonda eğik çizgi var mı?"} + T -->|hayır| RD["/docs/ adresine 302"] + T -->|evet| I{"index dosyası var mı?"} + I -->|evet| SI[index.html gönder] + I -->|hayır| L{"listing: true mu?"} + L -->|evet| HL[HTML listesi gönder] + L -->|hayır| FB[403] +``` -Yukarıdaki yol (route) her isteği `public` dizininde istenen dosya adı eşleşmesini arayarak cevaplar. Bu yapılandırma ile `/` yoluna (route) yapılacak bir istek HTTP `403` yanıtı ile karşılanacaktır. Bunu düzeltmek için bir indeks (index) dosyası ekleyebiliriz. Varsayılan olarak hapi dizinde adı `index.html` olan bir dosya var mı diye bakar. İndeks dosyası sunumunu index seçeneğini `false` olarak ayarlayarak kapatabileceğimiz gibi bu seçeneğe indeks olarak gösterilmek üzere bakılacak dosyaların bir dizisini de yazabiliriz. +| Seçenek | Varsayılan | Etkisi | +| ------------------ | ---------- | ------------------------------------------------------------------------------------------------------------ | +| `path` | zorunlu | klasör, sırayla denenen bir klasör dizisi ya da `(request) => path` fonksiyonu | +| `index` | `true` | bir dizin için `index.html` sunar; bir string ya da dizi başka index dosyalarını adlandırır; `false` kapatır | +| `listing` | `false` | index dosyası olmayan bir dizin için HTML listesi gösterir | +| `showHidden` | `false` | gizli dosyaları listelere dahil eder ve sunar | +| `redirectToSlash` | `true` | `docs` bir dizin olduğunda `/docs` adresini `/docs/` adresine yönlendirir | +| `defaultExtension` | yok | `/about` bulunamadığında `/about.html` dener | +| `lookupCompressed` | `false` | önceden sıkıştırılmış `.gz` dosyalarını sunar; `file` handler seçeneğiyle aynıdır | -```javascript -server.route({ - method: 'GET', - path: '/{param*}', - handler: { - directory: { - path: 'public', - index: ['index.html', 'default.html'], +`index: ['index.html', 'default.html']` ile bir dizin isteği önce `index.html`, sonra `default.html` dener. + +## Statik dosya sunucusu + +`public` klasörünü sitenin kökünde sunan eksiksiz bir sunucu: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -``` -`/` güzergahına yapılacak bir istek önce `/index.html`i sonra `/default.html`i yüklemeye çalışarak karşılanır. İndeks dosyası olmayınca inert dizinin içeriğini bir liste sayfası olarak görüntüleyebilir. Bu özelliği `listing` özelliğini `true` olarak ayarlayarak etkinleştirebilirsin: +await server.register(Inert); -```javascript server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'public', - listing: true, + path: '.', + defaultExtension: 'html', }, }, }); + +await server.start(); +console.log('Server running at:', server.info.uri); ``` -Şimdi `/` güzergahına yapılan bir istek dizin içeriğini gösteren HTML ile yanıtlanacak. Dizin işleyiciyi listeleme açık olarak kullanırken varsayılan olarak gizli dosyalar listede gösterilmez. Bu `showHidden` seçeneğini `true` olarak ayarlanarak değiştirilebilir. Dosya işleyicide olduğu gibi, dizin işleyicinin de mümkün olduğunda önceden sıkıştırılmış dosyaları sunmak için `lookupCompressed` (sıkıştırılmışına bak) adlı bir seçeneği bulunur. Orjinal güzergah bulunamadığında isteklere eklenmek üzere `defaultExtension` (varsayılan uzantı) da ayarlayabilirsin. Bu demek oluyor ki `/havuc` için yapılan bir istekte aynı zamanda `/index.html` de denenecek. +- `path: '.'` handler'ı `relativeTo` klasörüne, yani `public` klasörüne yönlendirir. +- `/` adresi `public/index.html` dosyasını sunar; `/docs` ise `/docs/` adresine yönlendirir ve bu adres `public/docs/index.html` dosyasını sunar. +- `defaultExtension: 'html'`, `/about` adresinin `public/about.html` dosyasını sunmasını sağlar, böylece URL'lerinde `.html` gerekmez. +- `/{param*}` mümkün olan en genel path olduğu için, eklediğin diğer route'lar, örneğin `/api/users`, kendi isteklerine yanıt vermeye devam eder. [Hangi route kazanır](/tutorials/tr_TR/routing#specificity) bölümüne bak. diff --git a/docs/tutorials/zh_CN/serving-files.md b/docs/tutorials/zh_CN/serving-files.md index 671ee833..7b8b3a72 100644 --- a/docs/tutorials/zh_CN/serving-files.md +++ b/docs/tutorials/zh_CN/serving-files.md @@ -1,177 +1,210 @@ --- -title: Serving Files +title: 静态文件服务 --- -## 静态内容 +# 静态文件服务 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更高版本_ -在构建任意一个 web 应用时,不可避免的需要从磁盘提供一些静态文件。有一个名为 [inert](https://github.com/hapijs/inert) 的 hapi 插件可以完成这个操作。 +## 概述 -首先你需要安装它,并且将 inert 作为依赖添加到你的项目中: +hapi 通过 [inert](/module/inert) 插件提供磁盘上的文件。先安装它: -`npm install --save @hapi/inert` - -## `h.file(path, [options])` - -首先我们看如何使用 [`h.file()`](https://github.com/hapijs/inert#hfilepath-options) 方法: - -```javascript -const start = async () => { - await server.register(require('@hapi/inert')); - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('/path/to/picture.jpg'); - }, - }); - - await server.start(); +```sh +npm install @hapi/inert +``` - console.log('Server running at:', server.info.uri); -}; +## Inert -start(); -``` +注册 inert 后会多出三种发送文件的方式: -如上所示,这就是你返回 `h.file(path)` 的基本方式。 +| 方式 | 适用场景 | +| ------------------- | ------------------------------------------- | +| `h.file(path)` | 在 handler 中执行完自己的逻辑后发送一个文件 | +| `file` handler | 一个始终提供同一文件的路由 | +| `directory` handler | 一个提供某个文件夹中所有文件的路由 | -### 相对路径 +导入 `@hapi/inert` 也会把这些内容加入 hapi 的 TypeScript 类型,因此 `h.file()` 以及 `file` 和 `directory` handler 都能通过类型检查。 -为了简化操作,特别是如果有多个需要返回文件的路由,你可以在服务器中配置基本路径,之后只将相对路径传递给 `h.file()`: +## 相对路径 -```javascript -'use strict'; +在服务器上设置一次 `routes.files.relativeTo`,路由中的所有文件路径都会从这个文件夹开始解析: -const Hapi = require('@hapi/hapi'); -const Path = require('path'); +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; const server = Hapi.server({ routes: { files: { - relativeTo: Path.join(__dirname, 'public'), + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -const start = async () => { - await server.register(require('@hapi/inert')); - - server.route({ - method: 'GET', - path: '/picture.jpg', - handler: function (request, h) { - return h.file('picture.jpg'); - }, - }); - - await server.start(); +await server.register(Inert); - console.log('Server running at:', server.info.uri); -}; +server.route({ + method: 'GET', + path: '/picture.jpg', + handler: (request, h) => { + return h.file('picture.jpg'); + }, +}); -start(); +await server.start(); +console.log('Server running at:', server.info.uri); ``` -当在 `server.options.routes` 中设置一个选项时,如上所述,它将应用于 _所有的_ 路由。你还可以修改这些选项,包括每个路由级别的 `relativeTo` 选项。 +`routes.files.relativeTo` 默认是进程的工作目录。服务器 `routes` 下的任何选项都会应用到所有路由,单个路由可以在自己的 `options.files` 中覆盖它。 -## 文件 handler +## `h.file(path, [options])` -上述路由的替代方法是使用 `file` handler: +[`h.file()`](/module/inert#h-file-path-options) 把一个文件作为响应发送。路径被限制在 `relativeTo` 文件夹内:解析到文件夹之外的路径,例如 `'../secrets.json'` 或磁盘上其他位置的绝对路径,会返回 `403 Forbidden`。这样请求参数就无法读取任意文件: -```javascript -server.route({ +```ts +server.route<{ Params: { filename: string } }>({ method: 'GET', - path: '/picture.jpg', - handler: { - file: 'picture.jpg', + path: '/download/{filename}', + handler: (request, h) => { + return h.file(request.params.filename, { mode: 'attachment' }); }, }); ``` -### 文件 handler 选项 +`/download/report.pdf` 会把 `public/report.pdf` 作为下载文件发送,而 `/download/..%2Fserver.ts` 得到 `403`。如果确实要发送文件夹之外的文件,请传入 `confine: false`,并且绝不要配合由用户输入拼出来的路径使用。 + +## File handler -我们还可以将参数指定为接受 `request` 对象,并返回表示文件路径字符串的函数 (绝对路径或者相对路径): +如果一个路由始终提供同一个文件,可以使用 `file` handler,而不必编写 handler 函数: -```javascript +```ts server.route({ method: 'GET', - path: '/{filename}', + path: '/picture.jpg', handler: { - file: function (request) { - return request.params.filename; - }, + file: 'picture.jpg', }, }); ``` -它也可以是具有 `path` 属性的对象。当在 handler 中使用对象时,我们可以附加一些别的东西,如设 `Content-Disposition` 头并允许压缩文件。如下: +### File handler 选项 -```javascript +对象形式接受与 `h.file()` 相同的选项: + +```ts server.route({ method: 'GET', path: '/script.js', handler: { file: { path: 'script.js', - filename: 'client.js', // 修改 Content-Disposition 头中的文件名 - mode: 'attachment', // 指定 Content-Disposition 是一个附件 - lookupCompressed: true, // 如果请求允许,将允许查找 script.js.gz + filename: 'client.js', + mode: 'attachment', + lookupCompressed: true, }, }, }); ``` -## 目录 handler +| 选项 | 作用 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `path` | 要发送的文件;也接受函数 `(request) => path` | +| `mode` | `'attachment'`(下载)或 `'inline'`;设置 `Content-Disposition` 响应头 | +| `filename` | `Content-Disposition` 中的文件名,这里是 `client.js` 而不是 `script.js` | +| `lookupCompressed` | 当 `script.js.gz` 存在且客户端接受 gzip 时提供它;`lookupMap` 会替换默认的 `{ gzip: '.gz' }`,所以要列出所有编码,例如 `{ gzip: '.gz', br: '.br' }`(`br` 还需要 [`server.encoder()`](/tutorials/zh_CN/response-marshalling#compression)) | +| `confine` | `true`(默认)把路径限制在 `relativeTo` 内;`false` 允许任意路径 | +| `etagMethod` | `'hash'`(默认)、`'simple'`(基于大小和修改时间,开销更小)或 `false` | + +完整列表见 [inert API](/module/inert#the-file-handler)。 + +## Directory handler -除 `file` handler 之外,inert 也有一个额外的 `directory` handler 允许一个路由提供多份文件。要使用它,必须使用参数指定路径。 参数的名称无关紧要,你也可以在参数上使用星号扩展名来限制文件的深度。目录 handler 的基本用法如下: +`directory` handler 用一个路由提供整个文件夹。路径必须以参数结尾,参数的值就是要查找的文件。参数决定了路由能提供多深的层级:`{param}` 提供一层,`{param?}` 还会提供文件夹根目录,`{param*}` 提供任意深度,`{param*2}` 恰好提供两层: -```javascript +```ts server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'public', + path: 'directory-path-here', }, }, }); ``` -### 目录 handler 选项 +### Directory handler 选项 + +对每个请求,inert 按以下方式解析文件: + +```mermaid +flowchart TD + R["请求 /docs"] --> F{"是文件吗?"} + F -->|是| S[发送该文件] + F -->|"未找到"| E{"设置了 defaultExtension?"} + E -->|是| X["尝试 /docs.html"] + E -->|否| N[404] + F -->|"是目录"| T{"有结尾斜杠?"} + T -->|否| RD["302 跳转到 /docs/"] + T -->|是| I{"存在索引文件?"} + I -->|是| SI[发送 index.html] + I -->|否| L{"listing: true?"} + L -->|是| HL[发送 HTML 目录列表] + L -->|否| FB[403] +``` -上述路由将通过在 `public` 目录中查找匹配的文件名来响应任何请求。这里需要注意的是,在此配置中对 `/` 的请求将会使用 HTTP `403` 响应进行回复。我们可以通过添加索引文件的方式来解决这个问题。默认情况下,hapi 会在目录中搜索名为 `index.html` 的文件。我们可以通过将 index 选项设置为 `false` 来禁用提供索引文件,或者我们可以指定 inert 可以查找的索引文件数组: +| 选项 | 默认值 | 作用 | +| ------------------ | ------- | ------------------------------------------------------------------------- | +| `path` | 必填 | 文件夹、按顺序尝试的文件夹数组,或函数 `(request) => path` | +| `index` | `true` | 对目录提供 `index.html`;字符串或数组可指定其他索引文件;`false` 表示禁用 | +| `listing` | `false` | 对没有索引文件的目录显示 HTML 目录列表 | +| `showHidden` | `false` | 在目录列表中包含隐藏文件,并提供这些文件 | +| `redirectToSlash` | `true` | 当 `docs` 是目录时,把 `/docs` 重定向到 `/docs/` | +| `defaultExtension` | 无 | 找不到 `/about` 时,尝试 `/about.html` | +| `lookupCompressed` | `false` | 提供预压缩的 `.gz` 文件,与 `file` handler 的同名选项相同 | -```javascript -server.route({ - method: 'GET', - path: '/{param*}', - handler: { - directory: { - path: 'public', - index: ['index.html', 'default.html'], +使用 `index: ['index.html', 'default.html']` 时,目录请求会先尝试 `index.html`,再尝试 `default.html`。 + +## 静态文件服务器 + +一个在站点根路径提供 `public` 文件夹的完整服务器: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -``` -一个发往 `/` 的请求首先尝试去加载 `/index.html`, 之后再加载 `/default.html`。当没有可用的索引文件时, inert 可以以列表的形式显示这个目录下的内容。你可以通过设置 `listing` 属性为 `true` 来开启这个选项,如: +await server.register(Inert); -```javascript server.route({ method: 'GET', path: '/{param*}', handler: { directory: { - path: 'public', - listing: true, + path: '.', + defaultExtension: 'html', }, }, }); + +await server.start(); +console.log('Server running at:', server.info.uri); ``` -现在,对 `/` 的请求将会回复一个显示目录内容的 HTML。使用启用了列表的目录 handler 时,默认情况下隐藏文件不会显示在列表中,可以通过将 `showHidden` 选项设置为 `true` 来显示。与文件 handler 一样,目录 handler 也有一个 `lookupCompressed` 的选项用于提供预压缩文件。你还可以设置一个 `defaultExtension`,如果找不到原始路径,它将附加到请求中。这意味着对 `/bacon` 的请求也会尝试文件 `/bacon.html`。 +- `path: '.'` 让 handler 指向 `relativeTo`,也就是 `public` 文件夹。 +- `/` 提供 `public/index.html`,`/docs` 会重定向到 `/docs/`,后者提供 `public/docs/index.html`。 +- `defaultExtension: 'html'` 让 `/about` 提供 `public/about.html`,这样你的 URL 不需要带 `.html`。 +- 由于 `/{param*}` 是最不具体的路径,你添加的其他路由,例如 `/api/users`,仍然会响应各自的请求。请参阅[哪个路由胜出](/tutorials/zh_CN/routing#specificity)。 From fe94883ac37bf29aa1913995c9c646da8536235c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:57 -0400 Subject: [PATCH 14/22] docs: rework testing tutorial TypeScript examples, tables in place of long prose, and translations updated. --- docs/tutorials/en_US/testing.md | 164 ++++++++++++++++++------------ docs/tutorials/es_ES/testing.md | 175 ++++++++++++++++++++++++++++++++ docs/tutorials/ko_KR/testing.md | 172 ++++++++++++++++++++++++++++++- docs/tutorials/pt_BR/testing.md | 172 ++++++++++++++++++++++++++++++- docs/tutorials/tr_TR/testing.md | 172 ++++++++++++++++++++++++++++++- docs/tutorials/zh_CN/testing.md | 170 +++++++++++++++++++------------ 6 files changed, 884 insertions(+), 141 deletions(-) create mode 100644 docs/tutorials/es_ES/testing.md diff --git a/docs/tutorials/en_US/testing.md b/docs/tutorials/en_US/testing.md index 5bbade96..10bc2fd5 100644 --- a/docs/tutorials/en_US/testing.md +++ b/docs/tutorials/en_US/testing.md @@ -6,101 +6,110 @@ title: Testing # Testing -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -Hapi is designed for creating robust, testable applications. To this end, Hapi includes the ability to test routes without having to actually start a server, completely avoiding the time overheads and added complexity of the TCP protocol. +hapi can test routes without opening a port. `server.inject()` sends a fake request through the full request lifecycle (routing, authentication, validation, the handler, and the response) and hands you the response, with no network involved. Tests run fast and never collide over ports. -This tutorial goes into a basic setup for testing routes, and outlines one possible setup for a testable application using [lab](/module/lab) and [code](/module/code). +This tutorial uses [lab](/module/lab) as the test runner and [code](/module/code) for assertions, but `server.inject()` works the same from [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), [Vitest](https://vitest.dev/), or the Node.js test runner. -## lab - -`lab` is a simple test utility for Node.js. Unlike other test utilities, lab uses only async/await features and includes everything you should expect from a modern Node.js test utility. `lab` works with any assertion library that throws an error when a condition isn't met. For this tutorial, you will be using the `code` assertion library. - -To install `lab`, type the following in your terminal: - -`npm install --save-dev @hapi/lab` - -## code - -`code` is based on the `chai` assertions library. It was created to be a small, simple, and intuitive assertions library that could be run without plugins, extensions, and have low overhead. +```sh +npm install --save-dev @hapi/lab @hapi/code +``` -To install `code`, type the following in your terminal: +## Server Setup -`npm install --save-dev @hapi/code` +Keep the code that builds the server apart from the code that starts it. Production starts the server on a port; tests only initialize it and send requests with `server.inject()`: -## Server Setup +``` +myproject/ +├── index.ts # production entry point: calls start() +├── lib/ +│ └── server.ts # builds the server, exports init() and start() +├── test/ +│ └── example.test.ts # calls init() and server.inject() +└── package.json +``` -Taking the server example from the Getting Started tutorial, we make a minor modification to it, such that it doesn't automatically start when referenced from our tests. You might call this file `server.js` and place it in the `lib` directory of your project: +`lib/server.ts` builds a new server on every call, so each test gets its own server with no state left over from the previous test: -```js -'use strict'; +```ts +import Hapi from '@hapi/hapi'; -const Hapi = require('@hapi/hapi'); +const createServer = () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); + server.route({ + method: 'GET', + path: '/', + handler: function () { + return 'Hello World!'; + }, + }); -server.route({ - method: 'GET', - path: '/', - handler: function () { - return 'Hello World!'; - }, -}); + return server; +}; -exports.init = async () => { +export const init = async () => { + const server = createServer(); await server.initialize(); return server; }; -exports.start = async () => { +export const start = async () => { + const server = createServer(); await server.start(); console.log(`Server running at: ${server.info.uri}`); return server; }; +``` + +The production entry point only starts the server: + +```ts +import { start } from './lib/server.ts'; process.on('unhandledRejection', (err) => { console.log(err); process.exit(1); }); -``` - -You now export, but do not call, `init()` and `start()`. This will allow you to initialize and start the server from different files. The `init()` function will initialize the server (starts the caches, finalizes plugin registration) but does not start the server. This is what you will use in your tests. The `start()` function will actually start the server. This is what you will use in our main entry-point for the server: - -```js -`use strict`; - -const { start } = require('lib/server'); start(); ``` -What you've created here is a way of starting the server normally by calling its start function in our entry-point, and exposing a port for external HTTP traffic, but you've also got a module which doesn't do anything by default, which you can use in our tests. +The difference between the two functions: -## Writing a Route Test +| | [`server.initialize()`](/api#await-server-initialize) | [`server.start()`](/api#await-server-start) | +| ----------------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| Checks plugin dependencies | yes | yes | +| Starts caches | yes | yes | +| Runs `onPreStart` extensions | yes | yes | +| Listens on the port | no | yes | +| Emits `'start'`, runs `onPostStart` | no | yes | -In this example you'll use [lab](/module/lab), but the same method can be used for any testing tool such as [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), [Tap](https://www.node-tap.org/), [Ava](https://github.com/avajs) etc. +Tests use `initialize()` so caches and `onPreStart` setup run exactly as in production, without binding a port. -By default, `lab` loads all the '\*.js' files inside the local `test` directory and executes the tests found. To use different directories or files, pass the file or directories as arguments: +## Writing a Route Test + +Create `test/example.test.ts`: -`$ lab unit.js` +```ts +import Lab from '@hapi/lab'; +import { expect } from '@hapi/code'; -To get started, create a file called `example.test.js` in the `test` directory: +import { init } from '../lib/server.ts'; -```js -'use strict'; +import type { Server } from '@hapi/hapi'; -const Lab = require('@hapi/lab'); -const { expect } = require('@hapi/code'); -const { afterEach, beforeEach, describe, it } = (exports.lab = Lab.script()); -const { init } = require('../lib/server'); +export const lab = Lab.script(); +const { afterEach, beforeEach, describe, it } = lab; describe('GET /', () => { - let server; + let server: Server; beforeEach(async () => { server = await init(); @@ -115,23 +124,52 @@ describe('GET /', () => { method: 'get', url: '/', }); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Hello World!'); }); }); ``` -Here you are testing whether or not our `'GET'` route will respond with a `200` status code. You first call `describe()` to provide the structure of your test. `describe()` takes two parameters, a description of the test, and the function that will setup the test. +- lab finds the tests through the exported `lab` script. +- `describe()` groups tests, and `it()` defines one test. `beforeEach()` and `afterEach()` run around every test. +- `server` is typed with hapi's `Server` type because it is assigned later, in `beforeEach()`. +- `server.stop()` after each test stops caches and runs `onPreStop` and `onPostStop` extensions, so nothing leaks between tests. + +### What `server.inject()` gives you -Note that you call `init` rather than `start` to set up the server, which means that the server starts, but does not listen on a socket. After each test you call `stop` to cleanup and stop the server. +`server.inject()` takes the request as an object and returns the response: -The `it()` function is what will run your test. `it()` takes two parameters, a description of a successful test, and a function to run the test. +| Request option | Use it for | +| --------------- | ----------------------------------------------------------------------------------------------------------- | +| `method`, `url` | The route to call. | +| `payload` | The request body. Objects are sent as JSON. | +| `headers` | Request headers, such as `authorization` or `cookie`. | +| `auth` | `{ strategy, credentials }` to skip the authentication strategy and test a protected route as a given user. | -You will note the use of `inject` on the server. `inject` uses [Shot](/module/shot) to `inject` a request directly into hapi's route handler. This is the magic which allows us to test HTTP methods. +| Response property | Contains | +| ----------------- | ---------------------------------------------------------------------------------------- | +| `statusCode` | The HTTP status code. | +| `headers` | The response headers. | +| `payload` | The response body as a string, exactly as a client would receive it. | +| `result` | The value the handler returned, before it was serialized. Compare objects with this one. | +| `request` | The hapi request object, for checking `request.auth`, `request.app`, and so on. | -To run the tests, you can modify the `package.json` of your project to run your test runner: +See [`server.inject()`](/api#await-server-inject-options) for every option. + +## Running the Tests + +lab only discovers `.js`, `.mjs`, and `.cjs` files on its own, so pass the TypeScript test files as arguments. Add a script to `package.json`: ```json - "scripts": { - "test": "lab -v **/*.test.js" - } +{ + "scripts": { + "test": "lab -v test/*.test.ts" + } +} ``` + +Then run `npm test`. Node.js strips the types when lab imports the files, so there is no build step. + +- Run lab without `--typescript`, `-t`, or `-c`; Node.js already handles the TypeScript. +- To measure coverage, install [c8](https://github.com/bcoe/c8) with `npm install --save-dev c8`, and run lab under it: `c8 lab test/*.test.ts`. diff --git a/docs/tutorials/es_ES/testing.md b/docs/tutorials/es_ES/testing.md new file mode 100644 index 00000000..50856f13 --- /dev/null +++ b/docs/tutorials/es_ES/testing.md @@ -0,0 +1,175 @@ +--- +title: Testing +--- + + + +# Testing + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +hapi puede testear rutas sin abrir un puerto. `server.inject()` envía una petición simulada a través de todo el ciclo de vida de la petición (enrutamiento, autenticación, validación, el handler y la respuesta) y te devuelve la respuesta, sin que intervenga la red. Los tests se ejecutan rápido y nunca chocan por los puertos. + +Este tutorial usa [lab](/module/lab) como test runner y [code](/module/code) para las aserciones, pero `server.inject()` funciona igual desde [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), [Vitest](https://vitest.dev/) o el test runner de Node.js. + +```sh +npm install --save-dev @hapi/lab @hapi/code +``` + +## Preparar el servidor + +Separa el código que construye el servidor del código que lo arranca. En producción el servidor se arranca en un puerto; los tests solo lo inicializan y envían peticiones con `server.inject()`: + +``` +myproject/ +├── index.ts # production entry point: calls start() +├── lib/ +│ └── server.ts # builds the server, exports init() and start() +├── test/ +│ └── example.test.ts # calls init() and server.inject() +└── package.json +``` + +`lib/server.ts` construye un servidor nuevo en cada llamada, así que cada test tiene su propio servidor, sin estado heredado del test anterior: + +```ts +import Hapi from '@hapi/hapi'; + +const createServer = () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + server.route({ + method: 'GET', + path: '/', + handler: function () { + return 'Hello World!'; + }, + }); + + return server; +}; + +export const init = async () => { + const server = createServer(); + await server.initialize(); + return server; +}; + +export const start = async () => { + const server = createServer(); + await server.start(); + console.log(`Server running at: ${server.info.uri}`); + return server; +}; +``` + +El punto de entrada de producción solo arranca el servidor: + +```ts +import { start } from './lib/server.ts'; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); + +start(); +``` + +La diferencia entre las dos funciones: + +| | [`server.initialize()`](/api#await-server-initialize) | [`server.start()`](/api#await-server-start) | +| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| Comprueba las dependencias de los plugins | sí | sí | +| Arranca las cachés | sí | sí | +| Ejecuta las extensiones `onPreStart` | sí | sí | +| Escucha en el puerto | no | sí | +| Emite `'start'` y ejecuta `onPostStart` | no | sí | + +Los tests usan `initialize()` para que las cachés y la preparación de `onPreStart` se ejecuten exactamente igual que en producción, sin ocupar un puerto. + +## Escribir un test de una ruta + +Crea `test/example.test.ts`: + +```ts +import Lab from '@hapi/lab'; +import { expect } from '@hapi/code'; + +import { init } from '../lib/server.ts'; + +import type { Server } from '@hapi/hapi'; + +export const lab = Lab.script(); +const { afterEach, beforeEach, describe, it } = lab; + +describe('GET /', () => { + let server: Server; + + beforeEach(async () => { + server = await init(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('responds with 200', async () => { + const res = await server.inject({ + method: 'get', + url: '/', + }); + + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Hello World!'); + }); +}); +``` + +- lab encuentra los tests a través del script `lab` exportado. +- `describe()` agrupa tests e `it()` define un test. `beforeEach()` y `afterEach()` se ejecutan antes y después de cada test. +- `server` tiene el tipo `Server` de hapi porque se asigna más tarde, en `beforeEach()`. +- `server.stop()` después de cada test detiene las cachés y ejecuta las extensiones `onPreStop` y `onPostStop`, para que nada se filtre de un test a otro. + +### Qué te da `server.inject()` + +`server.inject()` recibe la petición como un objeto y devuelve la respuesta: + +| Opción de la petición | Para qué sirve | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `method`, `url` | La ruta a la que se llama. | +| `payload` | El cuerpo de la petición. Los objetos se envían como JSON. | +| `headers` | Cabeceras de la petición, como `authorization` o `cookie`. | +| `auth` | `{ strategy, credentials }` para saltarse la strategy de autenticación y testear una ruta protegida como un usuario concreto. | + +| Propiedad de la respuesta | Contiene | +| ------------------------- | -------------------------------------------------------------------------------------- | +| `statusCode` | El código de estado HTTP. | +| `headers` | Las cabeceras de la respuesta. | +| `payload` | El cuerpo de la respuesta como string, exactamente como lo recibiría un cliente. | +| `result` | El valor que devolvió el handler, antes de serializarlo. Compara los objetos con este. | +| `request` | El objeto request de hapi, para comprobar `request.auth`, `request.app`, etc. | + +Consulta [`server.inject()`](/api#await-server-inject-options) para ver todas las opciones. + +## Ejecutar los tests + +lab solo descubre por sí mismo ficheros `.js`, `.mjs` y `.cjs`, así que pasa los ficheros de test de TypeScript como argumentos. Añade un script a `package.json`: + +```json +{ + "scripts": { + "test": "lab -v test/*.test.ts" + } +} +``` + +Después ejecuta `npm test`. Node.js elimina los tipos cuando lab importa los ficheros, así que no hay paso de compilación. + +- Ejecuta lab sin `--typescript`, `-t` ni `-c`; Node.js ya se encarga de TypeScript. +- Para medir la cobertura, instala [c8](https://github.com/bcoe/c8) con `npm install --save-dev c8` y ejecuta lab con él: `c8 lab test/*.test.ts`. diff --git a/docs/tutorials/ko_KR/testing.md b/docs/tutorials/ko_KR/testing.md index 4cf0e0bd..406761da 100644 --- a/docs/tutorials/ko_KR/testing.md +++ b/docs/tutorials/ko_KR/testing.md @@ -1,11 +1,175 @@ --- -title: Testing +title: 테스트 --- -# Testing +# 테스트 -## No Translation Yet +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -This tutorial has not been translated yet. If you would like to translate it, please submit a pull request. +## 개요 + +hapi는 포트를 열지 않고도 라우트를 테스트할 수 있습니다. `server.inject()`는 가짜 요청을 전체 요청 라이프사이클(라우팅, 인증, 검증, 핸들러, 응답)에 통과시키고 응답을 돌려줍니다. 네트워크는 전혀 거치지 않습니다. 테스트가 빠르고 포트 충돌도 일어나지 않습니다. + +이 튜토리얼은 테스트 러너로 [lab](/module/lab)을, 어서션에 [code](/module/code)를 사용하지만, `server.inject()`는 [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), [Vitest](https://vitest.dev/), Node.js 테스트 러너에서도 똑같이 동작합니다. + +```sh +npm install --save-dev @hapi/lab @hapi/code +``` + +## 서버 설정 + +서버를 만드는 코드와 서버를 시작하는 코드를 분리하세요. 프로덕션에서는 서버를 포트에서 시작하고, 테스트에서는 서버를 초기화만 한 뒤 `server.inject()`로 요청을 보냅니다: + +``` +myproject/ +├── index.ts # production entry point: calls start() +├── lib/ +│ └── server.ts # builds the server, exports init() and start() +├── test/ +│ └── example.test.ts # calls init() and server.inject() +└── package.json +``` + +`lib/server.ts`는 호출할 때마다 새 서버를 만들기 때문에, 각 테스트는 이전 테스트의 상태가 남아 있지 않은 자신만의 서버를 받습니다: + +```ts +import Hapi from '@hapi/hapi'; + +const createServer = () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + server.route({ + method: 'GET', + path: '/', + handler: function () { + return 'Hello World!'; + }, + }); + + return server; +}; + +export const init = async () => { + const server = createServer(); + await server.initialize(); + return server; +}; + +export const start = async () => { + const server = createServer(); + await server.start(); + console.log(`Server running at: ${server.info.uri}`); + return server; +}; +``` + +프로덕션 진입점은 서버를 시작하기만 합니다: + +```ts +import { start } from './lib/server.ts'; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); + +start(); +``` + +두 함수의 차이: + +| | [`server.initialize()`](/api#await-server-initialize) | [`server.start()`](/api#await-server-start) | +| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| 플러그인 의존성 검사 | 예 | 예 | +| 캐시 시작 | 예 | 예 | +| `onPreStart` 확장 실행 | 예 | 예 | +| 포트에서 리슨 | 아니요 | 예 | +| `'start'` 이벤트 발생, `onPostStart` 실행 | 아니요 | 예 | + +테스트에서는 `initialize()`를 사용합니다. 그러면 포트를 바인딩하지 않고도 캐시와 `onPreStart` 설정이 프로덕션과 똑같이 실행됩니다. + +## 라우트 테스트 작성 + +`test/example.test.ts`를 만드세요: + +```ts +import Lab from '@hapi/lab'; +import { expect } from '@hapi/code'; + +import { init } from '../lib/server.ts'; + +import type { Server } from '@hapi/hapi'; + +export const lab = Lab.script(); +const { afterEach, beforeEach, describe, it } = lab; + +describe('GET /', () => { + let server: Server; + + beforeEach(async () => { + server = await init(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('responds with 200', async () => { + const res = await server.inject({ + method: 'get', + url: '/', + }); + + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Hello World!'); + }); +}); +``` + +- lab은 export된 `lab` 스크립트를 통해 테스트를 찾습니다. +- `describe()`는 테스트를 묶고, `it()`은 테스트 하나를 정의합니다. `beforeEach()`와 `afterEach()`는 각 테스트의 전후에 실행됩니다. +- `server`는 나중에 `beforeEach()`에서 할당되므로 hapi의 `Server` 타입으로 지정합니다. +- 각 테스트 후의 `server.stop()`은 캐시를 멈추고 `onPreStop`과 `onPostStop` 확장을 실행해서 테스트 사이에 아무것도 새어 나가지 않게 합니다. + +### `server.inject()`가 돌려주는 것 + +`server.inject()`는 요청을 객체로 받고 응답을 반환합니다: + +| 요청 옵션 | 용도 | +| --------------- | --------------------------------------------------------------------------------------------- | +| `method`, `url` | 호출할 라우트. | +| `payload` | 요청 본문. 객체는 JSON으로 전송됩니다. | +| `headers` | `authorization`이나 `cookie` 같은 요청 헤더. | +| `auth` | `{ strategy, credentials }`. 인증 전략을 건너뛰고 보호된 라우트를 특정 사용자로 테스트합니다. | + +| 응답 속성 | 내용 | +| ------------ | ----------------------------------------------------------------- | +| `statusCode` | HTTP 상태 코드. | +| `headers` | 응답 헤더. | +| `payload` | 클라이언트가 받는 그대로의 응답 본문 문자열. | +| `result` | 직렬화되기 전에 핸들러가 반환한 값. 객체는 이 값으로 비교하세요. | +| `request` | `request.auth`, `request.app` 등을 확인할 수 있는 hapi 요청 객체. | + +모든 옵션은 [`server.inject()`](/api#await-server-inject-options)를 참고하세요. + +## 테스트 실행 + +lab은 `.js`, `.mjs`, `.cjs` 파일만 스스로 찾으므로 TypeScript 테스트 파일은 인자로 넘겨야 합니다. `package.json`에 스크립트를 추가하세요: + +```json +{ + "scripts": { + "test": "lab -v test/*.test.ts" + } +} +``` + +그런 다음 `npm test`를 실행하세요. lab이 파일을 import할 때 Node.js가 타입을 제거하므로 빌드 단계가 없습니다. + +- lab은 `--typescript`, `-t`, `-c` 없이 실행하세요. TypeScript는 이미 Node.js가 처리합니다. +- 커버리지를 측정하려면 `npm install --save-dev c8`로 [c8](https://github.com/bcoe/c8)을 설치하고 그 아래에서 lab을 실행하세요: `c8 lab test/*.test.ts`. diff --git a/docs/tutorials/pt_BR/testing.md b/docs/tutorials/pt_BR/testing.md index 4cf0e0bd..db90de8b 100644 --- a/docs/tutorials/pt_BR/testing.md +++ b/docs/tutorials/pt_BR/testing.md @@ -1,11 +1,175 @@ --- -title: Testing +title: Testes --- -# Testing +# Testes -## No Translation Yet +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ -This tutorial has not been translated yet. If you would like to translate it, please submit a pull request. +## Visão geral + +O hapi consegue testar rotas sem abrir uma porta. `server.inject()` envia uma requisição simulada por todo o ciclo de vida da requisição (roteamento, autenticação, validação, o handler e a resposta) e entrega a resposta para você, sem envolver a rede. Os testes rodam rápido e nunca disputam portas. + +Este tutorial usa o [lab](/module/lab) como test runner e o [code](/module/code) para as asserções, mas `server.inject()` funciona do mesmo jeito com [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), [Vitest](https://vitest.dev/) ou o test runner do Node.js. + +```sh +npm install --save-dev @hapi/lab @hapi/code +``` + +## Configurando o servidor + +Mantenha o código que monta o servidor separado do código que o inicia. Em produção, o servidor é iniciado em uma porta; os testes apenas o inicializam e enviam requisições com `server.inject()`: + +``` +myproject/ +├── index.ts # production entry point: calls start() +├── lib/ +│ └── server.ts # builds the server, exports init() and start() +├── test/ +│ └── example.test.ts # calls init() and server.inject() +└── package.json +``` + +`lib/server.ts` monta um servidor novo a cada chamada, então cada teste recebe o próprio servidor, sem estado sobrando do teste anterior: + +```ts +import Hapi from '@hapi/hapi'; + +const createServer = () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + server.route({ + method: 'GET', + path: '/', + handler: function () { + return 'Hello World!'; + }, + }); + + return server; +}; + +export const init = async () => { + const server = createServer(); + await server.initialize(); + return server; +}; + +export const start = async () => { + const server = createServer(); + await server.start(); + console.log(`Server running at: ${server.info.uri}`); + return server; +}; +``` + +O ponto de entrada de produção apenas inicia o servidor: + +```ts +import { start } from './lib/server.ts'; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); + +start(); +``` + +A diferença entre as duas funções: + +| | [`server.initialize()`](/api#await-server-initialize) | [`server.start()`](/api#await-server-start) | +| -------------------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| Verifica as dependências dos plugins | sim | sim | +| Inicia os caches | sim | sim | +| Executa as extensões `onPreStart` | sim | sim | +| Escuta na porta | não | sim | +| Emite `'start'`, executa `onPostStart` | não | sim | + +Os testes usam `initialize()` para que os caches e a configuração em `onPreStart` rodem exatamente como em produção, sem ocupar uma porta. + +## Escrevendo um teste de rota + +Crie `test/example.test.ts`: + +```ts +import Lab from '@hapi/lab'; +import { expect } from '@hapi/code'; + +import { init } from '../lib/server.ts'; + +import type { Server } from '@hapi/hapi'; + +export const lab = Lab.script(); +const { afterEach, beforeEach, describe, it } = lab; + +describe('GET /', () => { + let server: Server; + + beforeEach(async () => { + server = await init(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('responds with 200', async () => { + const res = await server.inject({ + method: 'get', + url: '/', + }); + + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Hello World!'); + }); +}); +``` + +- O lab encontra os testes pelo script `lab` exportado. +- `describe()` agrupa testes, e `it()` define um teste. `beforeEach()` e `afterEach()` rodam antes e depois de cada teste. +- `server` é tipado com o tipo `Server` do hapi porque só recebe o valor depois, em `beforeEach()`. +- `server.stop()` depois de cada teste para os caches e executa as extensões `onPreStop` e `onPostStop`, então nada vaza de um teste para outro. + +### O que `server.inject()` entrega + +`server.inject()` recebe a requisição como um objeto e retorna a resposta: + +| Opção da requisição | Use para | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `method`, `url` | A rota a chamar. | +| `payload` | O corpo da requisição. Objetos são enviados como JSON. | +| `headers` | Headers da requisição, como `authorization` ou `cookie`. | +| `auth` | `{ strategy, credentials }` para pular a strategy de autenticação e testar uma rota protegida como um usuário específico. | + +| Propriedade da resposta | Contém | +| ----------------------- | ------------------------------------------------------------------------------------------------ | +| `statusCode` | O status code HTTP. | +| `headers` | Os headers da resposta. | +| `payload` | O corpo da resposta como string, exatamente como um cliente o receberia. | +| `result` | O valor que o handler retornou, antes de ser serializado. Compare objetos com este. | +| `request` | O objeto de requisição do hapi, para verificar `request.auth`, `request.app` e assim por diante. | + +Veja [`server.inject()`](/api#await-server-inject-options) para todas as opções. + +## Executando os testes + +O lab só descobre sozinho arquivos `.js`, `.mjs` e `.cjs`, então passe os arquivos de teste TypeScript como argumentos. Adicione um script ao `package.json`: + +```json +{ + "scripts": { + "test": "lab -v test/*.test.ts" + } +} +``` + +Depois rode `npm test`. O Node.js remove os tipos quando o lab importa os arquivos, então não há etapa de build. + +- Rode o lab sem `--typescript`, `-t` ou `-c`; o Node.js já cuida do TypeScript. +- Para medir a cobertura, instale o [c8](https://github.com/bcoe/c8) com `npm install --save-dev c8` e rode o lab por meio dele: `c8 lab test/*.test.ts`. diff --git a/docs/tutorials/tr_TR/testing.md b/docs/tutorials/tr_TR/testing.md index 4cf0e0bd..89973e6e 100644 --- a/docs/tutorials/tr_TR/testing.md +++ b/docs/tutorials/tr_TR/testing.md @@ -1,11 +1,175 @@ --- -title: Testing +title: Test --- -# Testing +# Test -## No Translation Yet +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -This tutorial has not been translated yet. If you would like to translate it, please submit a pull request. +## Genel Bakış + +hapi, route'ları bir port açmadan test edebilir. `server.inject()` sahte bir isteği tüm istek yaşam döngüsünden (routing, kimlik doğrulama, doğrulama, handler ve yanıt) geçirir ve yanıtı sana verir; araya hiç ağ girmez. Testler hızlı çalışır ve portlar yüzünden asla çakışmaz. + +Bu eğitim test çalıştırıcısı olarak [lab](/module/lab), assertion'lar için de [code](/module/code) kullanıyor; ama `server.inject()` [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), [Vitest](https://vitest.dev/) ya da Node.js test çalıştırıcısından da aynı şekilde çalışır. + +```sh +npm install --save-dev @hapi/lab @hapi/code +``` + +## Sunucu Kurulumu + +Sunucuyu oluşturan kodu, onu başlatan koddan ayrı tut. Production sunucuyu bir portta başlatır; testler ise onu yalnızca initialize eder ve `server.inject()` ile istek gönderir: + +``` +myproject/ +├── index.ts # production entry point: calls start() +├── lib/ +│ └── server.ts # builds the server, exports init() and start() +├── test/ +│ └── example.test.ts # calls init() and server.inject() +└── package.json +``` + +`lib/server.ts` her çağrıda yeni bir sunucu oluşturur, böylece her test bir önceki testten kalan hiçbir durum olmadan kendi sunucusunu alır: + +```ts +import Hapi from '@hapi/hapi'; + +const createServer = () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + server.route({ + method: 'GET', + path: '/', + handler: function () { + return 'Hello World!'; + }, + }); + + return server; +}; + +export const init = async () => { + const server = createServer(); + await server.initialize(); + return server; +}; + +export const start = async () => { + const server = createServer(); + await server.start(); + console.log(`Server running at: ${server.info.uri}`); + return server; +}; +``` + +Production giriş noktası yalnızca sunucuyu başlatır: + +```ts +import { start } from './lib/server.ts'; + +process.on('unhandledRejection', (err) => { + console.log(err); + process.exit(1); +}); + +start(); +``` + +İki fonksiyon arasındaki fark: + +| | [`server.initialize()`](/api#await-server-initialize) | [`server.start()`](/api#await-server-start) | +| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| Plugin bağımlılıklarını kontrol eder | evet | evet | +| Önbellekleri başlatır | evet | evet | +| `onPreStart` extension'larını çalıştırır | evet | evet | +| Portu dinler | hayır | evet | +| `'start'` yayar, `onPostStart` çalıştırır | hayır | evet | + +Testler `initialize()` kullanır; böylece önbellekler ve `onPreStart` kurulumu, port bağlamadan tam olarak production'daki gibi çalışır. + +## Route Testi Yazmak + +`test/example.test.ts` dosyasını oluştur: + +```ts +import Lab from '@hapi/lab'; +import { expect } from '@hapi/code'; + +import { init } from '../lib/server.ts'; + +import type { Server } from '@hapi/hapi'; + +export const lab = Lab.script(); +const { afterEach, beforeEach, describe, it } = lab; + +describe('GET /', () => { + let server: Server; + + beforeEach(async () => { + server = await init(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('responds with 200', async () => { + const res = await server.inject({ + method: 'get', + url: '/', + }); + + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Hello World!'); + }); +}); +``` + +- lab testleri export edilen `lab` script'i üzerinden bulur. +- `describe()` testleri gruplar, `it()` bir test tanımlar. `beforeEach()` ve `afterEach()` her testin öncesinde ve sonrasında çalışır. +- `server`, daha sonra `beforeEach()` içinde atandığı için hapi'nin `Server` tipiyle tiplenmiştir. +- Her testten sonra çağrılan `server.stop()` önbellekleri durdurur ve `onPreStop` ile `onPostStop` extension'larını çalıştırır, böylece testler arasında hiçbir şey sızmaz. + +### `server.inject()` sana ne verir + +`server.inject()` isteği bir nesne olarak alır ve yanıtı döndürür: + +| İstek seçeneği | Ne için kullanılır | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `method`, `url` | Çağrılacak route. | +| `payload` | İstek gövdesi. Nesneler JSON olarak gönderilir. | +| `headers` | `authorization` ya da `cookie` gibi istek header'ları. | +| `auth` | Kimlik doğrulama stratejisini atlayıp korumalı bir route'u belirli bir kullanıcı olarak test etmek için `{ strategy, credentials }`. | + +| Yanıt özelliği | İçeriği | +| -------------- | -------------------------------------------------------------------------------------- | +| `statusCode` | HTTP durum kodu. | +| `headers` | Yanıt header'ları. | +| `payload` | Yanıt gövdesi; tam olarak bir istemcinin alacağı şekilde, string olarak. | +| `result` | Handler'ın serileştirilmeden önce döndürdüğü değer. Nesneleri bununla karşılaştır. | +| `request` | `request.auth`, `request.app` ve benzerlerini kontrol etmek için hapi request nesnesi. | + +Tüm seçenekler için [`server.inject()`](/api#await-server-inject-options) referansına bak. + +## Testleri Çalıştırmak + +lab kendi başına yalnızca `.js`, `.mjs` ve `.cjs` dosyalarını bulur, bu yüzden TypeScript test dosyalarını argüman olarak ver. `package.json`'a bir script ekle: + +```json +{ + "scripts": { + "test": "lab -v test/*.test.ts" + } +} +``` + +Sonra `npm test` çalıştır. lab dosyaları import ettiğinde Node.js tipleri siler, yani bir build adımı yoktur. + +- lab'ı `--typescript`, `-t` ya da `-c` olmadan çalıştır; TypeScript'i zaten Node.js hallediyor. +- Coverage ölçmek için [c8](https://github.com/bcoe/c8)'i `npm install --save-dev c8` ile kur ve lab'ı onun altında çalıştır: `c8 lab test/*.test.ts`. diff --git a/docs/tutorials/zh_CN/testing.md b/docs/tutorials/zh_CN/testing.md index ce5c84a0..aeb79a16 100644 --- a/docs/tutorials/zh_CN/testing.md +++ b/docs/tutorials/zh_CN/testing.md @@ -1,106 +1,115 @@ --- -title: Testing +title: 测试 --- -# Testing +# 测试 -此教程兼容hapi v17以及更高版本. +_本教程适用于 hapi v21 及更高版本_ -## Overview +## 概述 -Hapi设计理念是创建健壮的、可测试的应用.为此,Hapi具备不用启动服务就可以测试路由的功能, 减少了TCP协议连接的时间成本,降低了复杂度. +hapi 可以在不打开端口的情况下测试路由。`server.inject()` 会让一个模拟请求走完整个请求生命周期(路由匹配、身份认证、验证、handler 和响应),然后把响应交给你,整个过程不涉及网络。测试运行得很快,也不会因为端口冲突而出问题。 -此教程带你进行路由测试的基本设置, 并提出了一种通过[lab](/module/lab)和[code](/module/code)创建可测试应用的一种可能. +本教程使用 [lab](/module/lab) 作为测试运行器,使用 [code](/module/code) 做断言,不过在 [Mocha](https://mochajs.org/)、[Jest](https://jestjs.io/)、[Vitest](https://vitest.dev/) 或 Node.js 自带的测试运行器中,`server.inject()` 的用法完全一样。 -## lab - -`lab` 是Node.js的一个简单的测试套件. 区别于其他测试套件, lab 只使用 async/await 特性,并且包含了所有你在现代化Node.js测试套件里所需要的工具. `lab` 可以和任何其他的断言库兼容,在条件不符合时候掏出错误. 在本教程中,你将使用 `code` 断言库. - -首先安装 `lab`, 在terminal里面输入: - -`npm install --save-dev @hapi/lab` - -## code - -`code` 建立在 `chai` 断言库基础上,被设计成小巧,简单且直观的断言库. 可以不通需要任何插件,拓展库直接运行,并且开销很小. +```sh +npm install --save-dev @hapi/lab @hapi/code +``` -然后安装 `code`, 在terminal里输入: +## 服务器设置 -`npm install --save-dev @hapi/code` +把构建服务器的代码和启动服务器的代码分开。生产环境在某个端口上启动服务器;测试只初始化服务器,并用 `server.inject()` 发送请求: -## Server Setup +``` +myproject/ +├── index.ts # production entry point: calls start() +├── lib/ +│ └── server.ts # builds the server, exports init() and start() +├── test/ +│ └── example.test.ts # calls init() and server.inject() +└── package.json +``` -从Getting Started教程中拿到例子的服务代码, 我们对它进行一下小修改, 比如让它在被我们的测试用例引用的时候不自动重启服务. 你可以把这个文件保存为`server.js`,然后放在工程的 `lib`目录下: +`lib/server.ts` 每次调用都会构建一个新的服务器,因此每个测试都有自己的服务器,不会残留上一个测试的状态: -```js -'use strict'; +```ts +import Hapi from '@hapi/hapi'; -const Hapi = require('@hapi/hapi'); +const createServer = () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); -const server = Hapi.server({ - port: 3000, - host: 'localhost', -}); + server.route({ + method: 'GET', + path: '/', + handler: function () { + return 'Hello World!'; + }, + }); -server.route({ - method: 'GET', - path: '/', - handler: function () { - return 'Hello World!'; - }, -}); + return server; +}; -exports.init = async () => { +export const init = async () => { + const server = createServer(); await server.initialize(); return server; }; -exports.start = async () => { +export const start = async () => { + const server = createServer(); await server.start(); console.log(`Server running at: ${server.info.uri}`); return server; }; +``` + +生产环境的入口文件只负责启动服务器: + +```ts +import { start } from './lib/server.ts'; process.on('unhandledRejection', (err) => { console.log(err); process.exit(1); }); -``` - -现在用导出 `init()` 和 `start()`方法代替原来的调用这两个方法. 这样我们可以在别的文件中来初始化、启动服务. `init()` 方法可以初始化 (开启缓存, 完成插件注册) 但是并未启动服务. 接下来要在测试用例中启动服务. `start()` 会自动启动服务. 我们将要在入口文件中调用: - -```js -`use strict`; - -const { start } = require('lib/server'); start(); ``` -你在这里创建的,是通常启动服务时候使用的,在入口文件通过调用start方法来启动服务的一种方式, 这种方式对外暴露出一个外部HTTP端口, 此外你也可以在我们的测试用例中使用一个什么都不做的模块. +两个函数的区别: -## Writing a Route Test +| | [`server.initialize()`](/api#await-server-initialize) | [`server.start()`](/api#await-server-start) | +| ---------------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| 检查插件依赖 | 是 | 是 | +| 启动缓存 | 是 | 是 | +| 运行 `onPreStart` 扩展 | 是 | 是 | +| 监听端口 | 否 | 是 | +| 触发 `'start'`,运行 `onPostStart` | 否 | 是 | -在这个例子中你将使用 [lab](/module/lab), 同样的方法也可以在任何测试工具中使用,例如 [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), [Tap](https://www.node-tap.org/), [Ava](https://github.com/avajs) 等. +测试使用 `initialize()`,这样缓存和 `onPreStart` 中的初始化逻辑会和生产环境完全一样地运行,但不会绑定端口。 -默认情况下, `lab` 加载所有文件'\*.js' 在本地的 `test` 目录里,并执行所有发现的测试用例. 如果想设置其他的文件夹或者文件, 需要将文件或者文件夹路径作为参数传入: +## 编写路由测试 -`$ lab unit.js` +创建 `test/example.test.ts`: -准备开始,创建一个 `example.test.js` 在 `test` 目录里: +```ts +import Lab from '@hapi/lab'; +import { expect } from '@hapi/code'; -```js -'use strict'; +import { init } from '../lib/server.ts'; -const Lab = require('@hapi/lab'); -const { expect } = require('@hapi/code'); -const { afterEach, beforeEach, describe, it } = (exports.lab = Lab.script()); -const { init } = require('../lib/server'); +import type { Server } from '@hapi/hapi'; + +export const lab = Lab.script(); +const { afterEach, beforeEach, describe, it } = lab; describe('GET /', () => { - let server; + let server: Server; beforeEach(async () => { server = await init(); @@ -115,23 +124,52 @@ describe('GET /', () => { method: 'get', url: '/', }); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Hello World!'); }); }); ``` -这里你将要测试我们的 `'GET'` 路由是否会响应 `200` 状态码. 首先调用 `describe()` 来给你的测试用例提供一个构造函数. `describe()` 接受2个参数, 一个是测试用例的描述, 另一个是设置测试用例的方法. +- lab 通过导出的 `lab` 脚本找到测试。 +- `describe()` 用于给测试分组,`it()` 定义一个测试。`beforeEach()` 和 `afterEach()` 在每个测试前后运行。 +- `server` 使用 hapi 的 `Server` 类型声明,因为它要稍后在 `beforeEach()` 中才被赋值。 +- 每个测试结束后调用 `server.stop()` 会停止缓存,并运行 `onPreStop` 和 `onPostStop` 扩展,这样测试之间不会互相影响。 + +### `server.inject()` 提供了什么 + +`server.inject()` 以对象形式接收请求,并返回响应: -请注意 `init` 代替 `start` 来设置服务, 意味着我们启动了服务, 但是没有监听任何端口. 每个测试用例后都会调用 `stop` 来清理和停止服务. +| 请求选项 | 用途 | +| --------------- | ------------------------------------------------------------------------------------------ | +| `method`、`url` | 要调用的路由。 | +| `payload` | 请求的 payload。对象会以 JSON 发送。 | +| `headers` | 请求头,例如 `authorization` 或 `cookie`。 | +| `auth` | `{ strategy, credentials }`,用于跳过身份认证 strategy,以指定用户的身份测试受保护的路由。 | -`it()` 方法将会运行你的测试用例. `it()` 接受2个参数, 一个是测试用例通过的描述语句, 另一个是运行测试用例的方法. +| 响应属性 | 内容 | +| ------------ | ------------------------------------------------------------ | +| `statusCode` | HTTP 状态码。 | +| `headers` | 响应头。 | +| `payload` | 字符串形式的响应体,与客户端收到的完全一致。 | +| `result` | handler 返回的值,尚未序列化。比较对象时用这个属性。 | +| `request` | hapi 的请求对象,用于检查 `request.auth`、`request.app` 等。 | -你需要了解服务器上的 `inject`. `inject` 使用 [Shot](/module/shot) 来 `inject` 直接向hapi的hanlder注入请求. 这就是让我们能够测试HTTP方法的关键所在. +所有选项请参阅 [`server.inject()`](/api#await-server-inject-options)。 -为了让我们的测试用例运行, 你可以修改项目中的 `package.json` 来测试你的测试用例: +## 运行测试 + +lab 只会自动发现 `.js`、`.mjs` 和 `.cjs` 文件,所以要把 TypeScript 测试文件作为参数传给它。在 `package.json` 中添加一个脚本: ```json - "scripts": { - "test": "lab -v **/*.test.js" - } +{ + "scripts": { + "test": "lab -v test/*.test.ts" + } +} ``` + +然后运行 `npm test`。lab 导入这些文件时,Node.js 会剥离类型,所以不需要构建步骤。 + +- 运行 lab 时不要加 `--typescript`、`-t` 或 `-c`;Node.js 已经处理了 TypeScript。 +- 要统计覆盖率,请用 `npm install --save-dev c8` 安装 [c8](https://github.com/bcoe/c8),然后在它下面运行 lab:`c8 lab test/*.test.ts`。 From a41d247dde6d3c3ec6e1b88458cc2eb7cf1c83bf Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:57 -0400 Subject: [PATCH 15/22] docs: rework validation tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/validation.md | 270 ++++++++++++----------- docs/tutorials/es_ES/validation.md | 299 +++++++++++++++++++++++++ docs/tutorials/ko_KR/validation.md | 291 +++++++++++++++++------- docs/tutorials/pt_BR/validation.md | 340 ++++++++++++++++++----------- docs/tutorials/tr_TR/validation.md | 306 ++++++++++++++++++-------- docs/tutorials/zh_CN/validation.md | 291 +++++++++++++++++------- 6 files changed, 1285 insertions(+), 512 deletions(-) create mode 100644 docs/tutorials/es_ES/validation.md diff --git a/docs/tutorials/en_US/validation.md b/docs/tutorials/en_US/validation.md index 8786cf5f..1e7535ff 100644 --- a/docs/tutorials/en_US/validation.md +++ b/docs/tutorials/en_US/validation.md @@ -6,60 +6,67 @@ title: Validation # Validation -_This tutorial is compatible with hapi v17 and newer; and joi v16 and newer_ +_This tutorial is compatible with hapi v21 and newer; and joi v16 and newer_ ## Overview -Validating data can be very helpful in making sure that your application is stable and secure. hapi allows this functionality by using the module [Joi](https://joi.dev), which allows you to create your validations with a simple and clear object syntax. +hapi checks a request's headers, path parameters, query, payload, and cookies before your handler runs, and can check the response before it is sent. You describe what is allowed, usually with a [joi](https://joi.dev) schema, and hapi rejects anything else. ## Joi -[Joi](https://joi.dev) is an object schema description language and validator for JavaScript objects. Joi allows you to create blueprints or schemas for JavaScript objects to ensure validation of key information. To get started with joi, you must first install and add it as a dependency to your project: +Install joi and import it: -`npm install joi` +```sh +npm install joi +``` -Then, you must import it to your project: +```ts +import Joi from 'joi'; +``` -`const Joi = require('joi');` +joi includes its own TypeScript types, so there is no separate `@types` package to install. -## Input Validation +## Input validation -The first type of validation hapi can perform is input validation. This is defined in the `options` object on a route, and is able to validate headers, path parameters, query parameters, and payload data. Note: In the below examples, you'll see that we give a JS object to `route.options.validate`. The `validate` option has a default value of: +Input rules go in the route's `options.validate`. hapi runs them in this order, after authentication and before the handler, and stops at the first one that fails (see [failAction](#failaction)): -```json5 -{ - headers: true, - params: true, - query: true, - payload: true, - state: true, - failAction: 'error', -} +```mermaid +flowchart TD + A[onPostAuth] --> H[validate.headers] + H --> P[validate.params] + P --> Q[validate.query] + Q --> B[validate.payload] + B --> S[validate.state] + S --> X[onPreHandler, handler] ``` -If a key has a value of `true`, there will be no validation. Keys can also be either a validation function using the signature `async function(value, options)` or a `joi` validation object for its properties. The latter allows you to set `joi` options for that particular schema. Here is a partial rewrite of the [Query Parameters](#queryparams) example: +| Key | Validates | Replaced on success | +| --------- | ------------------------- | ------------------- | +| `headers` | `request.headers` | `request.headers` | +| `params` | `request.params` | `request.params` | +| `query` | `request.query` | `request.query` | +| `payload` | `request.payload` | `request.payload` | +| `state` | `request.state` (cookies) | `request.state` | -```js -options: { - validate: { - query: Joi.object({ - limit: Joi.number().integer().min(1).max(100).default(10), - }).options({ stripUnknown: true }); - } -} -``` +Each key accepts: -Look [here](https://joi.dev) for details about such options. +| Value | Meaning | +| ----------------------------- | ----------------------------------------------------------------------------- | +| `true` or not set (default) | anything is allowed | +| `false` | no value is allowed | +| a joi schema | the value must match the schema | +| a function | your own validator, see [Custom validators](#alternatives) | +| a plain object of joi schemas | allowed only after `server.validator(Joi)`; otherwise `server.route()` throws | -### Path parameters +Validation replaces the request value with the validated one, so joi's type conversions and defaults reach your handler. A query of `?limit=5` arrives as the string `'5'`, and after `Joi.number()` the handler sees the number `5`. The original value stays on [`request.orig`](/api#request-orig). -The first input type that `joi` can validate is path parameters. Consider the following: +### Path parameters -```javascript -server.route({ +```ts +server.route<{ Params: { name: string } }>({ method: 'GET', path: '/hello/{name}', - handler: function (request, h) { + handler: (request, h) => { return `Hello ${request.params.name}!`; }, options: { @@ -72,31 +79,23 @@ server.route({ }); ``` -As you can see here, you've passed a `validate.params` option to the `options` object, this is how you tell hapi that the named parameter specified in the path should be validated. Joi's syntax is very simple and clear to read, the validator you passed here makes sure that the parameter is a string with a minimum length of 3 and a maximum length of 10. - -With this configuration, if you make a request to `/hello/jennifer` you will get the expected `Hello jennifer!` reply, however if you make a request to `/hello/a` you will get an HTTP `400` response that looks like the following: +`/hello/jennifer` replies `Hello jennifer!`. `/hello/a` and `/hello/thisnameiswaytoolong` reply: ```json { + "statusCode": 400, "error": "Bad Request", - "message": "Invalid request params input", - "statusCode": 400 + "message": "Invalid request params input" } ``` -Likewise, if you were to make a request to `/hello/thisnameiswaytoolong`, you'd also get the same error. - ### Query parameters -To validate query parameters, you simply specify a `validate.query` option in the route's options, and you will get similar effects. By default hapi will not validate anything. If you specify a validator for even one query parameter, that means you _must_ specify a validator for all possible query parameters that you would like to accept. - -For example, if you have a route that returns a list of blog posts and you would like the user to limit their result set by count, you could use the following configuration: - -```javascript -server.route({ +```ts +server.route<{ Query: { limit: number } }>({ method: 'GET', path: '/posts', - handler: function (request, h) { + handler: (request, h) => { return posts.slice(0, request.query.limit); }, options: { @@ -109,19 +108,17 @@ server.route({ }); ``` -This makes sure that the `limit` query parameter is always an integer between 1 and 100, and if unspecified defaults to 10. However, if you make a request to `/posts?limit=15&offset=15` you get another HTTP `400` response and error. - -You get an error because the `offset` parameter is not allowed. That's because you didn't provide a validator for it, but you did provide one for the `limit` parameter. +- `limit` must be an integer from 1 to 100, and defaults to 10 when the client leaves it out. +- `/posts?limit=15&offset=15` fails with `400`: a `Joi.object()` schema rejects keys it does not list. List every parameter you accept, or add `.unknown()` to the schema to let other keys through. +- The `Query: { limit: number }` type argument tells TypeScript what the handler receives. hapi does not infer it from the joi schema, so keep the two in sync yourself. -### Payload parameters +### Payload -Also valid is the `validate.payload` option, which will validate payload data sent to a route by the user. It works exactly the same way as query parameters, in that if you validate one key, you must validate them all. Here is an example: - -```js +```ts server.route({ method: 'POST', path: '/post', - handler: function (request, h) { + handler: (request, h) => { return 'Blog post added'; }, options: { @@ -135,23 +132,11 @@ server.route({ }); ``` -The above example is a very basic route that handles an incoming blog post. The user submits the blog post and date in the `request.payload` object. Typically, this would then be stored to a database. Before that can happen though, we must validate the payload. First, `joi` states that `post` must be a minimum of 1 character, and a maximum of 140 characters. It also states that `date` must be a valid date in the MM-DD-YYYY format and is required. - -If any of payload fails validation, the following error will be thrown: - -```json -{ - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400 -} -``` +`post` must be 1 to 140 characters, and `date` is required and must be something JavaScript can parse as a date, such as `'2026-10-25'`. joi converts it, so the handler receives a `Date` object. A failure replies `400` with the message `Invalid request payload input`. ### Headers -You may validate incoming headers as well, with a `validate.headers` option. For example: - -```js +```ts server.route({ method: 'GET', path: '/hello/{name}', @@ -171,30 +156,59 @@ server.route({ }); ``` -Here, you are validating the cookie header as a string and making sure it is required. The `allowUnknown` option allows other incoming headers to be accepted without being validated. +Browsers and proxies add many headers, so header schemas almost always need `allowUnknown: true`. `validate.options` passes joi options to every input schema on the route. Header names are lowercase in `request.headers`, so write them lowercase in the schema. -## Output +### When validation fails -hapi can also validate responses before they are sent back to the client. This validation is defined in the `response` property of the route `options` object. +`validate.failAction` decides what happens: -If a response does not pass the response validation, the client will receive an Internal Server Error (500) response by default (see `response.failAction` below). +| `failAction` | Result | +| ------------------- | ------------------------------------------------------------------------------------------ | +| `'error'` (default) | `400` with a generic message such as `Invalid request query input` | +| `'log'` | logs a `request` event tagged `validation`, `error`, and the source, then runs the handler | +| `'ignore'` | runs the handler | +| a function | `(request, h, err)`: throw an error or return a takeover response | -Output validation is useful for ensuring that your API is serving data that is consistent with its documentation/contract. Additionally, plugins like [hapi-swagger](https://github.com/glennjones/hapi-swagger) can use the response-validation schemas to automatically document each endpoint's output format, thus ensuring that your documentation is always up to date. +With `'log'` and `'ignore'` the handler receives the original, unvalidated value: `?limit=500` arrives as the string `'500'`, not a number. -hapi supports quite a few options to fine-tune output validation. Here are a few of them: +The default `400` hides which field failed, so clients cannot probe your schemas. During development, or on an API where clients need the details, rethrow the detailed error from a `failAction` function: -### response.failAction +```ts +server.route({ + method: 'GET', + path: '/posts', + handler: (request, h) => posts, + options: { + validate: { + query: Joi.object({ + limit: Joi.number().integer().max(100), + }), + failAction: (request, h, err) => { + throw err; + }, + }, + }, +}); +``` + +`/posts?limit=500` now replies with joi's message and the failing keys: + +```json +{ + "statusCode": 400, + "error": "Bad Request", + "message": "\"limit\" must be less than or equal to 100", + "validation": { "source": "query", "keys": ["limit"] } +} +``` -You can choose what to do when response validation fails by setting `response.failAction` to one of the following: +To apply one `failAction` to every route, set it in the server's `routes.validate` option. See [`failAction`](/api#failaction-configuration) in the API reference. -- `error`: send an Internal Server Error (500) response (default) -- `log`: just log the offense and send the response as-is -- `ignore`: take no action and continue processing the request -- A lifecycle method with signature `async function(request, h, err)` where `request` is the request object, `h` is the response toolkit and `err` is the validation error. +## Response validation -For example: +`options.response` checks what the handler returns, after the handler and before `onPreResponse`. Use it to make sure an endpoint matches its documented contract. Plugins like [hapi-swagger](https://github.com/glennjones/hapi-swagger) also read these schemas to document each endpoint's output. -```js +```ts const bookSchema = Joi.object({ title: Joi.string().required(), author: Joi.string().required(), @@ -206,7 +220,7 @@ const bookSchema = Joi.object({ server.route({ method: 'GET', path: '/books', - handler: async function (request, h) { + handler: async (request, h) => { return await getBooks(); }, options: { @@ -218,68 +232,68 @@ server.route({ }); ``` -This is a route that will return a list of books. We can see that since `failAction` is set to `log`, the server will just log the error and send the response as-is. +| Option | What it does | +| ------------ | ------------------------------------------------------------------------------------------------------------- | +| `schema` | rule for every response, same values as input rules | +| `status` | rules per status code, such as `{ 201: created, 202: Joi.object({ original, updated }) }`; overrides `schema` | +| `failAction` | `'error'` (default) replies `500`; also `'log'`, `'ignore'`, or a function | +| `sample` | percentage of responses to validate, `0` to `100`, to save CPU on busy routes | +| `modify` | when `true`, the validated value replaces the response, so joi conversions and `stripUnknown` apply | +| `options` | joi options for these schemas, such as `stripUnknown` | -### response.sample +What response validation skips or rejects: -If performance is a concern, hapi can be configured to validate only a percentage of response. This can be achieved with the `response.sample` property of the route `options`. It should be set to a number between `0`-`100`, representing the percentage of responses that should be validated. Consider the following: +- Error responses (status `400` and up) are not validated, unless `status` has a schema for that code. +- Only plain values (objects, arrays, strings) can be validated. A stream or file response with a response schema fails with `500`. -```javascript -const bookSchema = Joi.object({ - title: Joi.string().required(), - author: Joi.string().required(), - isbn: Joi.string().length(10), - pageCount: Joi.number(), - datePublished: Joi.date().iso(), -}); +`modify: true` with `stripUnknown: true` is a practical way to keep fields like password hashes from leaking out of an endpoint: +```ts server.route({ method: 'GET', - path: '/books', - handler: async function (request, h) { - return await getBooks(); + path: '/me', + handler: (request, h) => { + return { id: '2133d32a', name: 'John Doe', passwordHash: '$2a$10$...' }; }, options: { response: { - sample: 50, - schema: Joi.array().items(bookSchema), + schema: Joi.object({ + id: Joi.string(), + name: Joi.string(), + }).options({ stripUnknown: true }), + modify: true, }, }, }); ``` -Looking at your book route again, you can see, the `sample` value is set to `50`. This means the server will validate one half of the responses. +The client receives `{ "id": "2133d32a", "name": "John Doe" }`. -### response.status +## Custom validators -Sometimes one endpoint can serve different response objects. For instance, a `POST` route may return one of the following: +Any rule can be a function `async (value, options)` instead of a joi schema: -- `201` with the newly created resource if a new resource is created. -- `202` with the old and new values if an existing resource was updated. +- Throw an error to fail validation. `failAction` handles it. +- Return a value to replace the one being validated. The original stays on `request.orig`. +- Return nothing to keep the value unchanged. -hapi supports this by allowing you to specify a different validation schema for each response status code. `response.status` is an object with keys that are numeric status codes, and properties that are joi schemas: +```ts +server.route({ + method: 'GET', + path: '/items', + handler: (request, h) => request.query, + options: { + validate: { + query: async (value) => { + if (typeof value !== 'object' || value === null || !('ids' in value) || typeof value.ids !== 'string') { + throw new Error('ids is required'); + } -```json5 -{ - response: { - status: { - 201: dataSchema, - 202: Joi.object({ original: dataSchema, updated: dataSchema }) - } - } -} + return { ids: value.ids.split(',') }; + }, + }, + }, +}); ``` -### response.options - -Options to pass to joi during validation. Useful to set global options such as `stripUnknown` or `abortEarly`. If a custom validation function is defined via `schema` or `status` then `options` can an arbitrary object that will be passed to this function as the second argument. - -## Alternatives to Joi - -We suggest using Joi for your validation, however each of the validation options hapi provides also accepts a few different options. - -Most simply, you can specify a boolean for any of the options. By default, all available validators are set to `true` which means that no validation will be performed. - -If the validation parameter is set to `false` it signifies that no value is allowed for that parameter. - -You may also pass a custom function with the signature `async function (value, options)` where `value` is the data to be validated and `options` is the validation options as defined on the server object. If a value is returned, the value will replace the original object being validated. For example, if you're validating `request.headers`, the returned value will replace `request.headers` and the original value is stored in `request.orig.headers`. Otherwise, the headers are left unchanged. If an error is thrown, the error is handled according to `failAction`. +`/items?ids=1,2,3` replies `{ "ids": ["1", "2", "3"] }`. A thrown error is turned into a `400`, the same as a joi failure. diff --git a/docs/tutorials/es_ES/validation.md b/docs/tutorials/es_ES/validation.md new file mode 100644 index 00000000..ff8687c2 --- /dev/null +++ b/docs/tutorials/es_ES/validation.md @@ -0,0 +1,299 @@ +--- +title: Validación +--- + + + +# Validación + +_Este tutorial es compatible con hapi v21 y versiones posteriores, y con joi v16 y versiones posteriores_ + +## Introducción + +hapi comprueba las cabeceras, los parámetros del path, la query, el payload y las cookies de una petición antes de que se ejecute tu handler, y puede comprobar la respuesta antes de enviarla. Tú describes lo que está permitido, normalmente con un esquema de [joi](https://joi.dev), y hapi rechaza todo lo demás. + +## Joi + +Instala joi e impórtalo: + +```sh +npm install joi +``` + +```ts +import Joi from 'joi'; +``` + +joi incluye sus propios tipos de TypeScript, así que no hay que instalar un paquete `@types` aparte. + +## Validación de la entrada + +Las reglas de entrada van en `options.validate` de la ruta. hapi las ejecuta en este orden, después de la autenticación y antes del handler, y se detiene en la primera que falla (consulta [failAction](#failaction)): + +```mermaid +flowchart TD + A[onPostAuth] --> H[validate.headers] + H --> P[validate.params] + P --> Q[validate.query] + Q --> B[validate.payload] + B --> S[validate.state] + S --> X[onPreHandler, handler] +``` + +| Clave | Valida | Se sustituye si es válido | +| --------- | ------------------------- | ------------------------- | +| `headers` | `request.headers` | `request.headers` | +| `params` | `request.params` | `request.params` | +| `query` | `request.query` | `request.query` | +| `payload` | `request.payload` | `request.payload` | +| `state` | `request.state` (cookies) | `request.state` | + +Cada clave acepta: + +| Valor | Significado | +| ----------------------------------- | ----------------------------------------------------------------------------------------------- | +| `true` o sin definir (por defecto) | se permite cualquier cosa | +| `false` | no se permite ningún valor | +| un esquema de joi | el valor tiene que cumplir el esquema | +| una función | tu propio validador; consulta [Validadores personalizados](#alternatives) | +| un objeto simple de esquemas de joi | solo se permite después de `server.validator(Joi)`; si no, `server.route()` lanza una excepción | + +La validación sustituye el valor de la petición por el validado, así que las conversiones de tipo y los valores por defecto de joi llegan a tu handler. Una query `?limit=5` llega como el string `'5'`, y después de `Joi.number()` el handler ve el número `5`. El valor original se queda en [`request.orig`](/api#request-orig). + +### Parámetros del path + +```ts +server.route<{ Params: { name: string } }>({ + method: 'GET', + path: '/hello/{name}', + handler: (request, h) => { + return `Hello ${request.params.name}!`; + }, + options: { + validate: { + params: Joi.object({ + name: Joi.string().min(3).max(10), + }), + }, + }, +}); +``` + +`/hello/jennifer` responde `Hello jennifer!`. `/hello/a` y `/hello/thisnameiswaytoolong` responden: + +```json +{ + "statusCode": 400, + "error": "Bad Request", + "message": "Invalid request params input" +} +``` + +### Parámetros de query + +```ts +server.route<{ Query: { limit: number } }>({ + method: 'GET', + path: '/posts', + handler: (request, h) => { + return posts.slice(0, request.query.limit); + }, + options: { + validate: { + query: Joi.object({ + limit: Joi.number().integer().min(1).max(100).default(10), + }), + }, + }, +}); +``` + +- `limit` tiene que ser un entero entre 1 y 100, y vale 10 por defecto cuando el cliente no lo envía. +- `/posts?limit=15&offset=15` falla con un `400`: un esquema `Joi.object()` rechaza las claves que no enumera. Enumera todos los parámetros que aceptas, o añade `.unknown()` al esquema para dejar pasar otras claves. +- El argumento de tipo `Query: { limit: number }` le dice a TypeScript qué recibe el handler. hapi no lo infiere del esquema de joi, así que tienes que mantener ambos sincronizados tú mismo. + +### Payload + +```ts +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().min(1).max(140), + date: Joi.date().required(), + }), + }, + }, +}); +``` + +`post` tiene que tener entre 1 y 140 caracteres, y `date` es obligatorio y tiene que ser algo que JavaScript pueda interpretar como fecha, como `'2026-10-25'`. joi lo convierte, así que el handler recibe un objeto `Date`. Un fallo responde `400` con el mensaje `Invalid request payload input`. + +### Cabeceras + +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: (request, h) => { + return `Hello ${request.params.name}!`; + }, + options: { + validate: { + headers: Joi.object({ + cookie: Joi.string().required(), + }), + options: { + allowUnknown: true, + }, + }, + }, +}); +``` + +Los navegadores y los proxies añaden muchas cabeceras, así que los esquemas de cabeceras casi siempre necesitan `allowUnknown: true`. `validate.options` pasa opciones de joi a todos los esquemas de entrada de la ruta. Los nombres de las cabeceras están en minúsculas en `request.headers`, así que escríbelos en minúsculas en el esquema. + +### Cuando la validación falla + +`validate.failAction` decide qué pasa: + +| `failAction` | Resultado | +| ----------------------- | ------------------------------------------------------------------------------------------------------ | +| `'error'` (por defecto) | `400` con un mensaje genérico como `Invalid request query input` | +| `'log'` | registra un evento `request` con las etiquetas `validation`, `error` y el origen, y ejecuta el handler | +| `'ignore'` | ejecuta el handler | +| una función | `(request, h, err)`: lanza un error o devuelve una respuesta takeover | + +Con `'log'` e `'ignore'` el handler recibe el valor original sin validar: `?limit=500` llega como el string `'500'`, no como un número. + +El `400` por defecto oculta qué campo falló, para que los clientes no puedan sondear tus esquemas. Durante el desarrollo, o en una API cuyos clientes necesitan los detalles, vuelve a lanzar el error detallado desde una función `failAction`: + +```ts +server.route({ + method: 'GET', + path: '/posts', + handler: (request, h) => posts, + options: { + validate: { + query: Joi.object({ + limit: Joi.number().integer().max(100), + }), + failAction: (request, h, err) => { + throw err; + }, + }, + }, +}); +``` + +Ahora `/posts?limit=500` responde con el mensaje de joi y las claves que fallan: + +```json +{ + "statusCode": 400, + "error": "Bad Request", + "message": "\"limit\" must be less than or equal to 100", + "validation": { "source": "query", "keys": ["limit"] } +} +``` + +Para aplicar un mismo `failAction` a todas las rutas, defínelo en la opción `routes.validate` del servidor. Consulta [`failAction`](/api#failaction-configuration) en la referencia de la API. + +## Validación de la respuesta + +`options.response` comprueba lo que devuelve el handler, después del handler y antes de `onPreResponse`. Úsalo para asegurarte de que un endpoint cumple su contrato documentado. Plugins como [hapi-swagger](https://github.com/glennjones/hapi-swagger) también leen estos esquemas para documentar la salida de cada endpoint. + +```ts +const bookSchema = Joi.object({ + title: Joi.string().required(), + author: Joi.string().required(), + isbn: Joi.string().length(10), + pageCount: Joi.number(), + datePublished: Joi.date().iso(), +}); + +server.route({ + method: 'GET', + path: '/books', + handler: async (request, h) => { + return await getBooks(); + }, + options: { + response: { + schema: Joi.array().items(bookSchema), + failAction: 'log', + }, + }, +}); +``` + +| Opción | Qué hace | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `schema` | regla para todas las respuestas, con los mismos valores que las reglas de entrada | +| `status` | reglas por código de estado, como `{ 201: created, 202: Joi.object({ original, updated }) }`; tiene prioridad sobre `schema` | +| `failAction` | `'error'` (por defecto) responde `500`; también `'log'`, `'ignore'` o una función | +| `sample` | porcentaje de respuestas que se validan, de `0` a `100`, para ahorrar CPU en rutas con mucho tráfico | +| `modify` | con `true`, el valor validado sustituye a la respuesta, así que se aplican las conversiones de joi y `stripUnknown` | +| `options` | opciones de joi para estos esquemas, como `stripUnknown` | + +Lo que la validación de la respuesta omite o rechaza: + +- Las respuestas de error (estado `400` o superior) no se validan, salvo que `status` tenga un esquema para ese código. +- Solo se pueden validar valores simples (objetos, arrays, strings). Una respuesta de stream o de fichero con un esquema de respuesta falla con un `500`. + +`modify: true` con `stripUnknown: true` es una forma práctica de evitar que campos como los hashes de contraseñas se filtren desde un endpoint: + +```ts +server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return { id: '2133d32a', name: 'John Doe', passwordHash: '$2a$10$...' }; + }, + options: { + response: { + schema: Joi.object({ + id: Joi.string(), + name: Joi.string(), + }).options({ stripUnknown: true }), + modify: true, + }, + }, +}); +``` + +El cliente recibe `{ "id": "2133d32a", "name": "John Doe" }`. + +## Validadores personalizados + +Cualquier regla puede ser una función `async (value, options)` en lugar de un esquema de joi: + +- Lanza un error para que la validación falle. `failAction` se encarga de él. +- Devuelve un valor para sustituir al que se está validando. El original se queda en `request.orig`. +- No devuelvas nada para dejar el valor como está. + +```ts +server.route({ + method: 'GET', + path: '/items', + handler: (request, h) => request.query, + options: { + validate: { + query: async (value) => { + if (typeof value !== 'object' || value === null || !('ids' in value) || typeof value.ids !== 'string') { + throw new Error('ids is required'); + } + + return { ids: value.ids.split(',') }; + }, + }, + }, +}); +``` + +`/items?ids=1,2,3` responde `{ "ids": ["1", "2", "3"] }`. Un error lanzado se convierte en un `400`, igual que un fallo de joi. diff --git a/docs/tutorials/ko_KR/validation.md b/docs/tutorials/ko_KR/validation.md index 2f6a2bdc..136078aa 100644 --- a/docs/tutorials/ko_KR/validation.md +++ b/docs/tutorials/ko_KR/validation.md @@ -1,141 +1,214 @@ --- -title: Validation +title: 검증 --- -## 유효성 검사 +# 검증 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상, joi v16 이상과 호환됩니다_ -데이터 검증은 응용프로그램이 안정적이고 안전한지 확인하는데 유용할 수 있습니다. hapi는 간단하고 명확한 객체 문법으로 유효성 검사를 만들수 있는 [Joi](https://github.com/hapijs/joi) 모듈을 사용하여 이 기능을 제공합니다. +## 개요 -## Input +hapi는 핸들러가 실행되기 전에 요청의 헤더, 경로 파라미터, 쿼리, 페이로드, 쿠키를 검사하고, 응답을 보내기 전에 응답도 검사할 수 있습니다. 허용되는 값을 보통 [joi](https://joi.dev) 스키마로 기술하면, hapi가 그 밖의 모든 것을 거부합니다. -hapi가 실행하는 첫 번째 유형의 유효성 검사는 입력 유효성 검사입니다. 이는 route 안에 `options` 객체에 정의되어 있으며 헤더, 경로 인자, 질의 인자, 페이로드 데이터의 유효성을 검사할 수 있습니다. +## Joi -예제를 보겠습니다.: +joi를 설치하고 import하세요: -```javascript -server.route({ +```sh +npm install joi +``` + +```ts +import Joi from 'joi'; +``` + +joi는 자체 TypeScript 타입을 포함하므로 별도의 `@types` 패키지를 설치할 필요가 없습니다. + +## 입력 검증 + +입력 규칙은 라우트의 `options.validate`에 넣습니다. hapi는 인증 후, 핸들러 전에 다음 순서로 규칙을 실행하고, 처음 실패한 규칙에서 멈춥니다([failAction](#failaction) 참고): + +```mermaid +flowchart TD + A[onPostAuth] --> H[validate.headers] + H --> P[validate.params] + P --> Q[validate.query] + Q --> B[validate.payload] + B --> S[validate.state] + S --> X[onPreHandler, 핸들러] +``` + +| 키 | 검증 대상 | 성공 시 교체되는 값 | +| --------- | ---------------------- | ------------------- | +| `headers` | `request.headers` | `request.headers` | +| `params` | `request.params` | `request.params` | +| `query` | `request.query` | `request.query` | +| `payload` | `request.payload` | `request.payload` | +| `state` | `request.state` (쿠키) | `request.state` | + +각 키에 넣을 수 있는 값: + +| 값 | 의미 | +| ------------------------------ | ----------------------------------------------------------------------------------- | +| `true` 또는 설정 안 함(기본값) | 모든 값을 허용 | +| `false` | 어떤 값도 허용하지 않음 | +| joi 스키마 | 값이 스키마와 일치해야 함 | +| 함수 | 직접 작성한 검증기. [커스텀 검증기](#alternatives) 참고 | +| joi 스키마로 된 일반 객체 | `server.validator(Joi)` 이후에만 허용. 그렇지 않으면 `server.route()`가 예외를 던짐 | + +검증은 요청 값을 검증된 값으로 교체하므로 joi의 타입 변환과 기본값이 핸들러에 전달됩니다. `?limit=5` 쿼리는 문자열 `'5'`로 도착하지만, `Joi.number()`를 거치면 핸들러는 숫자 `5`를 받습니다. 원래 값은 [`request.orig`](/api#request-orig)에 남아 있습니다. + +### 경로 파라미터 + +```ts +server.route<{ Params: { name: string } }>({ method: 'GET', path: '/hello/{name}', - handler: function (request, h) { + handler: (request, h) => { return `Hello ${request.params.name}!`; }, options: { validate: { - params: { + params: Joi.object({ name: Joi.string().min(3).max(10), - }, + }), }, }, }); ``` -### 경로 인자 - -여기에서 볼 수 있듯이 `validate.params` 인자를 `options` 객체에 전달하는 것으로 hapi는 경로에 지정된 명명된 인자의 유효성을 검증해야 합니다. Joi의 문법은 매우 간단하고 읽기에 명확합니다. 유효성 검사기는 인자가 최소 3글자, 최대 10글자인 문자열인지 확인합니다. - -이 설정으로 `/hello/jennifer`에 요청하면 `Hello jennifer!`라는 응답을 받을 것입니다. 하지만 `/hello/a`에 요청하면 HTTP `400` 응답을 다음과 같이 받을 것입니다.: +`/hello/jennifer`는 `Hello jennifer!`로 응답합니다. `/hello/a`와 `/hello/thisnameiswaytoolong`은 다음과 같이 응답합니다: ```json { + "statusCode": 400, "error": "Bad Request", - "message": "Invalid request params input", - "statusCode": 400 + "message": "Invalid request params input" } ``` -마찬가지로 `/hello/thisnameiswaytoolong`에 요청하면 같은 에러를 받을 것입니다. - -### 질의 인자 - -질의 인자의 유효성을 검사하려면 route 옵션에 `validate.query` 옵션을 지정하면 유사한 효과가 나타날 것입니다. 기본으로 hapi는 아무 유효성 검사를 하지 않습니다. 하나의 질의 인자에 대한 유효성 검사를 지정하면 받아들일 수 있는 모든 질의 인자들에 대한 유효성 검사기를 _반드시_ 지정해야 합니다. +### 쿼리 파라미터 -예를 들면 블로그 글의 목록을 반환하는 route를 가지고 있고 사용자가 결과의 개수를 제한하려는 경우 다음 설정을 사용할 수 있습니다.: - -```javascript -server.route({ +```ts +server.route<{ Query: { limit: number } }>({ method: 'GET', path: '/posts', - handler: function (request, h) { + handler: (request, h) => { return posts.slice(0, request.query.limit); }, options: { validate: { - query: { + query: Joi.object({ limit: Joi.number().integer().min(1).max(100).default(10), - }, + }), }, }, }); ``` -`limit` 질의 인자는 항상 1과 100 사이의 정수인 것을 확인하고, 만약 없다면 기본값으로 10을 설정합니다. `/list?limit=15&offset=15`로 요청하면 HTTP `400` 응답을 다음과 같이 받을 것입니다. - -`offset` 인자가 허락되지 않기때문에 에러를 받았습니다. `limit` 인자의 유효성 검사를 제공했지만 `offset`에 대한 유효성 검사기를 제공하지 않았기 때문입니다. - -### 헤더 - -`validate.headers` 인자로 들어오는 헤더를 검사할 수 있습니다. - -### 페이로드 인자 - -사용자가 route로 보낸 페이로드 데이터의 유효성을 검사하는 `validate.payload` 인자도 유효합니다. 하나의 키의 유효성을 검사하려면 모든 키의 유효성을 검사해야 하는 점에서 질의 인자 검사와 정확히 같은 방법으로 동작합니다. +- `limit`은 1부터 100 사이의 정수여야 하고, 클라이언트가 생략하면 기본값은 10입니다. +- `/posts?limit=15&offset=15`는 `400`으로 실패합니다. `Joi.object()` 스키마는 나열되지 않은 키를 거부하기 때문입니다. 받는 파라미터를 모두 나열하거나, 스키마에 `.unknown()`을 추가해 다른 키를 통과시키세요. +- `Query: { limit: number }` 타입 인자는 핸들러가 받는 값을 TypeScript에 알려 줍니다. hapi는 joi 스키마에서 이를 추론하지 않으므로 둘을 직접 맞춰 두어야 합니다. -## 출력 +### 페이로드 -클라이언트로 응답을 보내기 전에 hapi 응답의 유효성을 검사할 수 있습니다. 이 유효성 검사는 route `options` 객체의 `response` 속성에 정의되어 있습니다. - -응답이 응답 유효성 검사를 통과하지 못하면 클라이언트는 기본으로 내부 서버 에러 (500) 응답을 받을 것입니다. (아래의 `response.failAction`을 보세요.) +```ts +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().min(1).max(140), + date: Joi.date().required(), + }), + }, + }, +}); +``` -출력 유효성 검사는 API가 문서/계약과 일치하는 데이터를 제공하는지 확인하는 데 유용합니다. 게다가 [hapi-swagger](https://github.com/glennjones/hapi-swagger) 와 [lout](https://github.com/hapijs/lout) 같은 플러그인은 응답 유효성 검사 스키마를 사용하여 자동으로 각 단말의 출력 형식으로 문서를 만들고 그 문서를 항상 최신 상태로 유지합니다. +`post`는 1자에서 140자여야 하고, `date`는 필수이며 `'2026-10-25'`처럼 JavaScript가 날짜로 파싱할 수 있는 값이어야 합니다. joi가 이를 변환하므로 핸들러는 `Date` 객체를 받습니다. 실패하면 `Invalid request payload input` 메시지와 함께 `400`으로 응답합니다. -hapi는 출력 유효성 검사를 세밀하게 조정할 수 있는 몇 가지 옵션을 제공합니다. 여기 그중에 몇 가지가 있습니다. +### 헤더 -### response.failAction +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: (request, h) => { + return `Hello ${request.params.name}!`; + }, + options: { + validate: { + headers: Joi.object({ + cookie: Joi.string().required(), + }), + options: { + allowUnknown: true, + }, + }, + }, +}); +``` -`response.failAction`을 다음 중 하나로 설정하여 응답 유효성 검사가 실패할 때 수행할 작업을 선택할 수 있습니다. +브라우저와 프록시는 많은 헤더를 추가하므로 헤더 스키마에는 거의 항상 `allowUnknown: true`가 필요합니다. `validate.options`는 라우트의 모든 입력 스키마에 joi 옵션을 전달합니다. `request.headers`의 헤더 이름은 소문자이므로 스키마에도 소문자로 작성하세요. -- `error`: 내부 서버 에러 (500) 응답을 전송합니다. (기본) -- `log`: 공격을 기록하고 그대로 응답을 전송합니다. -- `ignore`: 아무 행동을 하지 않고 요청 처리를 계속 합니다. -- `request`는 요청 객체, `h`는 응답 도구, `err`이 유효성 에러인 async function(request, h, err)` 형식의 라이프사이클 메소드 +### 검증이 실패하면 -### response.sample +무슨 일이 일어날지는 `validate.failAction`이 결정합니다: -성능이 중요하다면 hapi는 응답의 일정 비율만 유효성 검사를 하도록 설정할 수 있습니다. route `options`의 `response.sample` 속성으로 이를 설정할 수 있습니다. 응답이 유효한지의 응답 확률인 `0`-`100` 사이의 숫자로 설정될 것입니다. +| `failAction` | 결과 | +| ------------------ | --------------------------------------------------------------------------------------- | +| `'error'` (기본값) | `Invalid request query input` 같은 일반적인 메시지와 함께 `400` | +| `'log'` | `validation`, `error`, 소스 태그가 붙은 `request` 이벤트를 로그로 남긴 뒤 핸들러를 실행 | +| `'ignore'` | 핸들러를 실행 | +| 함수 | `(request, h, err)`: 에러를 던지거나 takeover 응답을 반환 | -### response.status +`'log'`와 `'ignore'`에서는 핸들러가 검증되지 않은 원래 값을 받습니다. `?limit=500`은 숫자가 아니라 문자열 `'500'`으로 도착합니다. -때때로 하나의 단말이 여러 응답 객체를 제공할 수 있습니다. 예를 들어 `POST` route는 다음 중 하나를 반환할 수 있습니다.: +기본 `400`은 어떤 필드가 실패했는지 숨기므로 클라이언트가 스키마를 탐색할 수 없습니다. 개발 중이거나 클라이언트가 자세한 정보를 필요로 하는 API라면 `failAction` 함수에서 자세한 에러를 다시 던지세요: -- 새로운 자원이 생성되었다면 새로 생성된 자원과 함께 `201` -- 기존에 있던 자원이 갱신된 경우 이전 값과 새 값을 함께 `202` +```ts +server.route({ + method: 'GET', + path: '/posts', + handler: (request, h) => posts, + options: { + validate: { + query: Joi.object({ + limit: Joi.number().integer().max(100), + }), + failAction: (request, h, err) => { + throw err; + }, + }, + }, +}); +``` -hapi는 각 응답 코드에 대해 다른 유효성 검사를 지정하는 것을 제공합니다. `response.status`는 숫자 상태 코드인 키와 joi 스키마인 속성을 가진 객체입니다.: +이제 `/posts?limit=500`은 joi의 메시지와 실패한 키로 응답합니다: -```json5 +```json { - response: { - status: { - 201: dataSchema, - 202: Joi.object({ original: dataSchema, updated: dataSchema }) - } - } + "statusCode": 400, + "error": "Bad Request", + "message": "\"limit\" must be less than or equal to 100", + "validation": { "source": "query", "keys": ["limit"] } } ``` -### response.options - -유효성 검사중에 joi에 전달할 옵션입니다. 자세한 내용은 [API docs](/api#-routeoptionsresponseoptions)를 봐주세요. +모든 라우트에 하나의 `failAction`을 적용하려면 서버의 `routes.validate` 옵션에 설정하세요. API 레퍼런스의 [`failAction`](/api#failaction-configuration)을 참고하세요. -### Example +## 응답 검증 -여기에 책 목록을 반환하는 route 설정 예제가 있습니다.: +`options.response`는 핸들러가 반환한 값을 핸들러 이후, `onPreResponse` 이전에 검사합니다. 엔드포인트가 문서화된 계약과 일치하는지 확인할 때 사용하세요. [hapi-swagger](https://github.com/glennjones/hapi-swagger) 같은 플러그인도 이 스키마를 읽어 각 엔드포인트의 출력을 문서화합니다. -```javascript +```ts const bookSchema = Joi.object({ title: Joi.string().required(), author: Joi.string().required(), @@ -147,26 +220,80 @@ const bookSchema = Joi.object({ server.route({ method: 'GET', path: '/books', - handler: async function (request, h) { + handler: async (request, h) => { return await getBooks(); }, options: { response: { - sample: 50, schema: Joi.array().items(bookSchema), + failAction: 'log', }, }, }); ``` -응답의 반만 유효성을 검사합니다. (`sample: 50`) `response.failAction`이 설정되지 않았기 때문에 `books`가 `bookSchema`에 정확히 일치하지 않으면 hapi는 `500` 에러 코드를 응답할 것입니다. 에러 응답은 에러의 이유를 표지하지 _않습니다_. 로깅이 설정되어 있으면 응답 유효성 검사가 실패 원인의 정보를 에러 로그에서 확인할 수 있습니다. `response.failAction`이 `log`로 설정되었다면 hapi는 원래 페이로드로 응답을 하고 유효성 검사 에러를 기록할 것입니다. +| 옵션 | 하는 일 | +| ------------ | -------------------------------------------------------------------------------------------------- | +| `schema` | 모든 응답에 대한 규칙. 입력 규칙과 같은 값을 받음 | +| `status` | `{ 201: created, 202: Joi.object({ original, updated }) }`처럼 상태 코드별 규칙. `schema`보다 우선 | +| `failAction` | `'error'`(기본값)는 `500`으로 응답. `'log'`, `'ignore'`, 함수도 가능 | +| `sample` | 검증할 응답의 비율(`0`에서 `100`). 트래픽이 많은 라우트의 CPU를 아낌 | +| `modify` | `true`이면 검증된 값이 응답을 대체하므로 joi 변환과 `stripUnknown`이 적용됨 | +| `options` | 이 스키마들에 대한 joi 옵션. 예: `stripUnknown` | -### Joi의 대안 +응답 검증이 건너뛰거나 거부하는 것: -유효성 검사에 Joi를 사용할 것을 권합니다. 그러나 hapi가 제공하는 유효성 검사 옵션 각각에서 몇 가지 다른 선택을 할 수 있습니다. +- 에러 응답(상태 `400` 이상)은 `status`에 해당 코드의 스키마가 없으면 검증하지 않습니다. +- 일반 값(객체, 배열, 문자열)만 검증할 수 있습니다. 응답 스키마가 있는 라우트에서 스트림이나 파일 응답은 `500`으로 실패합니다. -가장 단순하게 어떤 옵션에 boolean을 지정할 수 있습니다. 기본으로 모든 사용 가능한 유효성 검사는 `true`로 설정되어 있고 이는 유효성 검사가 수행되지 않음을 의미합니다. +`modify: true`와 `stripUnknown: true`를 함께 쓰면 비밀번호 해시 같은 필드가 엔드포인트 밖으로 새어 나가는 것을 실용적으로 막을 수 있습니다: -유효성 검증 인자가 `false`로 설정되면, 해당 인자에 값이 허용되지 않음을 나타냅니다. +```ts +server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return { id: '2133d32a', name: 'John Doe', passwordHash: '$2a$10$...' }; + }, + options: { + response: { + schema: Joi.object({ + id: Joi.string(), + name: Joi.string(), + }).options({ stripUnknown: true }), + modify: true, + }, + }, +}); +``` + +클라이언트는 `{ "id": "2133d32a", "name": "John Doe" }`를 받습니다. + +## 커스텀 검증기 + +모든 규칙은 joi 스키마 대신 `async (value, options)` 함수가 될 수 있습니다: + +- 에러를 던지면 검증이 실패합니다. 처리는 `failAction`이 맡습니다. +- 값을 반환하면 검증 중인 값을 대체합니다. 원래 값은 `request.orig`에 남습니다. +- 아무것도 반환하지 않으면 값이 그대로 유지됩니다. + +```ts +server.route({ + method: 'GET', + path: '/items', + handler: (request, h) => request.query, + options: { + validate: { + query: async (value) => { + if (typeof value !== 'object' || value === null || !('ids' in value) || typeof value.ids !== 'string') { + throw new Error('ids is required'); + } + + return { ids: value.ids.split(',') }; + }, + }, + }, +}); +``` -유효성 검사를 받을 데이터인 `value`와 서버 객체에 정의된 유효성 검사 옵션인 `options`를 인자로 가지는 `async function(value, options)` 형식의 사용자 함수를 전달할 수 있습니다. 만약 값이 반환된다면 값은 유효성 검사를 받은 원본 객체를 대체합니다. 예를 들어 `request.headers`의 유효성을 검사한다면 반환된 값은 `request.headers`를 대체할 것이고 원본 값은 `request.orig.headers`에 저장될 것입니다. 그렇지 않으면 헤더는 변경되지 않습니다. 만약 에러가 발생하면 에러는 `failAction`에 따라 처리됩니다. +`/items?ids=1,2,3`은 `{ "ids": ["1", "2", "3"] }`로 응답합니다. 던진 에러는 joi 실패와 마찬가지로 `400`이 됩니다. diff --git a/docs/tutorials/pt_BR/validation.md b/docs/tutorials/pt_BR/validation.md index 3b8a5f0e..09f774b3 100644 --- a/docs/tutorials/pt_BR/validation.md +++ b/docs/tutorials/pt_BR/validation.md @@ -1,178 +1,214 @@ --- -title: Validation +title: Validação --- -## Validação +# Validação -Validação de dados é uma ferramenta muito útil para garantir que a sua aplicação é estável e segura. hapi permite essa funcionalidade utilizando o módulo `joi`. +_Este tutorial é compatível com o hapi v21 e versões mais recentes, e com o joi v16 e versões mais recentes_ -Por padrão, todos os validadores disponíveis são definidos como `true`, o que significa que nenhuma validação será executada. +## Visão geral -Se o parâmetro de validação é definido como `false` isso significa que nenhum valor é permitido para esse parâmetro. +O hapi verifica os headers, os parâmetros de caminho, a query, o payload e os cookies de uma requisição antes de o seu handler rodar, e pode verificar a resposta antes de ela ser enviada. Você descreve o que é permitido, normalmente com um schema do [joi](https://joi.dev), e o hapi rejeita todo o resto. -Você pode também passar uma função com a declaração padrão `function (value, options, next)` aonde `value` é dado a ser validado, `options` são as opções de validação definidas no objeto server, e `next` é o método callback (com a declaração padrão `function (err, value)`) a ser chamado ao final da validação. +## Joi -A última configuração disponível dos parâmetros de validação, é um objeto [Joi](https://github.com/hapijs/joi), que permite a criação de validações com uma sintaxe simples e clara. +Instale o joi e importe-o: -## Input +```sh +npm install joi +``` + +```ts +import Joi from 'joi'; +``` -O primeiro tipo de validação que o hapi pode executar é a validação de input. Ela é definida no objeto `options` em uma rota, e é capaz de validar cabeçalhos, parâmetros de rota (path), parâmetros de query, e payloads. +O joi já inclui os próprios tipos TypeScript, então não há um pacote `@types` separado para instalar. -Vamos observar um exemplo: +## Validação de entrada -```javascript -server.route({ +As regras de entrada ficam em `options.validate` da rota. O hapi as executa nesta ordem, depois da autenticação e antes do handler, e para na primeira que falhar (veja [failAction](#failaction)): + +```mermaid +flowchart TD + A[onPostAuth] --> H[validate.headers] + H --> P[validate.params] + P --> Q[validate.query] + Q --> B[validate.payload] + B --> S[validate.state] + S --> X[onPreHandler, handler] +``` + +| Chave | Valida | Substituído em caso de sucesso | +| --------- | ------------------------- | ------------------------------ | +| `headers` | `request.headers` | `request.headers` | +| `params` | `request.params` | `request.params` | +| `query` | `request.query` | `request.query` | +| `payload` | `request.payload` | `request.payload` | +| `state` | `request.state` (cookies) | `request.state` | + +Cada chave aceita: + +| Valor | Significado | +| ------------------------------------ | ---------------------------------------------------------------------------------------------- | +| `true` ou não definido (padrão) | qualquer coisa é permitida | +| `false` | nenhum valor é permitido | +| um schema do joi | o valor precisa corresponder ao schema | +| uma função | seu próprio validador, veja [Validadores próprios](#alternatives) | +| um objeto simples com schemas do joi | permitido só depois de `server.validator(Joi)`; caso contrário, `server.route()` lança um erro | + +A validação substitui o valor da requisição pelo valor validado, então as conversões de tipo e os valores padrão do joi chegam ao seu handler. Uma query `?limit=5` chega como a string `'5'`, e depois de `Joi.number()` o handler vê o número `5`. O valor original fica em [`request.orig`](/api#request-orig). + +### Parâmetros de caminho + +```ts +server.route<{ Params: { name: string } }>({ method: 'GET', path: '/hello/{name}', - handler: function (request, reply) { - reply('Hello ' + request.params.name + '!'); + handler: (request, h) => { + return `Hello ${request.params.name}!`; }, options: { validate: { - params: { + params: Joi.object({ name: Joi.string().min(3).max(10), - }, + }), }, }, }); ``` -### Parâmetros de Rota (path) - -Como você pode ver acima, nós passamos um parâmetro `validate.params` para o objeto `options`, essa é a forma que nós informamos ao hapi que um determinado parâmetro especificado na rota deve ser validado. A sintaxe do Joi é muito simples e clara de ler, o validador que nós passamos garante que o parâmetro é uma string, com um tamanho mínimo de 3 caracteres e um tamanho máximo de 10 caracteres. - -Com essa configuração, se nós fizermos uma requisição para `/hello/jennifer` nós vamos obter a resposta esperada `Hello jennifer!`, entretanto se nós fizermos uma requisição para `/hello/a` nós vamos obter uma resposta HTTP `400` com o seguinte formato: +`/hello/jennifer` responde `Hello jennifer!`. `/hello/a` e `/hello/thisnameiswaytoolong` respondem: ```json { - "error": "Bad Request", - "message": "the length of name must be at least 3 characters long", "statusCode": 400, - "validation": { - "keys": ["name"], - "source": "params" - } -} -``` - -Da memsa forma, se nós fizermos uma requisição para `/hello/thisnameiswaytoolong`, nós vamos obter uma resposta HTTP `400` com o seguinte formato: - -```json -{ "error": "Bad Request", - "message": "the length of name must be less than or equal to 10 characters long", - "statusCode": 400, - "validation": { - "keys": ["name"], - "source": "params" - } + "message": "Invalid request params input" } ``` -### Parâmetros de Query - -Para validar parâmetros de query, nós simplesmente especificamos um parâmetro `validate.query` na configuração de rota, e nós vamos obter efeitos similares. Por padrão hapi não irá validar nenhum parâmetro. Se você especificar um validador para um parâmetro de query qualquer, isso significa que você _deve_ especificar validadores para todos os parâmetros de query que você gostaria de aceitar. +### Parâmetros de query -Por exemplo, se você tem uma rota que lista todos os recursos e você gostaria que o usuário pudesse limitar a quantidade total do resultado, você poderia utilizar a seguinte configuração: - -```javascript -server.route({ +```ts +server.route<{ Query: { limit: number } }>({ method: 'GET', - path: '/list', - handler: function (request, reply) { - reply(resources.slice(0, request.query.limit)); + path: '/posts', + handler: (request, h) => { + return posts.slice(0, request.query.limit); }, options: { validate: { - query: { + query: Joi.object({ limit: Joi.number().integer().min(1).max(100).default(10), - }, + }), }, }, }); ``` -Essa configuração garante que o parâmetro de query `limit` é sempre um inteiro entre 1 e 100, e se não especificado um valor assume o valor padrão 10. Entretanto, se nós fizermos uma requisição para `/list?limit=15&offset=15` nós vamos obter uma resposta HTTP `400` com o seguinte formato: +- `limit` precisa ser um inteiro de 1 a 100, e assume 10 quando o cliente não o envia. +- `/posts?limit=15&offset=15` falha com `400`: um schema `Joi.object()` rejeita as chaves que não lista. Liste todos os parâmetros que você aceita, ou adicione `.unknown()` ao schema para deixar outras chaves passarem. +- O argumento de tipo `Query: { limit: number }` informa ao TypeScript o que o handler recebe. O hapi não o infere a partir do schema do joi, então mantenha os dois sincronizados você mesmo. -```json -{ - "error": "Bad Request", - "message": "the key offset is not allowed", - "statusCode": 400, - "validation": { - "keys": ["offset"], - "source": "query" - } -} -``` - -Como você pode ver no objeto de erro, o parâmetro `offset` não é permitido. Isso é porque nós não definimos um validador específico para `offset`, mas definimos um validador para `limit`. Se você validar uma chave, por padrão, você deve validar todas as chaves. +### Payload -### Cabeçalhos - -Você pode validar cabeçalhos das requisições, com o parâmetro `validate.headers`. - -### Parâmetros payload - -Também válido é o parâmetro `validate.payload`, que irá validar todos os dados enviados à uma rota pelo usuário. Funciona da mesma forma que a validação de parâmetro de query, em que se você validar uma chave, você deve validar todas as chaves. - -## Saída - -O Hapi também pode validar as respostas antes delas serem devolvidas para o cliente. -Essa validação é definida no objeto de `options` e na propriedade `response` da rota. - -Se a resposta não for valida, o cliente irá receber um Erro Interno do Servidor (500) como resposta por padrão (veja `response.failAction` abaixo). +```ts +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().min(1).max(140), + date: Joi.date().required(), + }), + }, + }, +}); +``` -A validação de saída é útil para garantir que a sua API está servindo dados consistentes com a documentação/contrato. -Além disso, plugins como [hapi-swagger](https://github.com/glennjones/hapi-swagger) e [lout](https://github.com/hapijs/lout) podem usar os esquemas de validação da resposta para documentar o formato de sáida de cada rota, garantindo que sua documentação está sempre atualizada. +`post` precisa ter de 1 a 140 caracteres, e `date` é obrigatório e precisa ser algo que o JavaScript consiga interpretar como data, como `'2026-10-25'`. O joi faz a conversão, então o handler recebe um objeto `Date`. Uma falha responde `400` com a mensagem `Invalid request payload input`. -O Hapi também suporta algumas opções para um ajuste fino na validação de saída. Aqui vão algumas dessas opções: +### Headers -### respose.failAction +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: (request, h) => { + return `Hello ${request.params.name}!`; + }, + options: { + validate: { + headers: Joi.object({ + cookie: Joi.string().required(), + }), + options: { + allowUnknown: true, + }, + }, + }, +}); +``` -Você pode escolher o que acontece quando sua validação falha, configurando `response.failAction` para uma das opções abaixo: +Navegadores e proxies adicionam muitos headers, então schemas de headers quase sempre precisam de `allowUnknown: true`. `validate.options` passa opções do joi para todos os schemas de entrada da rota. Os nomes dos headers ficam em minúsculas em `request.headers`, então escreva-os em minúsculas no schema. -- `error`: enviar um Erro Interno do Servidor (500) como resposta (padrão). -- `log`: apenas registre a violação e envie a resposta como ela está. +### Quando a validação falha -### response.sample +`validate.failAction` decide o que acontece: -Se performance é uma preocupação, é possível configurar para a validar apenas uma porcentagem das respostas. -Isso pode ser feito usando a propriedade `response.sample` do objeto `options` na rota. +| `failAction` | Resultado | +| ------------------ | --------------------------------------------------------------------------------------------------- | +| `'error'` (padrão) | `400` com uma mensagem genérica como `Invalid request query input` | +| `'log'` | registra um evento `request` com as tags `validation`, `error` e a origem, depois executa o handler | +| `'ignore'` | executa o handler | +| uma função | `(request, h, err)`: lance um erro ou retorne uma resposta de takeover | -### response.status +Com `'log'` e `'ignore'` o handler recebe o valor original, sem validação: `?limit=500` chega como a string `'500'`, e não como número. -Em alguns casos, uma rota pode servir respostas objetos de resposta diferentes. -Por exemplo. uma rota `POST` pode retornar uma das opções a seguir: +O `400` padrão esconde qual campo falhou, para que os clientes não consigam sondar seus schemas. Durante o desenvolvimento, ou em uma API em que os clientes precisam dos detalhes, relance o erro detalhado a partir de uma função `failAction`: -- `201` com o novo recurso criado, se um novo recurso for criado. -- `202` com os antigo e os novos valores se um recurso existente foi atualizado. +```ts +server.route({ + method: 'GET', + path: '/posts', + handler: (request, h) => posts, + options: { + validate: { + query: Joi.object({ + limit: Joi.number().integer().max(100), + }), + failAction: (request, h, err) => { + throw err; + }, + }, + }, +}); +``` -O Hapi suporta isso, permitindo você especificar um esquema de validação para cada _status code_. -`response.status` é um objeto onde as chaves são os números de _status codes_, e as propriedades são os esquemas do Joi: +Agora `/posts?limit=500` responde com a mensagem do joi e as chaves que falharam: -``` +```json { - response: { - status: { - 201: dataSchema, - 202: Joi.object({ original: dataSchema, updated: dataSchema }) - } - } + "statusCode": 400, + "error": "Bad Request", + "message": "\"limit\" must be less than or equal to 100", + "validation": { "source": "query", "keys": ["limit"] } } ``` -### response.options +Para aplicar um mesmo `failAction` a todas as rotas, defina-o na opção `routes.validate` do servidor. Veja [`failAction`](/api#failaction-configuration) na referência da API. -As opções passadas para o Joi durante a validação. Veja a documentação da API para mais detalhes. +## Validação de resposta -### Exemplo +`options.response` verifica o que o handler retorna, depois do handler e antes do `onPreResponse`. Use-a para garantir que um endpoint segue o contrato documentado. Plugins como o [hapi-swagger](https://github.com/glennjones/hapi-swagger) também leem esses schemas para documentar a saída de cada endpoint. -Aqui está um exemplo da configuração de uma rota que retorna uma lista de livros: - -```javascript +```ts const bookSchema = Joi.object({ title: Joi.string().required(), author: Joi.string().required(), @@ -184,26 +220,80 @@ const bookSchema = Joi.object({ server.route({ method: 'GET', path: '/books', + handler: async (request, h) => { + return await getBooks(); + }, options: { - handler: function (request, reply) { - getBooks((err, books) => { - if (err) { - return reply(err); - } - - return reply(books); - }); - }, response: { - sample: 50, schema: Joi.array().items(bookSchema), + failAction: 'log', + }, + }, +}); +``` + +| Opção | O que faz | +| ------------ | -------------------------------------------------------------------------------------------------------------- | +| `schema` | regra para todas as respostas, com os mesmos valores das regras de entrada | +| `status` | regras por status code, como `{ 201: created, 202: Joi.object({ original, updated }) }`; sobrescreve `schema` | +| `failAction` | `'error'` (padrão) responde `500`; também aceita `'log'`, `'ignore'` ou uma função | +| `sample` | porcentagem das respostas a validar, de `0` a `100`, para economizar CPU em rotas com muito tráfego | +| `modify` | quando `true`, o valor validado substitui a resposta, então as conversões do joi e o `stripUnknown` se aplicam | +| `options` | opções do joi para esses schemas, como `stripUnknown` | + +O que a validação de resposta ignora ou rejeita: + +- Respostas de erro (status `400` ou maior) não são validadas, a menos que `status` tenha um schema para aquele código. +- Só valores simples (objetos, arrays, strings) podem ser validados. Uma resposta de stream ou de arquivo com um schema de resposta falha com `500`. + +`modify: true` com `stripUnknown: true` é uma forma prática de evitar que campos como hashes de senha vazem de um endpoint: + +```ts +server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return { id: '2133d32a', name: 'John Doe', passwordHash: '$2a$10$...' }; + }, + options: { + response: { + schema: Joi.object({ + id: Joi.string(), + name: Joi.string(), + }).options({ stripUnknown: true }), + modify: true, + }, + }, +}); +``` + +O cliente recebe `{ "id": "2133d32a", "name": "John Doe" }`. + +## Validadores próprios + +Qualquer regra pode ser uma função `async (value, options)` em vez de um schema do joi: + +- Lance um erro para reprovar a validação. O `failAction` trata o erro. +- Retorne um valor para substituir o que está sendo validado. O original fica em `request.orig`. +- Não retorne nada para manter o valor como está. + +```ts +server.route({ + method: 'GET', + path: '/items', + handler: (request, h) => request.query, + options: { + validate: { + query: async (value) => { + if (typeof value !== 'object' || value === null || !('ids' in value) || typeof value.ids !== 'string') { + throw new Error('ids is required'); + } + + return { ids: value.ids.split(',') }; + }, }, }, }); ``` -Assim, será validado apenas metade das respostas (`sample: 50`). -Por quê, `response.failAction` não é especificado, o Hapi irá responder com `500` como código de erro, se algum dos livros não for exatamente válido para o esquema. -A resposta de erro _não_ indica a razão do erro. -Se os registros (logging) estiverem configurados, você será capaz de inspecionar os registros de erros sobre a causa da falha na validação da resposta. -Se `response.failAction` estiver configurada para `log`, então o Hapi irá responder com os dados originais, e registrar o erro na validação. +`/items?ids=1,2,3` responde `{ "ids": ["1", "2", "3"] }`. Um erro lançado vira um `400`, assim como uma falha do joi. diff --git a/docs/tutorials/tr_TR/validation.md b/docs/tutorials/tr_TR/validation.md index 6bc1cd5a..d0f2f409 100644 --- a/docs/tutorials/tr_TR/validation.md +++ b/docs/tutorials/tr_TR/validation.md @@ -1,148 +1,214 @@ --- -title: Validation +title: Doğrulama --- -## Doğrulama +# Doğrulama -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle, joi v16 ve sonraki sürümlerle uyumludur_ -Verileri doğrulamak, uygulamanın istikrarlı ve güvenli olmasına yardımcı olur. hapi bu fonksiyonelliği doğrulamalarını basit ve anlaşılır bir nesne söz dizimi kullanarak yapabileceğin [Joi](https://github.com/hapijs/joi) modülünü kullanarak sağlar. +## Genel Bakış -## Girdi (input) +hapi, handler'ın çalışmadan önce bir isteğin header'larını, path parametrelerini, query'sini, payload'unu ve cookie'lerini kontrol eder; yanıtı da gönderilmeden önce kontrol edebilir. Neyin izinli olduğunu genellikle bir [joi](https://joi.dev) şemasıyla tanımlarsın, hapi de geri kalan her şeyi reddeder. -hapinin icra ettiği doğrulamanın ilk çeşidi girdi doğrulamasıdır. Bu, bir yola (route) gönderilen `options` (seçenekler) nesnesinde tanımlanır ve başlıkları, güzergah değiştirgelerini, sorgu değiştirgelerini ve yararlı yük verisini doğrulamakta kullanılır. +## Joi -Haydi bir örneğe bakalım: +joi'yi kur ve import et: -```javascript -server.route({ +```sh +npm install joi +``` + +```ts +import Joi from 'joi'; +``` + +joi kendi TypeScript tiplerini içerir, bu yüzden ayrıca kurman gereken bir `@types` paketi yoktur. + +## Girdi doğrulama + +Girdi kuralları route'un `options.validate` alanına yazılır. hapi bunları kimlik doğrulamadan sonra ve handler'dan önce şu sırayla çalıştırır ve başarısız olan ilk kuralda durur (bkz. [failAction](#failaction)): + +```mermaid +flowchart TD + A[onPostAuth] --> H[validate.headers] + H --> P[validate.params] + P --> Q[validate.query] + Q --> B[validate.payload] + B --> S[validate.state] + S --> X[onPreHandler, handler] +``` + +| Anahtar | Doğruladığı | Başarılı olunca yerine konan | +| --------- | ---------------------------- | ---------------------------- | +| `headers` | `request.headers` | `request.headers` | +| `params` | `request.params` | `request.params` | +| `query` | `request.query` | `request.query` | +| `payload` | `request.payload` | `request.payload` | +| `state` | `request.state` (cookie'ler) | `request.state` | + +Her anahtar şunları kabul eder: + +| Değer | Anlamı | +| -------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `true` ya da ayarlanmamış (varsayılan) | her şeye izin verilir | +| `false` | hiçbir değere izin verilmez | +| bir joi şeması | değer şemaya uymalıdır | +| bir fonksiyon | kendi doğrulayıcın, bkz. [Özel doğrulayıcılar](#alternatives) | +| joi şemalarından oluşan düz bir nesne | yalnızca `server.validator(Joi)` sonrasında izinlidir; aksi halde `server.route()` hata fırlatır | + +Doğrulama, istekteki değeri doğrulanmış değerle değiştirir; böylece joi'nin tip dönüşümleri ve varsayılan değerleri handler'ına ulaşır. `?limit=5` query'si `'5'` string'i olarak gelir ve `Joi.number()` sonrasında handler `5` sayısını görür. Orijinal değer [`request.orig`](/api#request-orig) üzerinde kalır. + +### Path parametreleri + +```ts +server.route<{ Params: { name: string } }>({ method: 'GET', - path: '/selam/{name}', - handler: function (request, h) { - return `Selam ${request.params.name}!`; + path: '/hello/{name}', + handler: (request, h) => { + return `Hello ${request.params.name}!`; }, options: { validate: { - params: { + params: Joi.object({ name: Joi.string().min(3).max(10), - }, + }), }, }, }); ``` -### Guzergah degistirgeleri - -Burada görüldüğü üzere `options` (seçenekler) nesnesine `validate.params` (değiştirgeleri doğrula) seçeneği gönderdik. Hapiye istenilen güzergahta bulunan adlandırılmış değiştirgelerin doğrulanması gerektiğini böyle söylüyoruz. Joi'nin döz dizimi çok basit ve okuması da kolay. Burada gönderdiğimiz doğrulama değiştirgenin en az 3 en fazla 10 karakterden oluşan bir metin olduğunu kontrol ediyor. - -Eğer bu yapılandırma ile `/selam/aylin` adresine bir istekte bulunursak beklediğimiz üzere `Selam aylin!` yanıtını alırız. Ancak isteği `/selam/a`ya yaparsak aşağıdaki gibi bir HTTP `400` yanıtı alırız. +`/hello/jennifer` isteği `Hello jennifer!` yanıtını döner. `/hello/a` ve `/hello/thisnameiswaytoolong` ise şu yanıtı alır: ```json { + "statusCode": 400, "error": "Bad Request", - "message": "Invalid request params input", - "statusCode": 400 + "message": "Invalid request params input" } ``` -Benzeri şekilde, eğer isteği `/selam/adimokadaruzunkianlatamam` adresine yapmış olsaydık da aynı hatayı alacaktık. - -### Sorgu degistirgeleri - -Sorgu değiştirgelerini doğrulamak için `validate.query` (sorguyu doğrula) seçeneğini gönderiyoruz ve benzeri sonuçlar elde ediyoruz. Varsayılan olarak hapi hiç bir şeyi doğrulamaz. Eğer değiştirgelerden biri için bile doğrulayıcı belirlersen bu demektir ki kabul etmek istediğin mümkün olan tüm sorgu değiştirgileri için doğrulayıcı sağlamak _zorundasın_. +### Query parametreleri -Örneğin, eğer kullanıcı blog yazılarının listesini dönen bir güzergahın varsa ve kullanıcıların sonuç kümesine sayı sınırı uygulamasını istiyorsan aşağıdaki yapılandırmayı kullanabilirsin: - -```javascript -server.route({ +```ts +server.route<{ Query: { limit: number } }>({ method: 'GET', path: '/posts', - handler: function (request, h) { + handler: (request, h) => { return posts.slice(0, request.query.limit); }, options: { validate: { - query: { + query: Joi.object({ limit: Joi.number().integer().min(1).max(100).default(10), - }, + }), }, }, }); ``` -Bu `limit` sorgu değiştirgesinin değerinin her zaman 1 ila 100 arasında bir tamsayı olmasını güvence altına alır, ve eğer sağlanmazsa varsayılan olarak 10 kullanır. Ancak eğer `/posts?limit=15&offset=15` adresine istekte bulunursak bir başka `400` yanıtı ve hatası alırız. - -Hata aldık çünkü `offset` değiştirgesine izin verilmiyor. Bunun da sebebi `limit` değiştirgesi için bir doğrulama yaparken diğeri için hiç bir şey tanımlamamış olmamız. - -### Basliklar - -Gelen başlıkları da aynı şekilde doğrulayabilirsin. `validate.headers` (başlıkları doğrula) kullan. - -### Yararli yuk (payload) degistirgeleri - -`validate.payload` (yararlı yükü doğrula) seçeneği de geçerlidir ve kullanıcı tarafından yola gönderilen yararlı yükü doğrular. Aynı sorgu değiştirgelerinde çalıştığı gibi çalışır ve evet: bir anahtarı doğruluyorsan hepsini doğrulamalısın. +- `limit` 1 ile 100 arasında bir tam sayı olmalıdır; istemci onu göndermezse varsayılan değer 10'dur. +- `/posts?limit=15&offset=15` isteği `400` ile başarısız olur: bir `Joi.object()` şeması listelemediği anahtarları reddeder. Kabul ettiğin her parametreyi listele ya da diğer anahtarları geçirmek için şemaya `.unknown()` ekle. +- `Query: { limit: number }` tip argümanı TypeScript'e handler'ın ne alacağını söyler. hapi bunu joi şemasından çıkarmaz, bu yüzden ikisini senkron tutmak sana kalır. -## Çıktı +### Payload -hapi istemciye göndermeden önce yanıtı da doğrulayabilir. -Bu doğrulama seçenekler nesnesinin `response` (yanıt) özelliğinde tanımlanır. - -Eğer bir yanıt, yanıt doğrulamasını geçemezse istemci varsayılan olarak 500, iç sunucu hatası (Internal Server Error) alır (aşağıda bulunan `response.failAction` kısmına bakın). - -Çıktı doğrulama, APIının dokümantasyon ya da kontratında belirtilen maddelere uygun şekilde veri sunduğunundan emin olmak için çok elverişlidir. - -Buna ek olarak, [hapi-swagger](https://github.com/glennjones/hapi-swagger) ve [lout](https://github.com/hapijs/lout) çıktı doğrulama şemasını otomatik olarak çıktı noktalarını dokümante etmek için kullandığından, dokümantasyonunun her zaman güncel olduğundan emin olmanı da sağlar. +```ts +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().min(1).max(140), + date: Joi.date().required(), + }), + }, + }, +}); +``` -hapi çıktı doğrulamasına ince ayar yapmak için bir çok seçenek sunar. İşte bir kaçı: +`post` 1 ile 140 karakter arasında olmalıdır; `date` zorunludur ve JavaScript'in tarih olarak ayrıştırabileceği bir değer olmalıdır, örneğin `'2026-10-25'`. joi onu dönüştürür, böylece handler bir `Date` nesnesi alır. Başarısız olursa `Invalid request payload input` mesajıyla `400` döner. ---- +### Header'lar -### response.failAction +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: (request, h) => { + return `Hello ${request.params.name}!`; + }, + options: { + validate: { + headers: Joi.object({ + cookie: Joi.string().required(), + }), + options: { + allowUnknown: true, + }, + }, + }, +}); +``` -`response.failAction`ı aşağıdakilerden birine ayarlayarak yanıt doğrulama başarısız olduğunda ne olacağına karar verebilirsin: +Tarayıcılar ve proxy'ler çok sayıda header ekler, bu yüzden header şemaları neredeyse her zaman `allowUnknown: true` gerektirir. `validate.options`, joi seçeneklerini route'taki tüm girdi şemalarına aktarır. Header adları `request.headers` içinde küçük harflidir, bu yüzden onları şemada da küçük harfle yaz. -- `error`: bir İç Sunucu Hatası (500) gönder (varsayılan) -- `log`: Sıkıntıyı günlükle ve yanıtı olduğu gibi gönder -- `ignore`: görmezden gel ve isteği işlemeye devam et -- İmzası `async function(request, h, err)` olan bir yaşam göngüsü yöntemi. İmzada bulunan değiştirgelerden `request` istek nesnesi, `h` yanıt alet takımı ve `err` doğrulama hatası olarak gelir +### Doğrulama başarısız olduğunda -### response.sample +Ne olacağına `validate.failAction` karar verir: -Eğer performans kaygın varsa, hapi yanıtların yalnızca bir kısmını doğrulamak için ayarlanabilir. Bunun için yol (route) yapılandırmasında bununan `response.sample` özelliği kullanılmalıdır. Doğrulanması gereken yanıtların yüzde ne kadarının doğrulanacağını temsil eden `0` ile `100` arasında bir değer verilmelidir. +| `failAction` | Sonuç | +| ---------------------- | ----------------------------------------------------------------------------------------------------------- | +| `'error'` (varsayılan) | `Invalid request query input` gibi genel bir mesajla `400` | +| `'log'` | `validation`, `error` ve kaynak tag'lerini taşıyan bir `request` olayını loglar, sonra handler'ı çalıştırır | +| `'ignore'` | handler'ı çalıştırır | +| bir fonksiyon | `(request, h, err)`: bir hata fırlat ya da bir takeover yanıtı döndür | -### response.status +`'log'` ve `'ignore'` ile handler orijinal, doğrulanmamış değeri alır: `?limit=500` sayı olarak değil `'500'` string'i olarak gelir. -Bazen bir uç noktada (endpoint) birden fazla yanıt nesnesi sunulur. -Örneğin bir `POST` yolu (route) aşağıdakilerden birini dönebilir: +Varsayılan `400`, hangi alanın başarısız olduğunu gizler; böylece istemciler şemalarını yoklayamaz. Geliştirme sırasında ya da istemcilerin ayrıntılara ihtiyaç duyduğu bir API'de, ayrıntılı hatayı bir `failAction` fonksiyonundan yeniden fırlat: -- `201` durum kodu ile birlikte yeni yaratılan kaynak -- `202` durum kodu ile birlikte güncellenen kaynağın eski ve yeni değerleri +```ts +server.route({ + method: 'GET', + path: '/posts', + handler: (request, h) => posts, + options: { + validate: { + query: Joi.object({ + limit: Joi.number().integer().max(100), + }), + failAction: (request, h, err) => { + throw err; + }, + }, + }, +}); +``` -hapi bunun üstesinden her yanıt durum kodu (response status code) için farklı bir doğrulama şeması vermeni sağlayarak gelir. -`response.status` anahtarları sayısal durum kodları olan bir nesnedir ve özellikleri joi şemalarıdır. +Artık `/posts?limit=500` isteği joi'nin mesajı ve başarısız anahtarlarla yanıt verir: -```json5 +```json { - response: { - status: { - 201: dataSchema, - 202: Joi.object({ original: dataSchema, updated: dataSchema }) - } - } + "statusCode": 400, + "error": "Bad Request", + "message": "\"limit\" must be less than or equal to 100", + "validation": { "source": "query", "keys": ["limit"] } } ``` -### response.options - -joiye doğrulama sırasında gönderilecek seçenekler. Detaylı bilgi için [API dokümantasyonu](/api#-routeoptionsresponseoptions)na bak. +Tek bir `failAction`'ı tüm route'lara uygulamak için onu sunucunun `routes.validate` seçeneğinde ayarla. API referansındaki [`failAction`](/api#failaction-configuration) bölümüne bak. -### Örnek +## Yanıt doğrulama -Burada örnek olarak kitap listesi dönen bir yol (route) yapılandırması var: +`options.response`, handler'ın döndürdüğü değeri handler'dan sonra ve `onPreResponse`'tan önce kontrol eder. Bir endpoint'in belgelenmiş sözleşmesine uyduğundan emin olmak için bunu kullan. [hapi-swagger](https://github.com/glennjones/hapi-swagger) gibi plugin'ler de her endpoint'in çıktısını belgelemek için bu şemaları okur. -```javascript +```ts const bookSchema = Joi.object({ title: Joi.string().required(), author: Joi.string().required(), @@ -154,30 +220,80 @@ const bookSchema = Joi.object({ server.route({ method: 'GET', path: '/books', - handler: async function (request, h) { + handler: async (request, h) => { return await getBooks(); }, options: { response: { - sample: 50, schema: Joi.array().items(bookSchema), + failAction: 'log', }, }, }); ``` -Yanıtların yarısını (`sample: 50`) doğrulayacak. -`response.failAction` vermediğimizden, eğer kitaplardan biri `bookSchema`ya (kitap şeması) uymazsa hapi `500` hata kodu verir. -Hata yanıtı hatanın sebebini _göstermez_. -Eğer günlükleme yapılandırdıysan, yanıt doğrulamanın hata vermesine neyin sebep oılduğunu inceleyebilirsin. -Eğer `response.failAction` `log` olarak ayarlansaydı, hapi orjinal yanıtla cevap verirken doğrulama hatasını da günlükleyebilirdi. +| Seçenek | Ne yapar | +| ------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `schema` | her yanıt için kural; girdi kurallarıyla aynı değerleri alır | +| `status` | durum kodu başına kurallar, örneğin `{ 201: created, 202: Joi.object({ original, updated }) }`; `schema`'yı geçersiz kılar | +| `failAction` | `'error'` (varsayılan) `500` yanıtı verir; ayrıca `'log'`, `'ignore'` ya da bir fonksiyon olabilir | +| `sample` | yoğun route'larda CPU'dan tasarruf etmek için doğrulanacak yanıtların yüzdesi, `0` ile `100` arası | +| `modify` | `true` olduğunda doğrulanmış değer yanıtın yerine geçer, böylece joi dönüşümleri ve `stripUnknown` uygulanır | +| `options` | bu şemalar için joi seçenekleri, örneğin `stripUnknown` | -### Joi Alternatifleri +Yanıt doğrulamanın atladıkları ya da reddettikleri: -Doğrulama için Joi kullanmanızı tavsiye ediyoruz ancak hapinin sunduğu her doğrulama seçeneği aynı zamanda bir kaç farklı seçenek de kabul eder. +- Hata yanıtları (`400` ve üzeri durum kodları), `status` o kod için bir şema içermedikçe doğrulanmaz. +- Yalnızca düz değerler (nesneler, diziler, string'ler) doğrulanabilir. Yanıt şeması olan bir stream ya da dosya yanıtı `500` ile başarısız olur. -En basitinden her seçenek bir mantıksal operatörle ayarlanabilir. Varsayılan olarak var olan tüm doğrulayıcılar `true` olarak ayarlanmıştır ki bu durumda hiç bir doğrulama yapılmaz. +`modify: true` ile `stripUnknown: true` birlikte, parola hash'leri gibi alanların bir endpoint'ten dışarı sızmasını engellemenin pratik bir yoludur: -Doğrulama değiştirgeleri `false` olarak ayarlanırsa bu ayarlanan değiştirgelerin hiç bir değer almasına izin verilmediği anlamına gelir. +```ts +server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return { id: '2133d32a', name: 'John Doe', passwordHash: '$2a$10$...' }; + }, + options: { + response: { + schema: Joi.object({ + id: Joi.string(), + name: Joi.string(), + }).options({ stripUnknown: true }), + modify: true, + }, + }, +}); +``` + +İstemci `{ "id": "2133d32a", "name": "John Doe" }` alır. + +## Özel doğrulayıcılar + +Her kural, joi şeması yerine `async (value, options)` biçiminde bir fonksiyon olabilir: + +- Doğrulamayı başarısız kılmak için bir hata fırlat. Onu `failAction` ele alır. +- Doğrulanan değerin yerine geçecek bir değer döndür. Orijinal değer `request.orig` üzerinde kalır. +- Değeri değiştirmeden bırakmak için hiçbir şey döndürme. + +```ts +server.route({ + method: 'GET', + path: '/items', + handler: (request, h) => request.query, + options: { + validate: { + query: async (value) => { + if (typeof value !== 'object' || value === null || !('ids' in value) || typeof value.ids !== 'string') { + throw new Error('ids is required'); + } + + return { ids: value.ids.split(',') }; + }, + }, + }, +}); +``` -Bu değiştirgeler aynı zamanda `value`su (değeri) doğrulanacak veri ve `options`ı (seçenekleri) sunucu nesnesinde belirlenen doğrulama seçenekleri olan `async function (value, options)` imzasına sahip özel bir işlev de olabilir. Eğer bu işlev bir değer dönerse, doğrulanan orjinal nesneyi değiştirir. Örneğin, `request.headers`ı doğruluyorsan dönen değer `request.headers`ı ve `request.orig.headers`i değiştirir. Değer dönmezsen hiç birine dokunulmaz. Eğer bir hata fırlatılırsa hata `failAction`da belirtildiği üzere işlenir. +`/items?ids=1,2,3` isteği `{ "ids": ["1", "2", "3"] }` yanıtını döner. Fırlatılan bir hata, joi hatasında olduğu gibi `400`'e dönüştürülür. diff --git a/docs/tutorials/zh_CN/validation.md b/docs/tutorials/zh_CN/validation.md index f2005edc..c1d71e75 100644 --- a/docs/tutorials/zh_CN/validation.md +++ b/docs/tutorials/zh_CN/validation.md @@ -1,141 +1,214 @@ --- -title: Validation +title: 验证 --- -## 数据验证 +# 验证 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更高版本,以及 joi v16 及更高版本_ -对于你的应用来说,数据验证可以保证应用的稳定和安全。 hapi 通过模块 [Joi](https://github.com/hapijs/joi) 帮助你实现更为简洁清晰的验证代码。 +## 概述 -## 输入 +hapi 会在你的 handler 运行之前检查请求头、路径参数、查询参数、payload 和 cookie,也可以在响应发送之前检查它。你描述哪些内容是允许的,通常用一个 [joi](https://joi.dev) schema,其余的一律由 hapi 拒绝。 -第一种验证类型是输入验证,它可以通过路由的 `options` 进行定义。 它可以验证 HTTP 头信息、路径参数、查询参数以及请求的内容。 +## Joi -示例代码如下: +安装并导入 joi: -```javascript -server.route({ +```sh +npm install joi +``` + +```ts +import Joi from 'joi'; +``` + +joi 自带 TypeScript 类型,所以无需另外安装 `@types` 包。 + +## 输入验证 + +输入规则写在路由的 `options.validate` 中。hapi 在身份认证之后、handler 之前按以下顺序执行它们,并在第一个失败的规则处停止(参见 [failAction](#failaction)): + +```mermaid +flowchart TD + A[onPostAuth] --> H[validate.headers] + H --> P[validate.params] + P --> Q[validate.query] + Q --> B[validate.payload] + B --> S[validate.state] + S --> X[onPreHandler,handler] +``` + +| 键 | 验证对象 | 验证成功后替换 | +| --------- | ------------------------- | ----------------- | +| `headers` | `request.headers` | `request.headers` | +| `params` | `request.params` | `request.params` | +| `query` | `request.query` | `request.query` | +| `payload` | `request.payload` | `request.payload` | +| `state` | `request.state`(cookie) | `request.state` | + +每个键可以接受: + +| 值 | 含义 | +| ---------------------------- | ------------------------------------------------------------------------------- | +| `true` 或不设置(默认) | 允许任何值 | +| `false` | 不允许任何值 | +| joi schema | 值必须符合该 schema | +| 函数 | 你自己的验证器,参见[自定义验证器](#alternatives) | +| 由 joi schema 组成的普通对象 | 只有在调用 `server.validator(Joi)` 之后才允许;否则 `server.route()` 会抛出错误 | + +验证会用验证后的值替换请求中的值,因此 joi 的类型转换和默认值会传到你的 handler 中。查询 `?limit=5` 到达时是字符串 `'5'`,经过 `Joi.number()` 之后,handler 看到的是数字 `5`。原始值保留在 [`request.orig`](/api#request-orig) 上。 + +### 路径参数 + +```ts +server.route<{ Params: { name: string } }>({ method: 'GET', path: '/hello/{name}', - handler: function (request, h) { + handler: (request, h) => { return `Hello ${request.params.name}!`; }, options: { validate: { - params: { + params: Joi.object({ name: Joi.string().min(3).max(10), - }, + }), }, }, }); ``` -### 路径参数 - -正如同上例,我们在 `option` 对象中添加 `validate.params` 来完成这个操作,这就是 hapi 路径参数验证的配置。 Joi 的语法非常简介并且易读, 以上代码将会验证参数字符串的长度,其最小长度为3,最大长度为10。 - -通过以上配置,如果发送请求 `/hello/jennifer` 将会得到期望的 `Hello jennifer!` 返回。 而请求 `/hello/a` 将会得到如下 HTTP `400` 响应错误: +`/hello/jennifer` 返回 `Hello jennifer!`。`/hello/a` 和 `/hello/thisnameiswaytoolong` 返回: ```json { + "statusCode": 400, "error": "Bad Request", - "message": "Invalid request params input", - "statusCode": 400 + "message": "Invalid request params input" } ``` -同样,如果我们发送请求 `/hello/thisnameiswaytoolong` 也会得到相同的错误。 - -### 查询参数 - -验证查询参数我们只需要在路由选项中配置 `validate.query`。默认情况下 hapi 不做任何验证,但是如果一但为一个查询参数添加验证时,这将意味着你 _必须_ 为你所接受的所有查询参数添加验证。 +### 查询参数 -例如,一个路由返回 blog 列表,并且你希望限制用户的请求条目的数量,你可以通过以下配置来完成: - -```javascript -server.route({ +```ts +server.route<{ Query: { limit: number } }>({ method: 'GET', path: '/posts', - handler: function (request, h) { + handler: (request, h) => { return posts.slice(0, request.query.limit); }, options: { validate: { - query: { + query: Joi.object({ limit: Joi.number().integer().min(1).max(100).default(10), - }, + }), }, }, }); ``` -这里 `limit` 查询参数将被限定在 1 与 100之间,如果没有指定这个参数的时候将会有一个为 10 的默认值。当我们发送请求 `/posts?limit=15&offset=15` 将会获得一个 HTTP `400` 响应错误。 - -之所以得到这个错误是因为 `offset` 参数没有设置验证,因为我们只提供了 `limit` 参数的验证。 - -### Headers - -你可以通过 `validate.headers` 选项验证 HTTP 头部信息。 - -### Payload 参数 - -可以通过 `validate.payload` 选项验证 payload参数。 如同查询参数一样,你必须验证所有的内容。 +- `limit` 必须是 1 到 100 之间的整数,客户端不传时默认为 10。 +- `/posts?limit=15&offset=15` 会以 `400` 失败:`Joi.object()` schema 会拒绝它没有列出的键。要么列出你接受的每个参数,要么给 schema 加上 `.unknown()` 让其他键通过。 +- 类型参数 `Query: { limit: number }` 告诉 TypeScript handler 收到的是什么。hapi 不会从 joi schema 推断类型,所以你需要自己保持两者一致。 -## 输出 +### Payload -hapi 可以在返回之前验证响应信息,这个定义在路由 `options` 对象中的 `response`属性内。 - -如果一个响应没有通过验证,客户端将会收到一个默认的 (500) 内部服务错误 (参见 `response.failAction` 如下)。 +```ts +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().min(1).max(140), + date: Joi.date().required(), + }), + }, + }, +}); +``` -输出验证对于服务是否满足文档规定的数据格式很有帮助。除此之外如同 [hapi-swagger](https://github.com/glennjones/hapi-swagger) 和 [lout](https://github.com/hapijs/lout) 之类的插件会根据验证规则自动生成文档,这可以确保我们的文档总是最新的。 +`post` 必须是 1 到 140 个字符,`date` 是必填项,并且必须是 JavaScript 能解析为日期的值,例如 `'2026-10-25'`。joi 会对它做转换,所以 handler 收到的是一个 `Date` 对象。验证失败时返回 `400`,消息为 `Invalid request payload input`。 -hapi 支持多种选项来调整输出验证,这里简单列举一下: +### 请求头 -### response.failAction +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: (request, h) => { + return `Hello ${request.params.name}!`; + }, + options: { + validate: { + headers: Joi.object({ + cookie: Joi.string().required(), + }), + options: { + allowUnknown: true, + }, + }, + }, +}); +``` -你可以通过以下 `response.failAction` 的设置来修改验证失败的行为: +浏览器和代理会添加很多请求头,所以 请求头 schema 几乎总是需要 `allowUnknown: true`。`validate.options` 会把 joi 选项传给该路由上的所有输入 schema。`request.headers` 中的请求头名称都是小写的,所以在 schema 中也要用小写书写。 -- `error`: 发送内部服务器错误 (500) 请求 (默认) -- `log`: 只记录错误的日志并按原样返回 -- `ignore`: 继续处理请求而不做任何事情 -- 或者一个带有签名 `async function(request, h, err)` 的方法,其中 `request` 是请求对象, `h` 是 response toolkit ,`err` 是验证错误。 +### 验证失败时 -### response.sample +由 `validate.failAction` 决定会发生什么: -如果你担心性能问题,hapi 可以通过百分比配置只验证一部分请求。这个配置在路由 `config` 对象的 `response.sample`对象中。你可以指定一个`0`到`100`之间的数字以用来指定验证请求的百分比。 +| `failAction` | 结果 | +| ----------------- | -------------------------------------------------------------------------------- | +| `'error'`(默认) | 返回 `400` 和通用消息,例如 `Invalid request query input` | +| `'log'` | 记录一个带有 `validation`、`error` 和来源标签的 `request` 事件,然后运行 handler | +| `'ignore'` | 直接运行 handler | +| 函数 | `(request, h, err)`:抛出错误,或者返回一个接管响应(takeover response) | -### response.status +使用 `'log'` 和 `'ignore'` 时,handler 收到的是原始的、未经验证的值:`?limit=500` 到达时是字符串 `'500'`,而不是数字。 -有时候同样的endpoint会有不同的响应类型,比如一个`POST`路由可能会返回以下内容: +默认的 `400` 不会透露是哪个字段失败了,这样客户端就无法探测你的 schema。在开发阶段,或者客户端需要详细信息的 API 上,可以在 `failAction` 函数中重新抛出详细的错误: -- `201` 代表一个新的资源被创建。 -- `202` 代表一个资源被更新。 +```ts +server.route({ + method: 'GET', + path: '/posts', + handler: (request, h) => posts, + options: { + validate: { + query: Joi.object({ + limit: Joi.number().integer().max(100), + }), + failAction: (request, h, err) => { + throw err; + }, + }, + }, +}); +``` -hapi 可以通过不同的验证模式对此进行支持。`response.status` 是一个以返回码作为Key的对象, 它的属性是 joi 模式: +现在 `/posts?limit=500` 会返回 joi 的消息以及失败的键: -```json5 +```json { - response: { - status: { - 201: dataSchema, - 202: Joi.object({ original: dataSchema, updated: dataSchema }) - } - } + "statusCode": 400, + "error": "Bad Request", + "message": "\"limit\" must be less than or equal to 100", + "validation": { "source": "query", "keys": ["limit"] } } ``` -### response.options - -选项可以在验证的时候传递给 joi。 更多细节请参阅 [API docs](/api#-routeoptionsresponseoptions)。 +要把同一个 `failAction` 应用到所有路由,请在服务器的 `routes.validate` 选项中设置它。参见 API 参考中的 [`failAction`](/api#failaction-configuration)。 -### 示例 +## 响应验证 -这里是一个返回图书列表的路由配置: +`options.response` 会在 handler 之后、`onPreResponse` 之前检查 handler 的返回值。用它来确保接口符合文档中约定的契约。[hapi-swagger](https://github.com/glennjones/hapi-swagger) 这类插件也会读取这些 schema,为每个接口的输出生成文档。 -```javascript +```ts const bookSchema = Joi.object({ title: Joi.string().required(), author: Joi.string().required(), @@ -147,26 +220,80 @@ const bookSchema = Joi.object({ server.route({ method: 'GET', path: '/books', - handler: async function (request, h) { + handler: async (request, h) => { return await getBooks(); }, options: { response: { - sample: 50, schema: Joi.array().items(bookSchema), + failAction: 'log', }, }, }); ``` -这里只验证一半的请求 (`sample: 50`)。如果 `books` 不精确匹配 `bookSchema`时,之所以 hapi 返回 `500` 错误码,是因为 `response.failAction` 没有被定义。与此同时,错误请求 _不_ 再能鉴别错误的原因。但是如果你配置了日志, 你依然可以通过查阅日志来发现验证错误的具体原因。如果 `response.failAction` 设置为 `log`, hapi 将会返回原始的内容信息,并将验证错误记录在日之内。 +| 选项 | 作用 | +| ------------ | ---------------------------------------------------------------------------------------------------- | +| `schema` | 适用于所有响应的规则,取值与输入规则相同 | +| `status` | 按状态码设置的规则,例如 `{ 201: created, 202: Joi.object({ original, updated }) }`;会覆盖 `schema` | +| `failAction` | `'error'`(默认)返回 `500`;也可以是 `'log'`、`'ignore'` 或函数 | +| `sample` | 要验证的响应百分比,`0` 到 `100`,用于在繁忙路由上节省 CPU | +| `modify` | 为 `true` 时,验证后的值会替换响应,因此 joi 的转换和 `stripUnknown` 会生效 | +| `options` | 这些 schema 使用的 joi 选项,例如 `stripUnknown` | -### 除Joi之外的选择 +响应验证会跳过或拒绝的情况: -我们建议你使用 Joi 做验证, 但是 hapi 也为每个错误验证提供了一些不同的选项。 +- 错误响应(状态码 `400` 及以上)不会被验证,除非 `status` 中为该状态码设置了 schema。 +- 只有普通值(对象、数组、字符串)可以被验证。为流或文件响应设置响应 schema 会以 `500` 失败。 -最简单的你可以使用一个 boolean 验证器。默认情况下所有可用的验证都设置为 `true` 它表示不会做任何验证。 +`modify: true` 搭配 `stripUnknown: true`,是防止密码哈希这类字段从接口泄露出去的实用办法: -如果验证的参数设置为 `false` 它表示该参数不允许任何值。 +```ts +server.route({ + method: 'GET', + path: '/me', + handler: (request, h) => { + return { id: '2133d32a', name: 'John Doe', passwordHash: '$2a$10$...' }; + }, + options: { + response: { + schema: Joi.object({ + id: Joi.string(), + name: Joi.string(), + }).options({ stripUnknown: true }), + modify: true, + }, + }, +}); +``` + +客户端收到的是 `{ "id": "2133d32a", "name": "John Doe" }`。 + +## 自定义验证器 + +任何规则都可以是一个函数 `async (value, options)`,而不是 joi schema: + +- 抛出错误表示验证失败,由 `failAction` 处理。 +- 返回一个值,用它替换被验证的值。原始值保留在 `request.orig` 上。 +- 什么都不返回,则保持原值不变。 + +```ts +server.route({ + method: 'GET', + path: '/items', + handler: (request, h) => request.query, + options: { + validate: { + query: async (value) => { + if (typeof value !== 'object' || value === null || !('ids' in value) || typeof value.ids !== 'string') { + throw new Error('ids is required'); + } + + return { ids: value.ids.split(',') }; + }, + }, + }, +}); +``` -你也可以传递一个自定义拥有`async function (value, options)`签名的函数, `value` 代表要被验证的数据 `options` 是服务器对象内定义的验证的选项。当一个值返回时,这个值将会替换被验证的原始对象。 例如,你正在验证 `request.headers`,返回后的值将会代替 `request.headers`,原始的值将会被保留在 `request.orig.headers`。如果没有返回 headers 将保持不变。 如果发生了错误, 可以通过 `failAction` 进行错误处理。 +`/items?ids=1,2,3` 返回 `{ "ids": ["1", "2", "3"] }`。抛出的错误会变成 `400`,与 joi 验证失败时一样。 From fa499b1459b9ccf17d5078738b1e899937055a23 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:57 -0400 Subject: [PATCH 16/22] docs: rework views tutorial TypeScript examples, diagrams and tables in place of long prose, and translations updated. --- docs/tutorials/en_US/views.md | 355 ++++++++++++++-------------- docs/tutorials/es_ES/views.md | 310 +++++++++++++++++++++++++ docs/tutorials/ko_KR/views.md | 403 ++++++++++++++++---------------- docs/tutorials/pt_BR/views.md | 409 ++++++++++++++++---------------- docs/tutorials/tr_TR/views.md | 425 +++++++++++++++++----------------- docs/tutorials/zh_CN/views.md | 403 ++++++++++++++++---------------- 6 files changed, 1295 insertions(+), 1010 deletions(-) create mode 100644 docs/tutorials/es_ES/views.md diff --git a/docs/tutorials/en_US/views.md b/docs/tutorials/en_US/views.md index a7bf4ee2..bfda19ea 100644 --- a/docs/tutorials/en_US/views.md +++ b/docs/tutorials/en_US/views.md @@ -6,162 +6,182 @@ title: Views # Views -_This tutorial is compatible with hapi v17 and newer_ +_This tutorial is compatible with hapi v21 and newer_ ## Overview -hapi has extensive support for template rendering, including the ability to load and leverage multiple templating engines, partials, helpers (functions used in templates to manipulate data), and layouts. These capabilities are provided by the [vision](/module/vision) plugin. +hapi renders HTML templates through the [vision](/module/vision) plugin. Vision works with any template engine that has a `compile` function, including handlebars, pug, and ejs (engines with a different `compile`, such as nunjucks, need the small wrapper described under [Engines](#engines)), and supports partials, helpers, and layouts. ## Vision -[Vision](/module/vision) is a templates rendering plugin for hapi.js. `Vision` decorates the `server`, `request`, and `h` response toolkit interfaces with additional methods for managing view engines that can be used to render templated responses. `Vision` also provides a built-in handler implementation for creating templated responses. +Install vision and an engine: -`Vision` is compatible with most major templating engines out of the box, such as ejs, handlebars, pug, twig, etc. Engines that don't follow the normal API pattern can still be used by mapping their API to the [vision API](/module/vision). +```sh +npm install @hapi/vision handlebars +``` -For more info, please see [here](/module/vision). +Registering vision adds: -## Configuring the server +| What | Use it for | +| ------------------------------------- | ---------------------------------------------------------- | +| `server.views(options)` | configuring engines, template folders, layouts | +| `h.view(template, context)` | rendering a template from a handler | +| `view` handler | a route that only renders a template | +| `server.render()`, `request.render()` | rendering a template to a string, for example for an email | -To get started with views, first you have to configure at least one templating engine on the server. This is done by using the [`server.views()`](/module/vision/api#serverviewsoptions) method provided by `vision`: +Importing `@hapi/vision` also adds these to hapi's TypeScript types. The full API is in the [vision API reference](/module/vision). -```javascript -'use strict'; +## Configuring the server -const Path = require('path'); -const Hapi = require('@hapi/hapi'); -const Hoek = require('@hapi/hoek'); +```ts +import Hapi from '@hapi/hapi'; +import Vision from '@hapi/vision'; +import Handlebars from 'handlebars'; -const start = async () => { - const server = Hapi.server(); +const server = Hapi.server({ port: 3000 }); - await server.register(require('@hapi/vision')); +await server.register(Vision); - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - }); +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', +}); - await server.start(); -}; +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); -start(); +await server.start(); ``` -You're doing several things here. First, you load the [vision](/module/vision) module as a plugin. It adds template rendering support to hapi. - -Secondly, you register the `handlebars` module as the engine responsible for rendering templates with an extension of `.html`. +- `engines` maps a file extension to an engine: files ending in `.html` render with handlebars. +- `path` is the folder that holds the templates, resolved from `relativeTo`. ES modules have no `__dirname`; `import.meta.dirname` is the folder of the current file. +- `h.view('index', ...)` renders `templates/index.html`. With one engine registered, you can leave off the extension. -Next, you tell the server that your templates are located in the `templates` directory. You indicate that this directory should be taken relative to the current directory by providing a `relativeTo` option. By default, hapi will look for templates relative to the current working directory. +### How a view is rendered -## server.views() Options +`h.view()` does not render anything right away. It returns a view response, and vision renders it as hapi sends the response: -There are many options available to the views engine in hapi. Full documentation can be found in the [vision API reference](/module/vision/api#serverviewsoptions), but we'll go over some of them here as well. - -Note that all options may be set either globally, which configures them for all registered engines, or local to one specific engine, for example: +```mermaid +flowchart TD + A["handler returns h.view('index', context)"] --> B["vision finds and compiles
templates/index.html (cached)"] + B --> C[onPreResponse hooks] + C --> D["merge the global context
with the view's context"] + D --> E[render the template] + E --> F[wrap it in the layout] + F --> G["send as text/html"] +``` -```javascript +What this order means in practice: + +- A missing template fails with a `500` as soon as the handler returns, before any `onPreResponse` hook. +- An `onPreResponse` hook runs before rendering, so it can still replace a view with a different response, such as an error page. See [Error pages](#errors) below. +- [Response Marshalling](/tutorials/en_US/response-marshalling) covers this prepare and render split for every response type. + +## server.views() options + +| Option | Default | Effect | +| --------------------- | ----------------- | --------------------------------------------------------------------------- | +| `engines` | required | extension to engine, such as `{ html: Handlebars, pug: Pug }` | +| `path` | none | folder (or array of folders) with the templates | +| `relativeTo` | working directory | base folder for every other path option | +| `partialsPath` | none | folder with partials | +| `helpersPath` | none | folder with helper modules | +| `layout` | `false` | `true` to use `layout.html`, or the name of another layout file | +| `layoutPath` | none | folder with layouts | +| `context` | none | context merged into every view; an object, or a function of the request | +| `isCached` | `true` | `false` reads and compiles templates on every request; handy in development | +| `defaultExtension` | the only engine | extension used when `h.view()` gets a name without one | +| `compileMode` | `'sync'` | `'async'` for engines whose `compile` takes a callback | +| `allowAbsolutePaths` | `false` | allow absolute template names | +| `allowInsecureAccess` | `false` | allow `../` in template names | + +Options set on `server.views()` apply to every engine: + +```ts server.views({ - engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // engine specific - }, - }, - compileMode: 'async', // global setting + engines: { html: Handlebars }, + relativeTo: import.meta.dirname, + path: 'templates', + isCached: process.env.NODE_ENV === 'production', }); ``` -### Engines - -In order to use views in hapi, you must register at least one templating engine with the server. Templating engines may be either synchronous, or asynchronous, and should be an object with an export named `compile`. - -The signature of the `compile` method for synchronous engines is `function (template, options)` and that method should return a function with the signature `function (context, options)` which should either throw an error, or return the compiled html. - -Asynchronous template engines should have a `compile` method with the signature `function (template, options, callback)` which calls `callback` in standard error first format and returns a new method with the signature `function (context, options, callback)`. The returned method should also call `callback` in error first format, with the compiled html being the second parameter. +Here templates load from `templates/`, and outside production vision reads and compiles them again on every request, so template edits show up without a restart. -By default, hapi assumes that template engines are synchronous (i.e. `compileMode` defaults to `sync`), to use an asynchronous engine you must set `compileMode` to `async`. +### Engines -Leveraging the `options` parameter in both the `compile` method, and the method it returns, is done via the `compileOptions` and `runtimeOptions` settings. Both of these options default to the empty object `{}`. +An engine is any object with a `compile` function: -`compileOptions` is the object passed as the second parameter to `compile`, while `runtimeOptions` is passed to the method that `compile` returns. +| `compileMode` | `compile` signature | Returns | +| ------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `'sync'` (default) | `compile(template, compileOptions)` | `render(context, runtimeOptions)`, which returns the HTML | +| `'async'` | `compile(template, compileOptions, callback)` | calls `callback(err, render)`; `render(context, runtimeOptions, callback)` calls `callback(err, html)` | -If only one templating engine is registered, it automatically becomes the default allowing you to leave off the file extension when requesting a view. However, if more than one engine is registered, you must either append the file extension, or set the `defaultExtension` to the engine you use most frequently. For any views that do not use the default engine, you'll still need to specify a file extension. +`compileOptions` and `runtimeOptions` are `server.views()` options passed through to your engine. An engine whose API does not match can be wrapped in an object with this `compile` function. -Another useful options is `isCached`. If set to `false`, hapi will not cache the result of templates and will instead read the template from file on each use. When developing your application, this can be very handy as it prevents you from having to restart your app while working on templates. It is recommended that you leave `isCached` to its default value of `true` in production, however. +With more than one engine registered, `h.view()` needs the extension (`h.view('index.pug')`), unless you set `defaultExtension` to the one you use most. ### Paths -Since views can have files in several different locations, hapi allows you to configure several paths to help find things. Those options are: - -- `path`: the directory that contains your main templates -- `partialsPath`: the directory that contains your partials -- `helpersPath`: the directory that contains your template helpers -- `layoutPath`: the directory that contains layout templates -- `relativeTo`: used as a prefix for other path types, if specified other paths can be relative to this directory - -Additionally, there are two settings that alter how hapi will allow you to use paths. By default, absolute paths and traversing outside of the `path` directory is not allowed. This behavior can be changed by setting the `allowAbsolutePaths` and `allowInsecureAccess` settings to true. - -For example, if you have a directory structure like: +With this folder layout: ``` -templates\ - index.html - layout\ - layout.html - helpers\ - getUser.js - fortune.js +templates/ + index.html + layout/ + layout.html + helpers/ + fortune.js ``` -Your configuration might look like: +the configuration is: -```javascript +```ts server.views({ engines: { - html: require('handlebars'), + html: Handlebars, }, - relativeTo: __dirname, - path: './templates', - layoutPath: './templates/layout', - helpersPath: './templates/helpers', + relativeTo: import.meta.dirname, + path: 'templates', + layoutPath: 'templates/layout', + helpersPath: 'templates/helpers', }); ``` ### Global context -In the [rendering a view](#render) tutorial, you saw how to pass context directly into a view, but what if you have some default context that should _always_ be available on all templates? - -The simplest way to achieve this is by using the `context` option when calling `server.views()`: - -```javascript -const context = { - title: 'My personal site', -}; +`context` is merged into every view's context, and the view's own context wins when both set the same key. Pass a function to compute it per request, for example to show the logged-in user on every page: +```ts server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // engine specific - }, + html: Handlebars, }, - context, + relativeTo: import.meta.dirname, + path: 'templates', + context: (request) => ({ + siteName: 'My personal site', + user: request?.auth.credentials?.user ?? null, + }), }); ``` -The default global context will be merged with any local context passed taking the lowest precedence and applied to your view. +`request` is undefined when you render outside a request with `server.render()`, which is why the function checks it. ### View helpers -JavaScript modules located in the defined `helpersPath` are available in templates. For this example, you will create a view helper `fortune` which will pick and print out one element out of an array of strings, when used in a template. +For engines that support helpers, such as handlebars, each module in `helpersPath` becomes a helper named after its file. This helper, saved as `templates/helpers/fortune.js`, returns a random fortune: -The following snippet is the complete helper function which you will store in a file called `fortune.js` in the `helpers` directory. - -```javascript -module.exports = function () { +```js +export default function () { const fortunes = [ 'Heisenberg may have slept here...', 'Wanna buy a duck?', @@ -177,149 +197,114 @@ module.exports = function () { const x = Math.floor(Math.random() * fortunes.length); return fortunes[x]; -}; +} ``` -Now you can use the view helper in your templates. Here's a code snippet that show's the helper function in `templates/index.html` using handlebars as a rendering engine: +Helpers stay JavaScript files: vision loads them with `require()`, and skips any file whose extension Node.js has not registered with `require.extensions`, which does not include `.ts`. + +Use it in `templates/index.html`: ```html

Your fortune

-

{{fortune}}

-``` - -Now when you start the server and point your browser to the route which uses your template (which uses your view helper), you should see a paragraph with a randomly selected fortune right below the header. - -For reference, here is a complete server script that uses the fortune view helper method in a template. - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const start = async () => { - const server = Hapi.server({ port: 8080 }); - - await server.register(require('@hapi/vision')); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - helpersPath: 'helpers', - }); - - server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return h.view('index'); - }, - }); - - await server.start(); -}; - -start(); +

{{fortune}}

``` ### Layouts -`Vision` includes built-in support for view layouts. It comes disabled by default, because it may conflict with other layout systems that specific view engines may provide. We recommend choosing only one layout system. +Vision's layouts are off by default, because many engines have their own layout system. Use one or the other. To turn vision's on: -In order to use the built-in layout system, first setup the view engine: - -```javascript +```ts server.views({ - // ... + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', layout: true, layoutPath: 'templates/layout', }); ``` -This enables the built-in layouts and defines the default layout page to `templates/layout/layout.html` (or whatever other extension you're using). - -Setup a content area in your `layout.html`: +`templates/layout/layout.html` marks where each view goes with `content`: ```html - {{{content}}} + {{{content}}} ``` -And your view should be just the content: +and a view holds only its own part: ```html
Content
``` -When rendering the view, the `{{{content}}}` will be replaced by the view contents. - -If you want a different default layout, you can set the option globally: - -```javascript -server.views({ - // ... - layout: 'another_default', -}); -``` - -You can also specify a different layout per view: +| To | Do | +| -------------------------------- | --------------------------------------------------------- | +| use another default layout | `layout: 'another_default'` in `server.views()` | +| use another layout for one view | `h.view('myview', context, { layout: 'another_layout' })` | +| render one view without a layout | `h.view('myview', context, { layout: false })` | -```javascript -return h.view('myview', null, { layout: 'another_layout' }); -``` +Your context cannot have a `content` key while layouts are on; vision uses that name for the rendered view, and fails with a `500`. Change the name with the `layoutKeyword` option. ## Rendering a view -There are two options for rendering a view, you can use either the [`h.view()`](/module/vision/api#hviewtemplate-context-options) method, where `h` is the [response toolkit](/api#response-toolkit) or the view handler. - ### h.view() -The first method of rendering a view we'll look at is `h.view()`. Here's what a route using this method would look like: +Use [`h.view()`](/module/vision#h-view-template-context-options) when the handler has work to do first, such as loading data: -```javascript +```ts server.route({ method: 'GET', path: '/', - handler: function (request, h) { - return h.view('index'); + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); }, }); ``` -In order to pass context to `h.view()`, you pass an object as the second parameter, for example: - -```javascript -return h.view('index', { title: 'My home page' }); -``` +`h.view()` returns a normal response object, so you can chain `.code()`, `.header()`, and the other [response object](/api#response-object) methods. ### View handler -The second method of rendering a view, is using hapi's built in view handler. That route would look like: +When a route only renders a template, use the `view` handler: -```javascript +```ts server.route({ method: 'GET', path: '/', handler: { - view: 'index', + view: { + template: 'index', + context: { + title: 'My home page', + }, + }, }, }); ``` -When using the view handler, context is passed in the key `context`, for example: +`handler: { view: 'index' }` is the short form. The view handler always adds `params`, `payload`, `query`, and `pre` to the context, so a template on `/users/{id}` can print `params.id` with no handler code. -```json5 -handler: { - view: { - template: 'index', - context: { - title: 'My home page' - } +## Error pages + +hapi replies to errors with JSON. A site that renders HTML usually wants an HTML error page instead. Because rendering happens after `onPreResponse`, a hook there can swap any error for a view: + +```ts +import Boom from '@hapi/boom'; + +server.ext('onPreResponse', (request, h) => { + const response = request.response; + if (!Boom.isBoom(response)) { + return h.continue; } -} + + const { statusCode, payload } = response.output; + + return h.view('error', { statusCode, message: payload.message }).code(statusCode); +}); ``` + +A request for a missing page now gets `templates/error.html`, rendered in the layout, with status `404`. The hook also catches errors thrown by handlers and failed validation. See [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks) for more on `onPreResponse`. diff --git a/docs/tutorials/es_ES/views.md b/docs/tutorials/es_ES/views.md new file mode 100644 index 00000000..fe7e2237 --- /dev/null +++ b/docs/tutorials/es_ES/views.md @@ -0,0 +1,310 @@ +--- +title: Vistas +--- + + + +# Vistas + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +hapi renderiza plantillas HTML a través del plugin [vision](/module/vision). Vision funciona con cualquier motor de plantillas que tenga una función `compile`, como handlebars, pug y ejs (los motores con un `compile` distinto, como nunjucks, necesitan el pequeño wrapper que se describe en [Motores](#engines)), y soporta partials, helpers y layouts. + +## Vision + +Instala vision y un motor: + +```sh +npm install @hapi/vision handlebars +``` + +Al registrar vision se añade: + +| Qué | Para qué sirve | +| ------------------------------------- | --------------------------------------------------------------- | +| `server.views(options)` | configurar motores, carpetas de plantillas y layouts | +| `h.view(template, context)` | renderizar una plantilla desde un handler | +| handler `view` | una ruta que solo renderiza una plantilla | +| `server.render()`, `request.render()` | renderizar una plantilla a un string, por ejemplo para un email | + +Importar `@hapi/vision` también añade todo esto a los tipos de TypeScript de hapi. La API completa está en la [referencia de la API de vision](/module/vision). + +## Configurar el servidor + +```ts +import Hapi from '@hapi/hapi'; +import Vision from '@hapi/vision'; +import Handlebars from 'handlebars'; + +const server = Hapi.server({ port: 3000 }); + +await server.register(Vision); + +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', +}); + +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); + +await server.start(); +``` + +- `engines` asocia una extensión de fichero con un motor: los ficheros que terminan en `.html` se renderizan con handlebars. +- `path` es la carpeta que contiene las plantillas, resuelta a partir de `relativeTo`. Los módulos ES no tienen `__dirname`; `import.meta.dirname` es la carpeta del fichero actual. +- `h.view('index', ...)` renderiza `templates/index.html`. Con un solo motor registrado, puedes omitir la extensión. + +### Cómo se renderiza una vista + +`h.view()` no renderiza nada en el momento. Devuelve una respuesta de vista, y vision la renderiza cuando hapi envía la respuesta: + +```mermaid +flowchart TD + A["el handler devuelve h.view('index', context)"] --> B["vision busca y compila
templates/index.html (en caché)"] + B --> C[hooks onPreResponse] + C --> D["combina el contexto global
con el contexto de la vista"] + D --> E[renderiza la plantilla] + E --> F[la envuelve en el layout] + F --> G["se envía como text/html"] +``` + +Lo que significa este orden en la práctica: + +- Una plantilla que no existe falla con un `500` en cuanto el handler devuelve, antes de cualquier hook `onPreResponse`. +- Un hook `onPreResponse` se ejecuta antes del renderizado, así que todavía puede sustituir una vista por otra respuesta, como una página de error. Consulta [Páginas de error](#errors) más abajo. +- [Serialización de respuestas](/tutorials/es_ES/response-marshalling) explica esta separación entre preparar y renderizar para todos los tipos de respuesta. + +## Opciones de server.views() + +| Opción | Por defecto | Efecto | +| --------------------- | --------------------- | ------------------------------------------------------------------------------------ | +| `engines` | obligatoria | extensión a motor, como `{ html: Handlebars, pug: Pug }` | +| `path` | ninguna | carpeta (o array de carpetas) con las plantillas | +| `relativeTo` | directorio de trabajo | carpeta base para todas las demás opciones de rutas | +| `partialsPath` | ninguna | carpeta con los partials | +| `helpersPath` | ninguna | carpeta con los módulos de helpers | +| `layout` | `false` | `true` para usar `layout.html`, o el nombre de otro fichero de layout | +| `layoutPath` | ninguna | carpeta con los layouts | +| `context` | ninguna | contexto que se combina con todas las vistas; un objeto o una función de la petición | +| `isCached` | `true` | `false` lee y compila las plantillas en cada petición; útil en desarrollo | +| `defaultExtension` | el único motor | extensión que se usa cuando `h.view()` recibe un nombre sin ella | +| `compileMode` | `'sync'` | `'async'` para los motores cuyo `compile` recibe un callback | +| `allowAbsolutePaths` | `false` | permite nombres de plantilla absolutos | +| `allowInsecureAccess` | `false` | permite `../` en los nombres de plantilla | + +Las opciones definidas en `server.views()` se aplican a todos los motores: + +```ts +server.views({ + engines: { html: Handlebars }, + relativeTo: import.meta.dirname, + path: 'templates', + isCached: process.env.NODE_ENV === 'production', +}); +``` + +Aquí las plantillas se cargan desde `templates/`, y fuera de producción vision las vuelve a leer y compilar en cada petición, así que los cambios en las plantillas se ven sin reiniciar. + +### Motores + +Un motor es cualquier objeto con una función `compile`: + +| `compileMode` | Firma de `compile` | Devuelve | +| ---------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `'sync'` (por defecto) | `compile(template, compileOptions)` | `render(context, runtimeOptions)`, que devuelve el HTML | +| `'async'` | `compile(template, compileOptions, callback)` | llama a `callback(err, render)`; `render(context, runtimeOptions, callback)` llama a `callback(err, html)` | + +`compileOptions` y `runtimeOptions` son opciones de `server.views()` que se pasan tal cual a tu motor. Un motor cuya API no encaja se puede envolver en un objeto con esta función `compile`. + +Con más de un motor registrado, `h.view()` necesita la extensión (`h.view('index.pug')`), salvo que pongas en `defaultExtension` la que más uses. + +### Rutas + +Con esta estructura de carpetas: + +``` +templates/ + index.html + layout/ + layout.html + helpers/ + fortune.js +``` + +la configuración es: + +```ts +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', + layoutPath: 'templates/layout', + helpersPath: 'templates/helpers', +}); +``` + +### Contexto global + +`context` se combina con el contexto de cada vista, y el contexto propio de la vista gana cuando ambos definen la misma clave. Pasa una función para calcularlo en cada petición, por ejemplo para mostrar el usuario con sesión iniciada en todas las páginas: + +```ts +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', + context: (request) => ({ + siteName: 'My personal site', + user: request?.auth.credentials?.user ?? null, + }), +}); +``` + +`request` es undefined cuando renderizas fuera de una petición con `server.render()`; por eso la función lo comprueba. + +### Helpers de vistas + +En los motores que soportan helpers, como handlebars, cada módulo de `helpersPath` se convierte en un helper con el nombre de su fichero. Este helper, guardado como `templates/helpers/fortune.js`, devuelve una frase de la suerte al azar: + +```js +export default function () { + const fortunes = [ + 'Heisenberg may have slept here...', + 'Wanna buy a duck?', + 'Say no, then negotiate.', + 'Time and tide wait for no man.', + 'To teach is to learn.', + 'Never ask the barber if you need a haircut.', + 'You will forget that you ever knew me.', + 'You will be run over by a beer truck.', + 'Fortune favors the lucky.', + 'Have a nice day!', + ]; + + const x = Math.floor(Math.random() * fortunes.length); + return fortunes[x]; +} +``` + +Los helpers siguen siendo ficheros JavaScript: vision los carga con `require()` y omite cualquier fichero cuya extensión Node.js no haya registrado en `require.extensions`, que no incluye `.ts`. + +Úsalo en `templates/index.html`: + +```html +

Your fortune

+

{{fortune}}

+``` + +### Layouts + +Los layouts de vision están desactivados por defecto, porque muchos motores tienen su propio sistema de layouts. Usa uno u otro. Para activar los de vision: + +```ts +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', + layout: true, + layoutPath: 'templates/layout', +}); +``` + +`templates/layout/layout.html` marca con `content` dónde va cada vista: + +```html + + + {{{content}}} + + +``` + +y una vista contiene solo su propia parte: + +```html +
Content
+``` + +| Para | Haz | +| ------------------------------- | --------------------------------------------------------- | +| usar otro layout por defecto | `layout: 'another_default'` en `server.views()` | +| usar otro layout para una vista | `h.view('myview', context, { layout: 'another_layout' })` | +| renderizar una vista sin layout | `h.view('myview', context, { layout: false })` | + +Tu contexto no puede tener una clave `content` mientras los layouts están activados; vision usa ese nombre para la vista renderizada y falla con un `500`. Cambia el nombre con la opción `layoutKeyword`. + +## Renderizar una vista + +### h.view() + +Usa [`h.view()`](/module/vision#h-view-template-context-options) cuando el handler tiene trabajo que hacer antes, como cargar datos: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); +``` + +`h.view()` devuelve un objeto response normal, así que puedes encadenar `.code()`, `.header()` y los demás métodos del [objeto response](/api#response-object). + +### Handler view + +Cuando una ruta solo renderiza una plantilla, usa el handler `view`: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'My home page', + }, + }, + }, +}); +``` + +`handler: { view: 'index' }` es la forma corta. El handler view siempre añade `params`, `payload`, `query` y `pre` al contexto, así que una plantilla en `/users/{id}` puede mostrar `params.id` sin código en el handler. + +## Páginas de error + +hapi responde a los errores con JSON. Un sitio que renderiza HTML normalmente quiere una página de error en HTML. Como el renderizado ocurre después de `onPreResponse`, un hook ahí puede cambiar cualquier error por una vista: + +```ts +import Boom from '@hapi/boom'; + +server.ext('onPreResponse', (request, h) => { + const response = request.response; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload } = response.output; + + return h.view('error', { statusCode, message: payload.message }).code(statusCode); +}); +``` + +Ahora una petición a una página que no existe recibe `templates/error.html`, renderizada dentro del layout, con estado `404`. El hook también captura los errores que lanzan los handlers y las validaciones fallidas. Consulta [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) para saber más sobre `onPreResponse`. diff --git a/docs/tutorials/ko_KR/views.md b/docs/tutorials/ko_KR/views.md index 279554ba..bb07d92d 100644 --- a/docs/tutorials/ko_KR/views.md +++ b/docs/tutorials/ko_KR/views.md @@ -1,206 +1,187 @@ --- -title: Views +title: 뷰 --- -## Views +# 뷰 -_이 튜터리얼은 hapi v17과 호환됩니다._ +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ -hapi는 다양한 템플릿 엔진, 부분, 헬퍼 (데이터를 조작하는 템플릿에서 사용되는 함수들) 그리고 레이아웃을 읽어 들여 활용하는 기능을 포함하여 템플릿 렌더링을 광범위하게 지원합니다. 이런 기능들은 [vision](https://github.com/hapijs/vision) 플러그인에서 제공됩니다. +## 개요 -## 서버 설정하기 +hapi는 [vision](/module/vision) 플러그인으로 HTML 템플릿을 렌더링합니다. vision은 handlebars, pug, ejs를 포함해 `compile` 함수가 있는 모든 템플릿 엔진과 함께 동작하며(nunjucks처럼 `compile`이 다른 엔진은 [엔진](#engines)에서 설명하는 작은 래퍼가 필요합니다), partial, 헬퍼, 레이아웃을 지원합니다. -view를 시작하려면 먼저 서버에 최소한 하나의 템플릿 엔진을 설정해야 합니다. vision에서 제공하는 [`server.views()`](https://github.com/hapijs/vision/blob/master/API.md#serverviewsoptions) 메소드를 사용하여 설정합니다.: +## Vision -```javascript -'use strict'; +vision과 엔진을 설치하세요: -const Path = require('path'); -const Hapi = require('@hapi/hapi'); -const Hoek = require('@hapi/hoek'); - -const server = Hapi.server(); - -const start = async () => { - await server.register(require('@hapi/vision')); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - }); -}; - -start(); +```sh +npm install @hapi/vision handlebars ``` -여기에서 몇 가지 작업을 하고 있습니다. 먼저 플러그인으로 [`vision`](https://github.com/hapijs/vision) 모듈을 읽어들입니다. hapi에 템플릿 렌더링 지원을 추가합니다. +vision을 등록하면 다음이 추가됩니다: + +| 기능 | 용도 | +| ------------------------------------- | ---------------------------------------------- | +| `server.views(options)` | 엔진, 템플릿 폴더, 레이아웃 설정 | +| `h.view(template, context)` | 핸들러에서 템플릿 렌더링 | +| `view` 핸들러 | 템플릿만 렌더링하는 라우트 | +| `server.render()`, `request.render()` | 이메일 등에 쓰기 위해 템플릿을 문자열로 렌더링 | -두 번째 `handlebars` 모듈을 `.html` 확장자를 가진 템플릿을 렌더링을 담당하는 엔진으로 등록합니다. +`@hapi/vision`을 import하면 hapi의 TypeScript 타입에도 이들이 추가됩니다. 전체 API는 [vision API 레퍼런스](/module/vision)에 있습니다. -그 다음 현재 경로의 `templates` 디렉터리에 템플릿이 있음을 서버에게 알립니다. `relativeTo` 옵션을 제공하여 현재 디렉터리의 상대 경로로 이 디렉터리를 가리킬 수 있습니다. 기본적으로 hapi는 현재 작업 디렉터리에서 템플릿을 찾습니다. +## 서버 설정 -### View options +```ts +import Hapi from '@hapi/hapi'; +import Vision from '@hapi/vision'; +import Handlebars from 'handlebars'; -hapi에서 view 엔진에 대한 여러가지 옵션이 있습니다. 전체 문서는 [vision API reference](https://github.com/hapijs/vision/blob/master/API.md#serverviewsoptions)에서 찾을 수 있으나 여기에서 그중 일부를 살펴보겠습니다. +const server = Hapi.server({ port: 3000 }); -모든 옵션은 모든 등록된 엔진에 대해 전역적으로 설정할 수 있거나 특정 엔진에 지역적으로 설정할 수 있습니다. 예제: +await server.register(Vision); -```javascript server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // engine specific - }, + html: Handlebars, }, - compileMode: 'async', // global setting + relativeTo: import.meta.dirname, + path: 'templates', }); -``` - -### 엔진 - -hapi에서 view를 사용하려면 서버에 최소한 하나의 템플릿 엔진을 등록해야 합니다. 템플릿 엔진은 동기적 또는 비동기적일 수 있으며 외부 공개된 `compile` 이름을 가진 객체이어야 합니다. - -동기적 엔진을 위한 `compile` 메소드는 `function (template, options)`이며 에러를 던지거나 컴파일된 html을 반환하는 `function (context, option)` 형태의 함수를 반환합니다. - -비동기적 템플릿 엔진은 `function (template, options, callback)` 형태의 `compile` 메소드를 가지고 있습니다. 이 메소드는 표준 에러를 첫 인자로 가지는 `callback`을 호출하고 `function(context, options, callback)` 형태의 새로운 메소드를 반환합니다. 반환된 메소드는 역시 에러를 첫 인자로, 컴파일된 html을 두 번째 인자로 가지는 콜백을 호출합니다. - -기본으로 hapi는 템플릿 엔진을 동기로 가정합니다. (즉 `compileMode`가 기본으로 `sync`) 비동기적 엔진을 사용하려면 `complieMode`를 `async`로 설정해야 합니다. -`compileOptions`와 `runtimeOptions` 설정을 통해 `compile` 메소드와 그 메소드가 반환하는 두 곳에서 `options` 인자를 사용합니다. 이 두 옵션의 기본 값은 빈 객체 `{}`입니다. - -`compileOptions`는 `compile`에 두 번째 인자로 전달되는 객체이고 `runtimeOptions`는 `compile` 반환하는 메소드에 전달됩니다. - -하나의 템플릿 엔진만 등록되어 있으면 자동으로 기본이 되어 view를 요청할 때 파일 확장자를 생략할 수 있습니다. 그러나 둘 이상의 엔진이 등록되어 있다면 파일 확장자를 붙이거나 가장 자주 사용하는 엔진에 `defaultExtenstion`을 설정해야 합니다. 기본 엔진을 사용하지 않는 view의 경우 여전히 파일 확장자를 지정해야 합니다. - -다른 유용한 옵션은 `isCached`입니다. `false`로 설정되면 hapi는 템플릿의 결과를 캐시 하지 않을 것이고 대신에 매 사용 시 파일로부터 템플릿을 읽어 들일것 입니다. 응용프로그램을 개발할 때 템플릿을 사용하는 동안 앱을 다시 시작하지 않아도 되기 때문에 매우 유용할 수 있습니다. 그러나 프로덕션에서는 `isCached`을 기본값인 `true`로 두는 것이 좋습니다. - -### 경로 +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); -view는 여러 다른 위치에 파일들을 가질 수 있기 때문에 hapi는 파일들을 찾는 데 도움이 되게 여러 경로를 설정할 수 있습니다. 여기에 옵션이 있습니다.: +await server.start(); +``` -- `path`: 기본 템플릿을 포함한 디렉터리 -- `partialsPath`: 부분을 담고 있는 디렉터리 -- `helpersPath`: 템플릿 헬퍼를 담고 있는 디렉터리 -- `layoutPath`: 레이아웃 템플릿을 담고 있는 디렉터리 -- `relativeTo`: 다른 경로에 대한 접두어로 사용. 만약 지정되었다면 다른 경로는 이 디렉터리의 상대 경로입니다. +- `engines`는 파일 확장자를 엔진에 매핑합니다. `.html`로 끝나는 파일은 handlebars로 렌더링됩니다. +- `path`는 템플릿이 있는 폴더이며 `relativeTo`를 기준으로 해석됩니다. ES 모듈에는 `__dirname`이 없습니다. `import.meta.dirname`이 현재 파일의 폴더입니다. +- `h.view('index', ...)`는 `templates/index.html`을 렌더링합니다. 엔진이 하나만 등록되어 있으면 확장자를 생략할 수 있습니다. -또한 hapi가 경로를 사용하는 방법을 변경하는 두 가지 설정이 있습니다. 기본으로 절대 경로와 `path` 디렉터리의 외부로 이동은 허용되지 않습니다. 이 동작은 `allowAbsolutePaths`와 `allowInsecureAccess`를 true로 설정하여 변경할 수 있습니다. +### 뷰가 렌더링되는 과정 -예를 들면 다음과 같은 디렉터리 구조로 되어 있다면: +`h.view()`는 즉시 무언가를 렌더링하지 않습니다. 뷰 응답을 반환하고, hapi가 응답을 보낼 때 vision이 렌더링합니다: +```mermaid +flowchart TD + A["핸들러가 h.view('index', context)를 반환"] --> B["vision이 templates/index.html을
찾아 컴파일 (캐시됨)"] + B --> C["onPreResponse 훅"] + C --> D["전역 컨텍스트를
뷰의 컨텍스트와 병합"] + D --> E["템플릿 렌더링"] + E --> F["레이아웃으로 감싸기"] + F --> G["text/html로 전송"] ``` -templates\ - index.html - layout\ - layout.html - helpers\ - getUser.js - fortune.js -``` - -설정은 다음과 같을 것입니다.: -```javascript +이 순서가 실제로 의미하는 것: + +- 템플릿이 없으면 핸들러가 반환하는 즉시, 어떤 `onPreResponse` 훅보다도 먼저 `500`으로 실패합니다. +- `onPreResponse` 훅은 렌더링 전에 실행되므로 뷰를 에러 페이지 같은 다른 응답으로 교체할 수 있습니다. 아래의 [에러 페이지](#errors)를 참고하세요. +- 모든 응답 타입에 대한 이 준비 단계와 렌더링 단계의 구분은 [응답 마샬링](/tutorials/ko_KR/response-marshalling)에서 다룹니다. + +## server.views() 옵션 + +| 옵션 | 기본값 | 효과 | +| --------------------- | ------------- | ---------------------------------------------------------------------- | +| `engines` | 필수 | 확장자에서 엔진으로의 매핑. 예: `{ html: Handlebars, pug: Pug }` | +| `path` | 없음 | 템플릿이 있는 폴더(또는 폴더의 배열) | +| `relativeTo` | 작업 디렉터리 | 다른 모든 경로 옵션의 기준 폴더 | +| `partialsPath` | 없음 | partial이 있는 폴더 | +| `helpersPath` | 없음 | 헬퍼 모듈이 있는 폴더 | +| `layout` | `false` | `true`이면 `layout.html`을 사용. 다른 레이아웃 파일의 이름도 지정 가능 | +| `layoutPath` | 없음 | 레이아웃이 있는 폴더 | +| `context` | 없음 | 모든 뷰에 병합되는 컨텍스트. 객체 또는 요청을 받는 함수 | +| `isCached` | `true` | `false`이면 요청마다 템플릿을 읽고 컴파일. 개발 중에 편리함 | +| `defaultExtension` | 유일한 엔진 | `h.view()`가 확장자 없는 이름을 받을 때 사용할 확장자 | +| `compileMode` | `'sync'` | `compile`이 콜백을 받는 엔진에는 `'async'` | +| `allowAbsolutePaths` | `false` | 절대 경로 템플릿 이름 허용 | +| `allowInsecureAccess` | `false` | 템플릿 이름에 `../` 허용 | + +`server.views()`에 설정한 옵션은 모든 엔진에 적용됩니다: + +```ts server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: './templates', - layoutPath: './templates/layout', - helpersPath: './templates/helpers', + engines: { html: Handlebars }, + relativeTo: import.meta.dirname, + path: 'templates', + isCached: process.env.NODE_ENV === 'production', }); ``` -## view 렌더링 - -view를 렌더링하는데 두 가지 옵션이 있습니다. [응답 도구](/api#response-toolkit)인 `h`로 `h.view()` 또는 view 처리기를 사용하는 것입니다. +여기서는 템플릿을 `templates/`에서 불러오고, 프로덕션이 아닐 때는 vision이 요청마다 템플릿을 다시 읽고 컴파일하므로 재시작하지 않아도 템플릿 수정이 반영됩니다. -### [`h.view()`](https://github.com/hapijs/vision/blob/master/API.md#hviewtemplate-context-options) +### 엔진 -view를 렌더링하는 첫 번째 방법은 `h.view()`입니다. 이 방법을 사용하는 route는 다음과 같습니다.: +엔진은 `compile` 함수가 있는 객체라면 무엇이든 됩니다: -```javascript -server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return h.view('index'); - }, -}); -``` +| `compileMode` | `compile` 시그니처 | 반환 | +| ----------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `'sync'` (기본값) | `compile(template, compileOptions)` | HTML을 반환하는 `render(context, runtimeOptions)` | +| `'async'` | `compile(template, compileOptions, callback)` | `callback(err, render)`를 호출. `render(context, runtimeOptions, callback)`은 `callback(err, html)`을 호출 | -context를 `h.view()`에 전달하려면 두 번째 인자로 겍체를 전달합니다. 예제입니다.: +`compileOptions`와 `runtimeOptions`는 엔진에 그대로 전달되는 `server.views()` 옵션입니다. API가 맞지 않는 엔진은 이런 `compile` 함수를 가진 객체로 감싸면 됩니다. -```javascript -return h.view('index', { title: 'My home page' }); -``` +엔진이 둘 이상 등록되어 있으면 가장 많이 쓰는 엔진을 `defaultExtension`으로 설정하지 않는 한 `h.view()`에 확장자가 필요합니다(`h.view('index.pug')`). -### View 핸들러 +### 경로 -view를 렌더링하는 두 번째 방법은 hapi의 내장 view 처리기를 사용하는 것입니다. route는 다음과 같습니다. +다음과 같은 폴더 구조라면: -```javascript -server.route({ - method: 'GET', - path: '/', - handler: { - view: 'index', - }, -}); ``` - -view 처리기를 사용할 때 context는 `context` 키로 전달 됩니다. 예제입니다.: - -```json5 -handler: { - view: { - template: 'index', - context: { - title: 'My home page' - } - } -} +templates/ + index.html + layout/ + layout.html + helpers/ + fortune.js ``` -### 전역 context +설정은 다음과 같습니다: -view에 context를 직접 전달하는 방법을 살펴보았지만 모든 템플릿에 _항상_ 사용가능한 기본 context가 있다면 어떻할까요? +```ts +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', + layoutPath: 'templates/layout', + helpersPath: 'templates/helpers', +}); +``` -가장 간단한 방법은 `server.view()`를 호출할 때 `context` 옵션을 사용하는 것입니다. +### 전역 컨텍스트 -```javascript -const context = { - title: 'My personal site', -}; +`context`는 모든 뷰의 컨텍스트에 병합되며, 둘 다 같은 키를 설정하면 뷰 자신의 컨텍스트가 우선합니다. 요청마다 계산하려면 함수를 넘기세요. 예를 들어 모든 페이지에 로그인한 사용자를 표시할 수 있습니다: +```ts server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // engine specific - }, + html: Handlebars, }, - context, + relativeTo: import.meta.dirname, + path: 'templates', + context: (request) => ({ + siteName: 'My personal site', + user: request?.auth.credentials?.user ?? null, + }), }); ``` -기본 전역 context는 낮은 우선순위로 전달되는 지역 context와 합쳐지고 view에 적용될 것입니다. - -### View helpers +`server.render()`로 요청 밖에서 렌더링하면 `request`가 undefined이므로 함수에서 이를 확인합니다. -`helpersPath`에 위치한 javascript 모듈은 템플릿에서 사용할 수 있습니다. 이 예제에서 템플릿에서 사용될 때 문자열 배열에서 하나의 원소를 골라서 출력하는 `fortune`이라는 view 헬퍼를 만들 것입니다. +### 뷰 헬퍼 -다음 작은 코드는 `helpers` 디렉터리에 `fortune.js` 파일에 저장된 완전한 헬퍼 함수입니다. +handlebars처럼 헬퍼를 지원하는 엔진에서는 `helpersPath`의 각 모듈이 파일 이름을 딴 헬퍼가 됩니다. `templates/helpers/fortune.js`로 저장한 다음 헬퍼는 무작위 운세를 반환합니다: -```javascript -module.exports = function () { +```js +export default function () { const fortunes = [ 'Heisenberg may have slept here...', 'Wanna buy a duck?', @@ -216,96 +197,114 @@ module.exports = function () { const x = Math.floor(Math.random() * fortunes.length); return fortunes[x]; -}; +} ``` -이제 view 헬퍼를 템플릿에서 사용할 수 있습니다. 여기에 handlebar를 템플릿 엔진으로 사용하는 `templates/index.html`에서 헬퍼 함수를 보여주는 작은 코드가 있습니다. +헬퍼는 JavaScript 파일로 둡니다. vision은 헬퍼를 `require()`로 불러오고, Node.js가 `require.extensions`에 등록하지 않은 확장자의 파일은 건너뛰는데, 여기에는 `.ts`가 포함되지 않습니다. + +`templates/index.html`에서 사용하세요: ```html

Your fortune

-

{{fortune}}

+

{{fortune}}

``` -이제 서버를 시작하고 브라우저가 템플릿을 (view 헬퍼를 사용한) 사용하는 route에 접근하면 헤더 바로 아래에 무작위로 선택된 행운을 가진 단락을 볼 것입니다. - -참고로 여기 템플릿에서 fortune 헬퍼 메소드를 사용하는 완전한 서버 스크립트가 있습니다. +### 레이아웃 -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ port: 8080 }); - -const start = async () => { - await server.register(require('@hapi/vision')); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - helpersPath: 'helpers', - }); - - server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return h.view('index'); - }, - }); -}; +많은 엔진이 자체 레이아웃 시스템을 갖고 있기 때문에 vision의 레이아웃은 기본적으로 꺼져 있습니다. 둘 중 하나만 사용하세요. vision의 레이아웃을 켜려면: -start(); -``` - -### Layouts - -vision은 view 레이아웃에 대한 내장 지원을 가지고 있습니다. 특정 view 엔진에서 제공하는 레이아웃 시스템과 충돌을 일으킬 수 있기 때문에 기본으로 비활성 상태입니다. 단 하나의 레이아웃 시스템 선택을 추천합니다. - -내장 레이아웃 시스템을 사용하려면 먼저 view 엔진을 설정합니다.: - -```javascript +```ts server.views({ - // ... + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', layout: true, layoutPath: 'templates/layout', }); ``` -내장 레이아웃을 활성화하고 기본 레이아웃 페이지를 `templates/layout/layout.html`로 정의합니다. (또는 사용 중인 다른 확장) - -`laylout.html`에서 콘텐츠 영역 설정: +`templates/layout/layout.html`은 `content`로 각 뷰가 들어갈 자리를 표시합니다: ```html - {{{content}}} + {{{content}}} ``` -그리고 view는 콘텐츠가 될 것입니다: +그리고 뷰에는 자신의 부분만 들어갑니다: ```html
Content
``` -view를 렌더링할 때 `{{content}}`는 view 콘텐츠로 바뀔 것입니다. +| 하려는 것 | 방법 | +| ---------------------------- | --------------------------------------------------------- | +| 다른 기본 레이아웃 사용 | `server.views()`에 `layout: 'another_default'` | +| 한 뷰에만 다른 레이아웃 사용 | `h.view('myview', context, { layout: 'another_layout' })` | +| 한 뷰를 레이아웃 없이 렌더링 | `h.view('myview', context, { layout: false })` | -다른 기본 레이아웃을 원한다면 옵션을 전역으로 설정하세요.: +레이아웃이 켜져 있는 동안에는 컨텍스트에 `content` 키를 둘 수 없습니다. vision이 렌더링된 뷰에 그 이름을 쓰기 때문이며, 이 경우 `500`으로 실패합니다. 이름은 `layoutKeyword` 옵션으로 바꿀 수 있습니다. -```javascript -server.views({ - // ... - layout: 'another_default', +## 뷰 렌더링 + +### h.view() + +데이터 로딩처럼 핸들러에서 먼저 할 일이 있을 때는 [`h.view()`](/module/vision#h-view-template-context-options)를 사용하세요: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); +``` + +`h.view()`는 일반 응답 객체를 반환하므로 `.code()`, `.header()` 등 [응답 객체](/api#response-object) 메서드를 체이닝할 수 있습니다. + +### 뷰 핸들러 + +라우트가 템플릿만 렌더링한다면 `view` 핸들러를 사용하세요: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'My home page', + }, + }, + }, }); ``` -view 별로 다른 레이아웃을 지정할 수도 있습니다.: +`handler: { view: 'index' }`가 짧은 형태입니다. 뷰 핸들러는 항상 컨텍스트에 `params`, `payload`, `query`, `pre`를 추가하므로 `/users/{id}`의 템플릿은 핸들러 코드 없이도 `params.id`를 출력할 수 있습니다. + +## 에러 페이지 + +hapi는 에러에 JSON으로 응답합니다. HTML을 렌더링하는 사이트는 보통 HTML 에러 페이지를 원합니다. 렌더링은 `onPreResponse` 이후에 일어나므로, 그 훅에서 어떤 에러든 뷰로 바꿀 수 있습니다: -```javascript -return h.view('myview', null, { layout: 'another_layout' }); +```ts +import Boom from '@hapi/boom'; + +server.ext('onPreResponse', (request, h) => { + const response = request.response; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload } = response.output; + + return h.view('error', { statusCode, message: payload.message }).code(statusCode); +}); ``` + +이제 없는 페이지를 요청하면 레이아웃 안에 렌더링된 `templates/error.html`을 상태 `404`와 함께 받습니다. 이 훅은 핸들러가 던진 에러와 검증 실패도 잡습니다. `onPreResponse`에 대해서는 [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks)을 참고하세요. diff --git a/docs/tutorials/pt_BR/views.md b/docs/tutorials/pt_BR/views.md index d97d9816..68649113 100644 --- a/docs/tutorials/pt_BR/views.md +++ b/docs/tutorials/pt_BR/views.md @@ -4,205 +4,184 @@ title: Views -## Views +# Views -_Este tutorial é compatível com hapi v16_ +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ -hapi tem um extenso suporte para renderização de template, incluindo a habilidade de carregar e alavancar múltiplos motores de templates, partials, helpers (funções usada no templates para manipular dados), e layouts. +## Visão geral -## Configurando o servidor +O hapi renderiza templates HTML por meio do plugin [vision](/module/vision). O vision funciona com qualquer template engine que tenha uma função `compile`, incluindo handlebars, pug e ejs (engines com um `compile` diferente, como o nunjucks, precisam do pequeno wrapper descrito em [Engines](#engines)), e suporta partials, helpers e layouts. -Para inicar com a views, primeiro nós temos que configurar pelo menos um motor de template no servidor. Este é feito pelo método `server.views`: +## Vision -```javascript -'use strict'; +Instale o vision e uma engine: -const Path = require('path'); -const Hapi = require('hapi'); -const Hoek = require('hoek'); - -const server = new Hapi.Server(); - -server.register(require('vision'), (err) => { - Hoek.assert(!err, err); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - }); -}); +```sh +npm install @hapi/vision handlebars ``` -Nós estamos fazendo várias coisas aqui. +Registrar o vision adiciona: -Primeiro, nós carregamos o modulo [`vision`](https://github.com/hapijs/vision) como um plugin. Isto adiciona um suporte de renderização de template para o hapi. O [`vision`](https://github.com/hapijs/vision) não está incluso no hapi, você precisa instalar ele. +| O quê | Use para | +| ------------------------------------- | ------------------------------------------------------------------ | +| `server.views(options)` | configurar engines, pastas de templates e layouts | +| `h.view(template, context)` | renderizar um template a partir de um handler | +| handler `view` | uma rota que só renderiza um template | +| `server.render()`, `request.render()` | renderizar um template para uma string, por exemplo para um e-mail | -Segundo, no registramos o módulo `handlebars` como o motor responsável pela renderização do templete que contendo a extenção `.html`. +Importar `@hapi/vision` também adiciona esses recursos aos tipos TypeScript do hapi. A API completa está na [referência da API do vision](/module/vision). -Terceiro, nós dizemos ao servidor que seus templates estam localizacos no diretório `templates` dentro do caminho atual. Por padrão, hapi irá olhar para templates no diretório de trabalho atual. Você pode adicionar um parâmetro com o diretório que os templates estão localizados. +## Configurando o servidor -### Opções da View +```ts +import Hapi from '@hapi/hapi'; +import Vision from '@hapi/vision'; +import Handlebars from 'handlebars'; -Temos algumas opções habilitadas para o motor de views no hapi. Toda documentação pode ser encontrada nas [referência da API](/api/#server-options), mas vamos mostrar alguns deles aqui também. +const server = Hapi.server({ port: 3000 }); -Note que todas opções pode ser definidas de forma global, que configura todos os motores registrados, ou direcionada para um motor específico, por exemplo: +await server.register(Vision); -```javascript server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // motor específica - }, + html: Handlebars, }, - compileMode: 'async', // configuração global + relativeTo: import.meta.dirname, + path: 'templates', }); -``` - -#### Motores - -Para utilizar views no hapi, você tem que registrar pelo menos um motor de template no servidor. Motor de templates pode ser síncronos, ou assíncronos, e deve ser um objecto quem contém um método com o nome `compile`. - -A assinatura do método `compile` para motor síncronos é `function (template, options)` e o método deve retorna uma função com a assinatura `function (context, options)` que deve disparar um erro, ou retornar o html compilado. - -Motores de templete assíncronos deve ter um método `compile` com a assinatura `function (template, options, callback)` que chamam `callback` por padrão primeiro formatar o erro e retorna um novo método com a assinatura `function (context, options, callback)`. O método retonado também deve chamar `callback` primeiro formatar erro, e com o html compilado sendo o segundo parâmetro. - -Por padrão, hapi assume que o motor de template é síncrono (i.e. `compileMode` o padrão é `sync`), par usar um motor assíncrono você deve definir `compileMode` para `async`. - -Aproveitando o parâmetro `options` tanto o método `compile`, e o método que retornado, é através da configuração do `compileOptions` e `runtimeOptions`. As duas opções tem por padrão um objeto vazio `{}`. -`compileOptions` é o objeto passado no segunda parâmetro pelo `compile`, enquando `runtimeOptions` é passado pelo método que `compile` retorna. - -Se somente um motor de template é registardo, torna-se automaticamento padrão, permitindo que você deixe de fora a extenção do arquivo ao solicitar a view. Contudo, se mais de um motor for registardo, você deve adiciona a extensão do arquvio, ou definir o `defaultExtension` para o motor que você usa com mais frenquente. Para qualquer views que não usa o motor padrão, você ainda precisa especificar o extenção do arquivo. - -Outro opção util é `isCached`. Se definida como `false`, hapi não irá cachear o resultado do template e em vez disso vai ler o template do arquivo tada vez que usar. Quando desenvolver sua aplicação, isto pode ser bastante útil pois evita que você reinicie a sua aplicação toda vez que alterar o templete. É recomentdado que você deixe o `isCached` com o valor padrão `true` em produção. - -#### Paths +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); -As views pode ter varios arquivos em difrente locais, hapi permite que você configure varios caminhos para ajudar encontrar os arquvios. -As opções são: +await server.start(); +``` -- `path`: o diretório que contém seus principais templates -- `partialsPath`: o diretório que contém suas partials -- `helpersPath`: o diretório que contém suas templates helpers -- `layoutPath`: o diretório que contém templates layout -- `relativeTo`: usado como prefixo dos outros caminhos, os outros caminho podem ser relativo a este diretório +- `engines` associa uma extensão de arquivo a uma engine: arquivos terminados em `.html` são renderizados com handlebars. +- `path` é a pasta que guarda os templates, resolvida a partir de `relativeTo`. Módulos ES não têm `__dirname`; `import.meta.dirname` é a pasta do arquivo atual. +- `h.view('index', ...)` renderiza `templates/index.html`. Com uma única engine registrada, você pode omitir a extensão. -Adicionalmente, existem duas configurações que alteram como o hapi ira permitir que você use os caminhos. Por padrão, os caminhos absolutos e sair fora do dirtório `path` não é permitido. Este comportamento pode ser mudado configurando o `allowAbsolutePaths` e `allowInsecureAccess` para true. +### Como uma view é renderizada -Por exemplo, se você tiver uma estrutura de diretório tipo: +`h.view()` não renderiza nada na hora. Ele retorna uma resposta de view, e o vision a renderiza enquanto o hapi envia a resposta: +```mermaid +flowchart TD + A["o handler retorna h.view('index', context)"] --> B["o vision encontra e compila
templates/index.html (em cache)"] + B --> C[hooks onPreResponse] + C --> D["junta o contexto global
com o contexto da view"] + D --> E[renderiza o template] + E --> F[envolve no layout] + F --> G["envia como text/html"] ``` -views\ - index.html - layout\ - layout.html - helpers\ - getUser.js - fortune.js -``` - -Sua configuração pode parecer isso: -```javascript +O que essa ordem significa na prática: + +- Um template inexistente falha com `500` assim que o handler retorna, antes de qualquer hook `onPreResponse`. +- Um hook `onPreResponse` roda antes da renderização, então ainda pode substituir uma view por outra resposta, como uma página de erro. Veja [Páginas de erro](#errors) abaixo. +- [Marshalling de respostas](/tutorials/pt_BR/response-marshalling) explica essa divisão entre prepare e render para todos os tipos de resposta. + +## Opções de server.views() + +| Opção | Padrão | Efeito | +| --------------------- | --------------------- | ---------------------------------------------------------------------------- | +| `engines` | obrigatório | extensão para engine, como `{ html: Handlebars, pug: Pug }` | +| `path` | nenhum | pasta (ou array de pastas) com os templates | +| `relativeTo` | diretório de trabalho | pasta base para todas as outras opções de caminho | +| `partialsPath` | nenhum | pasta com os partials | +| `helpersPath` | nenhum | pasta com os módulos de helpers | +| `layout` | `false` | `true` para usar `layout.html`, ou o nome de outro arquivo de layout | +| `layoutPath` | nenhum | pasta com os layouts | +| `context` | nenhum | contexto incluído em todas as views; um objeto ou uma função da requisição | +| `isCached` | `true` | `false` lê e compila os templates a cada requisição; útil em desenvolvimento | +| `defaultExtension` | a única engine | extensão usada quando `h.view()` recebe um nome sem extensão | +| `compileMode` | `'sync'` | `'async'` para engines cujo `compile` recebe um callback | +| `allowAbsolutePaths` | `false` | permite nomes de template absolutos | +| `allowInsecureAccess` | `false` | permite `../` nos nomes de template | + +As opções definidas em `server.views()` se aplicam a todas as engines: + +```ts server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: './views', - layoutPath: './views/layout', - helpersPath: './views/helpers', + engines: { html: Handlebars }, + relativeTo: import.meta.dirname, + path: 'templates', + isCached: process.env.NODE_ENV === 'production', }); ``` -## Rederizando uma visão (view) +Aqui os templates são carregados de `templates/`, e fora de produção o vision os lê e compila de novo a cada requisição, então as alterações nos templates aparecem sem reiniciar o servidor. -Temos duas opções para rederizar uma view, você pode usar o `reply.view()`, ou o monipulador da view. +### Engines -### `reply.view()` +Uma engine é qualquer objeto com uma função `compile`: -O primeiro método que renderiza um view que vamos olhar é `reply.view()`. Aqui mostra como é usado este método em um rota: +| `compileMode` | Assinatura de `compile` | Retorna | +| ----------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `'sync'` (padrão) | `compile(template, compileOptions)` | `render(context, runtimeOptions)`, que retorna o HTML | +| `'async'` | `compile(template, compileOptions, callback)` | chama `callback(err, render)`; `render(context, runtimeOptions, callback)` chama `callback(err, html)` | -```javascript -server.route({ - method: 'GET', - path: '/', - handler: function (request, reply) { - reply.view('index'); - }, -}); -``` +`compileOptions` e `runtimeOptions` são opções de `server.views()` repassadas para a sua engine. Uma engine cuja API não corresponde pode ser envolvida em um objeto com essa função `compile`. -Podemos passar o contexto para o `reply.view()`, você passa um objeto no segundo parâmetro, por exemplo: +Com mais de uma engine registrada, `h.view()` precisa da extensão (`h.view('index.pug')`), a menos que você defina `defaultExtension` com a que mais usa. -```javascript -reply.view('index', { title: 'My home page' }); -``` - -### Manipulador de visão (view) +### Caminhos -O segundo método para renderizar a view, é usando um objeto com a propriedade view no handler. A rota seria algo como: +Com esta estrutura de pastas: -```javascript -server.route({ - method: 'GET', - path: '/', - handler: { - view: 'index', - }, -}); ``` - -Quando usado o view handler, o contexto é passdo pela chave `context`, por exemplo: - -```json5 -handler: { - view: { - template: 'index', - context: { - title: 'My home page' - } - } -} +templates/ + index.html + layout/ + layout.html + helpers/ + fortune.js ``` -### Contexto global +a configuração é: -Nós estamos vendo como passar o contexto direto para uma view, mas como é que temos alguns contextos padrão que deve _sempre_ estar disponível em todos os templates? +```ts +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', + layoutPath: 'templates/layout', + helpersPath: 'templates/helpers', +}); +``` -O mais simplete caminho para alcançar isso é usando a opção `context` ao chamar `server.views()`: +### Contexto global -```javascript -const defaultContext = { - title: 'My personal site', -}; +`context` é incluído no contexto de todas as views, e o contexto da própria view prevalece quando os dois definem a mesma chave. Passe uma função para calculá-lo por requisição, por exemplo para mostrar o usuário logado em todas as páginas: +```ts server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // específico do motor - }, + html: Handlebars, }, - context: defaultContext, + relativeTo: import.meta.dirname, + path: 'templates', + context: (request) => ({ + siteName: 'My personal site', + user: request?.auth.credentials?.user ?? null, + }), }); ``` -O contexto global padrão se fundido com qualquer contexto local, pegando a menor precedência e aplicando para sua view. +`request` é undefined quando você renderiza fora de uma requisição com `server.render()`, e é por isso que a função o verifica. -### Ajudante de visão (view) +### Helpers de view -Os modulos de JavaScript definidos no `helpersPath` fica disponivel nos templates. -Por exemplo, nós criamos uma view helper `fortune` que irá escolher e imprimir um elemento de uma array de strings, quando usada em um tempalte. +Em engines que suportam helpers, como o handlebars, cada módulo em `helpersPath` vira um helper com o nome do arquivo. Este helper, salvo como `templates/helpers/fortune.js`, retorna uma frase da sorte aleatória: -O código a seguir é uma função helper que irá armazerna em um arquivo chamado `fortune.js` dentro do diretório `helpers`. - -```javascript -module.exports = function () { +```js +export default function () { const fortunes = [ 'Heisenberg may have slept here...', 'Wanna buy a duck?', @@ -215,103 +194,117 @@ module.exports = function () { 'Fortune favors the lucky.', 'Have a nice day!', ]; + const x = Math.floor(Math.random() * fortunes.length); return fortunes[x]; -}; +} ``` -Agora nos podemos usar o view helper dentro do nosso template. Aqui está um trecho de código para mostrar a função helper no `templates/index.html` usando handlebars como o motor de renderização: +Os helpers continuam sendo arquivos JavaScript: o vision os carrega com `require()` e ignora qualquer arquivo cuja extensão o Node.js não registrou em `require.extensions`, o que não inclui `.ts`. + +Use-o em `templates/index.html`: ```html

Your fortune

-

{{fortune}}

+

{{fortune}}

``` -Agora quando nós iniciamos o servidor e apontar o seu browser para a rota que utiliza seu template (que usa nossa view helper), devemos ver um parágrafo com um frase escolhida randomicamente logo abaixo do cabeçalho. - -Para referência, este é um script de servidor complete para usar o método fortune view helper em um template. - -```javascript -'use strict'; - -const Hapi = require('hapi'); +### Layouts -const server = new Hapi.Server(); +Os layouts do vision vêm desativados por padrão, porque muitas engines têm o próprio sistema de layouts. Use um ou outro. Para ativar os do vision: -server.connection({ - port: Number(process.argv[2] || 8080), - host: 'localhost', -}); - -server.register(require('vision'), (err) => { - Hoek.assert(!err, err); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - helpersPath: 'helpers', - }); - - server.route({ - method: 'GET', - path: '/', - handler: function (request, reply) { - reply.view('index'); - }, - }); -}); - -server.start(); -``` - -### Layouts - -Hapi inclui suporte embutido para view layouts. Isto vem desabilitado por padrão, porque isto pode entrar em conflito com outros sistemas de layout que alguns motores podem disponibilizar. Nós recomendamos escolher somente um sistema de layout. - -Podemos usar o sistema de layout embutido, primeiro configure a view: - -```javascript +```ts server.views({ - // ... + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', layout: true, - layoutPath: Path.join(__dirname, 'views/layout'), + layoutPath: 'templates/layout', }); ``` -Isto permite os layouts embutidos e definidos o layout de páginas padrão para `views/layout/layout.html` (ou qualquer outra extensão que você estiver utilizando). - -Estabelecer uma área de conteúdo em seu `layout.html`: +`templates/layout/layout.html` marca com `content` onde cada view entra: ```html - {{{content}}} + {{{content}}} ``` -E seu view deve ter somente o conteúdo: +e uma view contém só a sua parte: ```html
Content
``` -Quando renderizamos a view, o `{{{content}}}` irá replicar pelo conteúdo da view. +| Para | Faça | +| ------------------------------ | --------------------------------------------------------- | +| usar outro layout padrão | `layout: 'another_default'` em `server.views()` | +| usar outro layout em uma view | `h.view('myview', context, { layout: 'another_layout' })` | +| renderizar uma view sem layout | `h.view('myview', context, { layout: false })` | -Se você quiser uma layout padrão diferente, você pode definir uma opção globalmente: +Seu contexto não pode ter uma chave `content` enquanto os layouts estiverem ativos; o vision usa esse nome para a view renderizada e falha com `500`. Mude o nome com a opção `layoutKeyword`. -```javascript -server.views({ - // ... - layout: 'another_default', +## Renderizando uma view + +### h.view() + +Use [`h.view()`](/module/vision#h-view-template-context-options) quando o handler tem trabalho a fazer antes, como carregar dados: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, }); ``` -Você também pode especificar um layout diferente por view: +`h.view()` retorna um objeto de resposta comum, então você pode encadear `.code()`, `.header()` e os outros métodos do [objeto de resposta](/api#response-object). + +### Handler de view + +Quando uma rota só renderiza um template, use o handler `view`: -```javascript -reply.view('myview', null, { layout: 'another_layout' }); +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'My home page', + }, + }, + }, +}); +``` + +`handler: { view: 'index' }` é a forma curta. O handler de view sempre adiciona `params`, `payload`, `query` e `pre` ao contexto, então um template em `/users/{id}` pode exibir `params.id` sem nenhum código no handler. + +## Páginas de erro + +O hapi responde aos erros com JSON. Um site que renderiza HTML normalmente quer uma página de erro em HTML. Como a renderização acontece depois do `onPreResponse`, um hook nesse ponto pode trocar qualquer erro por uma view: + +```ts +import Boom from '@hapi/boom'; + +server.ext('onPreResponse', (request, h) => { + const response = request.response; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload } = response.output; + + return h.view('error', { statusCode, message: payload.message }).code(statusCode); +}); ``` + +Agora uma requisição para uma página inexistente recebe `templates/error.html`, renderizado dentro do layout, com status `404`. O hook também pega os erros lançados pelos handlers e as falhas de validação. Veja [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks) para saber mais sobre o `onPreResponse`. diff --git a/docs/tutorials/tr_TR/views.md b/docs/tutorials/tr_TR/views.md index c40ea569..9735e665 100644 --- a/docs/tutorials/tr_TR/views.md +++ b/docs/tutorials/tr_TR/views.md @@ -1,311 +1,310 @@ --- -title: Views +title: View'lar --- -## Views +# View'lar -_Bu kurs hapi v17 ile uyumludur_ +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ -hapinin kapsamlı bir şablon işleme desteği bulunur. Buna birden çok şablon motoru yükleyip kullanabilmek (template engine), kısmı şablonlar (partial), yardımcılar (helper function) (şablonlarda kullanılan ve veri işlemek için kullanılan işleçler) ve sayfa düzenleri de dahildir (layout). These capabilities are provided by the [vision](https://github.com/hapijs/vision) plugin. +## Genel Bakış -## Sunucuyu yapılandırmak +hapi HTML şablonlarını [vision](/module/vision) plugin'i aracılığıyla render eder. Vision, handlebars, pug ve ejs dahil `compile` fonksiyonu olan her şablon motoruyla çalışır (nunjucks gibi farklı bir `compile` fonksiyonuna sahip motorlar, [Motorlar](#engines) bölümünde anlatılan küçük bir sarmalayıcıya ihtiyaç duyar); partial'ları, helper'ları ve layout'ları da destekler. -Kullanıcı arayüzlerine (view) başlamak için önce en sunucuda en az bir şablon motoru (template engine) yapılandırmalıyız. Bu "vision" tarafından sunulan [`server.views()`](https://github.com/hapijs/vision/blob/master/API.md#serverviewsoptions) yöntemi ile yapılıyor. +## Vision -```javascript -'use strict'; +vision'ı ve bir motoru kur: -const Path = require('path'); -const Hapi = require('@hapi/hapi'); -const Hoek = require('@hapi/hoek'); - -const server = Hapi.server(); - -const start = async () => { - await server.register(require('@hapi/vision')); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - }); -}; - -start(); +```sh +npm install @hapi/vision handlebars ``` -Burda bir çok şey yapıyoruz. Önce [vision](https://github.com/hapijs/vision) modülünü bir eklenti olarak yüklüyoruz. O da hapiye şablon işleme desteği katıyor. +vision'ı kaydetmek şunları ekler: + +| Ne | Ne için kullanılır | +| ------------------------------------- | ----------------------------------------------------------- | +| `server.views(options)` | motorları, şablon klasörlerini ve layout'ları yapılandırmak | +| `h.view(template, context)` | bir handler'dan şablon render etmek | +| `view` handler'ı | yalnızca bir şablon render eden bir route | +| `server.render()`, `request.render()` | bir şablonu string'e render etmek, örneğin bir e-posta için | -İkinci olarak, `handlebars`ı; `.html` uzantılı şablonları işlemekten sorumlu modül olarak kaydediyoruz. +`@hapi/vision`'ı import etmek bunları hapi'nin TypeScript tiplerine de ekler. API'nin tamamı [vision API referansında](/module/vision). -Daha sonra, sunucuya şablonların `templates` (şablonlar) dizininde olduğunu söylüyoruz. `relativeTo` seçeneğini kullanarak bu dizinin şu anda bulunulan dizine bağıl olarak bulunması gerektiğini belirtiyoruz. Varsayılan olarak hapi şablonlar dizinini şu anda çalışılan dizine bağıl olarak arar. +## Sunucuyu yapılandırmak -### Kullanıcı arayuzu (view) secenekleri +```ts +import Hapi from '@hapi/hapi'; +import Vision from '@hapi/vision'; +import Handlebars from 'handlebars'; -hapi kullanıcı arayüzü motoru ile ilgili bir çok seçenek bulunur. Tam dokümantasyonu [vision API referansı](https://github.com/hapijs/vision/blob/master/API.md#serverviewsoptions)nda bulunuyor ancak biz burada da bir kısmından bahsedeceğiz. +const server = Hapi.server({ port: 3000 }); -Unutmayın ki tüm seçenekler kayıtlı tüm motorları yapılandıracak şekilde evrensel olarak ayarlanabileceği gibi, belli bir motoru yapılandırmak üzere yerel olarak da ayarlanabilir. Örneğin: +await server.register(Vision); -```javascript server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // motora özel - }, + html: Handlebars, }, - compileMode: 'async', // evrensel ayar + relativeTo: import.meta.dirname, + path: 'templates', }); -``` -### Motorlar +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); -hapida kullanıcı arayüzleri kullanmak için sunucuya en az bir şablon motoru kaydetmelisin. Şablon motorlarının çıktı adı (export name) `compile` olmalıdır. Senkron da asenkron da olabilirler. +await server.start(); +``` -Senkron bir motorun `compile` yönteminin imzası `function (template, options)` olmalıdır ve bu yöntem de imzası `function (context, options)` olan, derlenen htmli dönen ya da hata fırlatan bir işlev dönmelidir. +- `engines` bir dosya uzantısını bir motorla eşler: `.html` ile biten dosyalar handlebars ile render edilir. +- `path`, şablonları tutan klasördür ve `relativeTo`'ya göre çözümlenir. ES modüllerinde `__dirname` yoktur; `import.meta.dirname` mevcut dosyanın klasörüdür. +- `h.view('index', ...)`, `templates/index.html` dosyasını render eder. Tek bir motor kayıtlıysa uzantıyı yazmayabilirsin. -Asenkron bir motorun `compile` yöntemi imzası `function (template, options, callback)` olan; standart önce-hata formatında `callback` (geri çağır) çağrısı yapan ve imzası `function (context, options, callback)` olan bir fonksiyon dönen bir yöntem olmalıdır. Dönen yöntem de aynı şekilde önce-hata formatında ikinci değiştirgesi derlenen html olacak şekilde `callback` çağrısı yapmalıdır. +### Bir view nasıl render edilir -Varsayılan olarak hapi şablon motorlarının senkron olduğu varsayar (`compileMode` varsayılanı `sync`), asenkron bir motor kullanmak için `compileMode`u `async` olarak ayarlamalısın. +`h.view()` hemen bir şey render etmez. Bir view yanıtı döndürür ve vision onu hapi yanıtı gönderirken render eder: -`options` değiştirgesinin gücünü `compile` yöntemi ve döndürdüğü yöntemde kullanmak için `compileOptions` ve `runtimeOptions` ayarları kullanılır. Her iki seçeneğin varsayılan değeri boş bir nesnedir `{}`. +```mermaid +flowchart TD + A["handler h.view('index', context) döndürür"] --> B["vision templates/index.html dosyasını
bulur ve derler (önbellekli)"] + B --> C[onPreResponse hook'ları] + C --> D["global context'i
view'in context'iyle birleştir"] + D --> E[şablonu render et] + E --> F[layout ile sarmala] + F --> G["text/html olarak gönder"] +``` -`compileOptions`, `compile` yöntemine gönderilen ikinci değiştirgeyken; `runtimeOptions`, `compile` tarafından döndürülen yönteme gönderilir. +Bu sıranın pratikteki anlamı: + +- Eksik bir şablon, handler döner dönmez, herhangi bir `onPreResponse` hook'undan önce `500` ile başarısız olur. +- Bir `onPreResponse` hook'u render işleminden önce çalışır, bu yüzden bir view'i hâlâ farklı bir yanıtla, örneğin bir hata sayfasıyla değiştirebilir. Aşağıdaki [Hata sayfaları](#errors) bölümüne bak. +- [Yanıt Hazırlama](/tutorials/tr_TR/response-marshalling) eğitimi bu hazırlama ve render ayrımını tüm yanıt tipleri için anlatıyor. + +## server.views() seçenekleri + +| Seçenek | Varsayılan | Etkisi | +| --------------------- | -------------- | ----------------------------------------------------------------------------- | +| `engines` | zorunlu | uzantıdan motora eşleme, örneğin `{ html: Handlebars, pug: Pug }` | +| `path` | yok | şablonların bulunduğu klasör (ya da klasör dizisi) | +| `relativeTo` | çalışma dizini | diğer tüm path seçenekleri için temel klasör | +| `partialsPath` | yok | partial'ların bulunduğu klasör | +| `helpersPath` | yok | helper modüllerinin bulunduğu klasör | +| `layout` | `false` | `layout.html` kullanmak için `true` ya da başka bir layout dosyasının adı | +| `layoutPath` | yok | layout'ların bulunduğu klasör | +| `context` | yok | her view'e birleştirilen context; bir nesne ya da isteği alan bir fonksiyon | +| `isCached` | `true` | `false` şablonları her istekte okur ve derler; geliştirme sırasında işe yarar | +| `defaultExtension` | tek motor | `h.view()` uzantısız bir ad aldığında kullanılan uzantı | +| `compileMode` | `'sync'` | `compile` fonksiyonu callback alan motorlar için `'async'` | +| `allowAbsolutePaths` | `false` | mutlak şablon adlarına izin verir | +| `allowInsecureAccess` | `false` | şablon adlarında `../` kullanımına izin verir | + +`server.views()` üzerinde ayarlanan seçenekler tüm motorlara uygulanır: + +```ts +server.views({ + engines: { html: Handlebars }, + relativeTo: import.meta.dirname, + path: 'templates', + isCached: process.env.NODE_ENV === 'production', +}); +``` -Eğer yalnızca bir şablon motoru kayıtlıysa, otomatik olarak varsayılan haline getirilerek kullanıcı arayüzlerini isterken dosya uzantılarıyla uğraşmamanız sağlanmış olur. Ancak birden fazla motor kayıtlıysa ya dosya uzantılarını sona eklemelisin ya da en çok kullandığın motor için `defaultExtension` ayarı yapmalısın. Yine de bu durumda varsayılan motoru kullanmayan kullanıcı arayüzlerini çağırırken dosya uzantısı belirtmek gerekecek. +Burada şablonlar `templates/` klasöründen yüklenir; production dışında vision onları her istekte yeniden okuyup derler, böylece şablon değişiklikleri yeniden başlatmadan görünür. -Bir diğer kullanışlı seçencek `isCached`dir. Eğer `false` olarak ayarlanırsa hapi şablon sonuçlarını önbelleklemez ve bunun yerine her kullanıldığında şablonu dosyasından tekrar tekrar okur. Uygulamanı geliştirirken bu çok işe yarıyor çünkü seni şablonlarla uğraşırken uygulamanı tekrar tekrar yeniden başlatmaktan kurtarıyor. Tabi canlı ortamda `isCached` seçeneğinin `true` olarak ayarlanmasını öneriyoruz. +### Motorlar -### Guzergahlar (paths) +Motor, `compile` fonksiyonu olan herhangi bir nesnedir: -Kullanıcı arayüzü dosyaları birden fazla yerde bulunabileceğinden hapi onları bulmak için birden fazla güzergah belirlenmesine izin verir. Bu seçenekler: +| `compileMode` | `compile` imzası | Döndürdüğü | +| --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `'sync'` (varsayılan) | `compile(template, compileOptions)` | HTML döndüren `render(context, runtimeOptions)` | +| `'async'` | `compile(template, compileOptions, callback)` | `callback(err, render)` çağırır; `render(context, runtimeOptions, callback)` de `callback(err, html)` çağırır | -- `path`: ana şablonlarının bulunduğu dizin -- `partialsPath`: kısmi şablonlarının bulunduğu dizin -- `helpersPath`: şablon yardımcılarının bulunduğu dizin -- `layoutPath`: sayfa düzeni şablonlarının bulunduğu dizin -- `relativeTo`: diğer dizin tiplerinin başına eklemek için kullanılır. Eğer belirtilirse diğer dizinler bu dizine bağlı dizinler olabilir +`compileOptions` ve `runtimeOptions`, motoruna aktarılan `server.views()` seçenekleridir. API'si uymayan bir motor, bu `compile` fonksiyonuna sahip bir nesneyle sarmalanabilir. -Ayrıca, hapinin güzergahların nasıl kullanacağına izin verdiğini ayarlamak için iki ayar bulunur. Varsayılan olarak tam güzergahlara ve `path` (güzergah) dizininin dışına doğru giden güzergahlara izin verilmez. Bu davranışlar `allowAbsolutePaths` (tam güzergahlara izin ver) ve `allowInsecureAccess` (güvensiz erişime izin ver) ayarları `true` olarak ayarlanarak değiştirilebilir. +Birden fazla motor kayıtlıysa, en çok kullandığını `defaultExtension` olarak ayarlamadıkça `h.view()` uzantıya ihtiyaç duyar (`h.view('index.pug')`). -Örneğin eğer dizin yapın şu şekildeyse: +### Path'ler + +Şu klasör yapısıyla: ``` -templates\ - index.html - layout\ - layout.html - helpers\ - getUser.js - fortune.js +templates/ + index.html + layout/ + layout.html + helpers/ + fortune.js ``` -Ayarların şöyle olabilir: +yapılandırma şöyledir: -```javascript +```ts server.views({ engines: { - html: require('handlebars'), + html: Handlebars, }, - relativeTo: __dirname, - path: './templates', - layoutPath: './templates/layout', - helpersPath: './templates/helpers', + relativeTo: import.meta.dirname, + path: 'templates', + layoutPath: 'templates/layout', + helpersPath: 'templates/helpers', }); ``` -## Bir kullanici arayuzunu yorumlamak (rendering views) - -Bir kullanıcı arayüzünü yorumlarken iki seçeneğin var. Ya `h.view()` yöntemini kullanırsın ki burada `h` [yanıt alet çantası (response toolkit)](/api#response-toolkit)tir ya da kullanıcı arayüzü işleyiciyi. +### Global context -### [`h.view()`](https://github.com/hapijs/vision/blob/master/API.md#hviewtemplate-context-options) +`context` her view'in context'ine birleştirilir; ikisi aynı anahtarı ayarladığında view'in kendi context'i kazanır. Bunu istek başına hesaplamak için bir fonksiyon ver; örneğin her sayfada oturum açmış kullanıcıyı göstermek için: -Bakacağımız ilk kullanıcı arayüzü yorumlayan yöntem `h.view()`. Hemen aşağıda bu yöntemi kullanan bir yol (route)un nasıl görüneceğini görebilirsin: - -```javascript -server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return h.view('index'); +```ts +server.views({ + engines: { + html: Handlebars, }, + relativeTo: import.meta.dirname, + path: 'templates', + context: (request) => ({ + siteName: 'My personal site', + user: request?.auth.credentials?.user ?? null, + }), }); ``` -`h.view()`e bir kapsam göndermek istersen, ikinci değiştirge olarak bir nesne gönderebilirsin. Örneğin: +`server.render()` ile bir istek dışında render ettiğinde `request` undefined olur; fonksiyonun onu kontrol etmesinin nedeni budur. + +### View helper'ları + +handlebars gibi helper destekleyen motorlarda `helpersPath` içindeki her modül, dosya adıyla adlandırılmış bir helper olur. `templates/helpers/fortune.js` olarak kaydedilen bu helper rastgele bir fal döndürür: + +```js +export default function () { + const fortunes = [ + 'Heisenberg may have slept here...', + 'Wanna buy a duck?', + 'Say no, then negotiate.', + 'Time and tide wait for no man.', + 'To teach is to learn.', + 'Never ask the barber if you need a haircut.', + 'You will forget that you ever knew me.', + 'You will be run over by a beer truck.', + 'Fortune favors the lucky.', + 'Have a nice day!', + ]; -```javascript -return h.view('index', { title: 'My home page' }); + const x = Math.floor(Math.random() * fortunes.length); + return fortunes[x]; +} ``` -### Kullanici arayuzu isleyici (view handler) - -Kullanıcı arayüzü işlemenin ikinci yöntemi, hapi'nin hazır gelen kullanıcı arayüzü işleyicisini kullanmaktır. Bu yol (route) şöyle görünür: - -```javascript -server.route({ - method: 'GET', - path: '/', - handler: { - view: 'index', - }, -}); -``` +Helper'lar JavaScript dosyası olarak kalır: vision onları `require()` ile yükler ve uzantısı Node.js tarafından `require.extensions` ile kaydedilmemiş her dosyayı atlar; `.ts` de bunlardan biridir. -Kullanıcı arayüzünü işlerken, kapsam `context` (kapsam) anahtarıyla gönderilir. Örneğin: +Onu `templates/index.html` içinde kullan: -```json5 -handler: { - view: { - template: 'index', - context: { - title: 'My home page' - } - } -} +```html +

Your fortune

+

{{fortune}}

``` -### Evrensel kapsam - -Bir kapsamı nasıl direkt kullanıcı arayüzüne göndereceğimize baktık ancak; ya tüm şablonlarda _her zaman_ elimizin altında olmasını istediğimiz varsayılan bir kapsam varsa? - -Bunu yapmanın en kolay yolu `server.views()` yöntemini çağırırken `context` (kapsam) seçeneğini kullanmaktır. +### Layout'lar -```javascript -const context = { - title: 'Benim sitem', -}; +Birçok motorun kendi layout sistemi olduğu için vision'ın layout'ları varsayılan olarak kapalıdır. İkisinden birini kullan. vision'ınkini açmak için: +```ts server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // motora özgü - }, + html: Handlebars, }, - context, + relativeTo: import.meta.dirname, + path: 'templates', + layout: true, + layoutPath: 'templates/layout', }); ``` -Varsayılan evrensel kapsam gönderilen her yerel kapsamla en düşük önceliği alacak şekilde birleştirilerek kullanıcı arayüzüne uygulanır. - -### Kullanici arayuzu yardimcilari - -`helpersPath` altında bulunan tüm JavaScript modüllerine şablonlardan erişilebilir. Örnek olarak kullanıcı arayüzünde kullanıldığında bir dizi metinndan birini seçerek yazdıran `fortune` adında bir kullanıcı arayüzü yardımcısı oluşturacağiz. - -Aşağıdaki kod bloğu `fortune.js` içerisinde `helpers` dizininde saklayacağımız tam bir yardımcı işlevdir. - -```javascript -module.exports = function () { - const fortune = [ - 'Tolga burada uyumuş olabilir...', - 'Ördek lazım mı?', - 'Önce hayır de, sonra pazarlık yap.', - 'Sona kalan dona kalır.', - 'Öğretmek öğrenmektir.', - 'Elinde çekiç olan her şeyi çivi sanır', - 'Beni tanıdığın için kendini affedeceksin', - 'Eller günahkar diller günahkar.', - 'Talih şanslıdan yanadır.', - 'İyi günler!', - ]; +`templates/layout/layout.html`, her view'in nereye gideceğini `content` ile işaretler: - const x = Math.floor(Math.random() * fortune.length); - return fortune[x]; -}; +```html + + + {{{content}}} + + ``` -Artık kullanıcı arayüzü yardımcımızı şablonumuzda kullanabiliriz. Aşağıda `templates/index.html` içerisinde handlebars kullanıcı arayüzü yorumlama motorunu ve yardımcı işlevi kullanan bir kod bloğu var. +view ise yalnızca kendi parçasını tutar: ```html -

Kısmetin

-

{{fortune}}

+
Content
``` -Artık sunucumuzu çalıştırdığımızda ve tarayıcımızı şablonu kullanan bir yola (route) yönlendirğimizde (kullanıcı arayüzü yardımcımızı kullanan), başlığın hemen altında rasgele çekilmiş bir kısmet paragrafı görmeliyiz. - -Aşağıda referans olarak, şablonunda fortune (kısmet) kullanıcı arayüzü yardımcısını kullanan tam bir sunucu programcığını bulabilirsin. - -```javascript -'use strict'; +| Amaç | Yapılacak | +| ------------------------------------------ | --------------------------------------------------------- | +| başka bir varsayılan layout kullanmak | `server.views()` içinde `layout: 'another_default'` | +| tek bir view için başka layout kullanmak | `h.view('myview', context, { layout: 'another_layout' })` | +| tek bir view'i layout olmadan render etmek | `h.view('myview', context, { layout: false })` | -const Hapi = require('@hapi/hapi'); +Layout'lar açıkken context'inde `content` anahtarı olamaz; vision bu adı render edilen view için kullanır ve `500` ile başarısız olur. Adı `layoutKeyword` seçeneğiyle değiştir. -const server = Hapi.server({ port: 8080 }); +## Bir view render etmek -const start = async () => { - await server.register(require('@hapi/vision')); +### h.view() - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - helpersPath: 'helpers', - }); - - server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return h.view('index'); - }, - }); -}; +Handler'ın önce yapacağı bir iş olduğunda, örneğin veri yüklemek gibi, [`h.view()`](/module/vision#h-view-template-context-options) kullan: -start(); +```ts +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); ``` -### Sayfa duzenleri (layouts) +`h.view()` normal bir response nesnesi döndürür, bu yüzden `.code()`, `.header()` ve diğer [response nesnesi](/api#response-object) metotlarını zincirleyebilirsin. -Sayfa düzeni sunan bazı diğer kullanıcı arayüzü motorları ile olası çakışmaların önüne geçmek için kapalı olarak gelse de vision; kullanıcı arayüzü sayfa düzenleri için yerleşik destek sunar. Biz yalnızca bir sayfa düzeni sistemi kullanılmasını tavsiye ediyoruz. +### View handler'ı -Yerleşik olarak gelen sayfa düzeni sistemini kullanmak için önce kullanıcı arayüzü motorunu kur: +Bir route yalnızca bir şablon render ediyorsa `view` handler'ını kullan: -```javascript -server.views({ - // ... - layout: true, - layoutPath: 'templates/layout', +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'My home page', + }, + }, + }, }); ``` -Bu, yerleşik sayfa düzenlerini aktifleştiri ve varsayılan sayfa düzeni olarak `templates/layout/layout.html` dosyasını ayarlar. (ya da artık uzantı olarak ne kullanıyorsan). - -`layout.html` içerisinde içerik alanını belirle: +`handler: { view: 'index' }` kısa biçimdir. View handler'ı context'e her zaman `params`, `payload`, `query` ve `pre` ekler; böylece `/users/{id}` üzerindeki bir şablon, hiç handler kodu yazmadan `params.id` değerini yazdırabilir. -```html - - - {{{content}}} - - -``` +## Hata sayfaları -Ve kullanıcı arayüzüne yalnızca içeriği koy: +hapi hatalara JSON ile yanıt verir. HTML render eden bir site genellikle bunun yerine bir HTML hata sayfası ister. Render işlemi `onPreResponse` sonrasında gerçekleştiği için oradaki bir hook herhangi bir hatayı bir view ile değiştirebilir: -```html -
Content
-``` +```ts +import Boom from '@hapi/boom'; -Kullanıcı arayüzünü yorumlarken, `{{{content}}}` ile kullanıcı arayüzünün içeriği değiştirilecek. +server.ext('onPreResponse', (request, h) => { + const response = request.response; + if (!Boom.isBoom(response)) { + return h.continue; + } -Varsayılan bir sayfa düzeni ayarlamak istersen bunu evrensel olarak yapabilirsin: + const { statusCode, payload } = response.output; -```javascript -server.views({ - // ... - layout: 'another_default', + return h.view('error', { statusCode, message: payload.message }).code(statusCode); }); ``` -Kullanıcı arayüzlerine özel sayfa düzenleri de ayarlayabilirsin: - -```javascript -return h.view('myview', null, { layout: 'another_layout' }); -``` +Artık eksik bir sayfa için gelen istek, layout içinde render edilmiş `templates/error.html` dosyasını `404` durum koduyla alır. Hook ayrıca handler'ların fırlattığı hataları ve başarısız doğrulamaları da yakalar. `onPreResponse` hakkında daha fazlası için [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks) eğitimine bak. diff --git a/docs/tutorials/zh_CN/views.md b/docs/tutorials/zh_CN/views.md index 909833a1..61b80850 100644 --- a/docs/tutorials/zh_CN/views.md +++ b/docs/tutorials/zh_CN/views.md @@ -1,206 +1,187 @@ --- -title: Views +title: 视图 --- -## 视图 +# 视图 -_该教程适用于 hapi v17版本_ +_本教程适用于 hapi v21 及更高版本_ -hapi 对模板渲染有广泛的支持,包括加载以及使用多个模板引擎、部分渲染、辅助工具 (模板中用于操作数据的函数)以及布局功能。这些功能由 [vision](https://github.com/hapijs/vision) 插件提供。 +## 概述 -## 配置服务器 +hapi 通过 [vision](/module/vision) 插件渲染 HTML 模板。vision 支持任何带有 `compile` 函数的模板引擎,包括 handlebars、pug 和 ejs(`compile` 签名不同的引擎,例如 nunjucks,需要用[引擎](#engines)一节介绍的小包装器),并支持 partial、helper 和布局。 -如果要使用视图,首先需要在服务器上配置一个模板引擎。 这可以通过 vision 提供的 [`server.views()`](https://github.com/hapijs/vision/blob/master/API.md#serverviewsoptions) 方法来完成: +## Vision -```javascript -'use strict'; +安装 vision 和一个模板引擎: -const Path = require('path'); -const Hapi = require('@hapi/hapi'); -const Hoek = require('@hapi/hoek'); - -const server = Hapi.server(); - -const start = async () => { - await server.register(require('@hapi/vision')); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - }); -}; - -start(); +```sh +npm install @hapi/vision handlebars ``` -我们在这里做了许多工作: -首先,我们加载 [vision](https://github.com/hapijs/vision) 插件,它可以为hapi提供模板渲染引擎。 +注册 vision 后会多出: + +| 内容 | 用途 | +| ------------------------------------- | -------------------------------- | +| `server.views(options)` | 配置引擎、模板文件夹和布局 | +| `h.view(template, context)` | 在 handler 中渲染模板 | +| `view` handler | 只渲染模板的路由 | +| `server.render()`、`request.render()` | 把模板渲染成字符串,例如用于邮件 | -其次,我们注册 `handlebars` 模块为引擎提供渲染 `.html` 文件的能力。 +导入 `@hapi/vision` 也会把这些内容加入 hapi 的 TypeScript 类型。完整 API 见 [vision API 参考](/module/vision)。 -最后,我们告诉服务器我们的模板在 `templates` 目录下。我们可以通过 `relativeTo` 选项指定相对路径,默认情况下 hapi 在当前工作目录下查找模板文件。 +## 配置服务器 -### 视图选项 +```ts +import Hapi from '@hapi/hapi'; +import Vision from '@hapi/vision'; +import Handlebars from 'handlebars'; -hapi 的视图引擎有众多的选项。完整的文档可以在这里找到 [vision API reference](https://github.com/hapijs/vision/blob/master/API.md#serverviewsoptions),我们这里只示例一部分。 +const server = Hapi.server({ port: 3000 }); -这些选项可以全局配置,也可以配置给指定的引擎,例如: +await server.register(Vision); -```javascript server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // 引擎特定配置 - }, + html: Handlebars, }, - compileMode: 'async', // 全局配置 + relativeTo: import.meta.dirname, + path: 'templates', }); -``` - -### 引擎 - -如果需要在 hapi 中使用视图,你需要在服务器中至少注册一个模板引擎。引擎可以是同步的也可以是异步的,并且这个对象需要以 `compile` 为名导出。 - -同步引擎 `compile` 方法的签名为 `function (template, options)`。 并且这个方法需要返回一个签名为 `function (context, options)` 的函数,在函数内或者抛出错误或者返回编译后的 html 文件。 - -异步引擎 `compile` 方法的签名为 `function (template, options, callback)` 它以标准错误优先格式调用 `callback` 并返回一个带有签名`function (context,options,callback)` 的新方法。返回的方法也应该以错误优先格式调用 `callback`,而编译的 html 是第二个参数。 - -默认情况下 hapi 认为所有的模板引擎都是同步的 (例如 `compileMode` 默认是 `sync`), 如果使用异步引擎你需要将 `compileMode` 设置为 `async`。 -想要修改两种 `compile` 方法中的 `options` 参数或者返回方法,可以通过 `compileOptions` 和 `runtimeOptions`设置来完成。这两个选项都默认为空对象 `{}`。 -`compileOptions` 是作为第二个参数传递给 `compile` 的对象, 而 `runtimeOptions` 传递给 `compile` 返回的方法。 - -如果只注册了一个模板引擎,他将自动成为默认值,允许在渲染视图的时候不使用文件扩展名。但是如果过注册了多个模板引擎,则必须使用文件扩展名,或者将 `defaultExtension` 设置为最常用的引擎。对于不使用默认引擎的任何视图,依然需要指定文件扩展名。 - -另外一个有用的选项是 `isCached`。如果设置为 `false` , hapi 将不会缓存模板引擎渲染的结果,而是每次都去读取模板文件并渲染。 在开发你的应用时, 这个设置非常方便,它可以防止您在处理模板时重新启动应用程序。 我们建议在生产环境中将 `isCached` 的值设置为 `true` 。 - -### 路径 +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); -由于视图可以包含多个不同位置的文件, 因为hapi 提供了一些选项帮助你配置多个路径用于内容查找: +await server.start(); +``` -- `path` : 包含你主要模板的路径 -- `partialsPath` : 部分内容的路径 -- `helpersPath` : 模板辅助工具的路径 -- `layoutPath` : 布局模板的路径 -- `relativeTo` : 用于指定其他路径的前缀。当指定后,其余路径则都可以相对于此路径 +- `engines` 把文件扩展名映射到引擎:以 `.html` 结尾的文件用 handlebars 渲染。 +- `path` 是存放模板的文件夹,从 `relativeTo` 开始解析。ES 模块中没有 `__dirname`;`import.meta.dirname` 就是当前文件所在的文件夹。 +- `h.view('index', ...)` 渲染 `templates/index.html`。只注册了一个引擎时,可以省略扩展名。 -此外, 还有两个设置可以改变 hapi 允许你使用路径的方式。默认情况下,不允许使用绝对路径和遍历 `path` 目录,但是可以通过将 `allowAbsolutePaths` 和 `allowInsecureAccess` 设置为 true 来开启这些功能。 +### 视图是如何渲染的 -例如你的目录结构如下: +`h.view()` 不会立即渲染任何内容。它返回一个视图响应,由 vision 在 hapi 发送响应时进行渲染: +```mermaid +flowchart TD + A["handler 返回 h.view('index', context)"] --> B["vision 查找并编译
templates/index.html(有缓存)"] + B --> C[onPreResponse 钩子] + C --> D["把全局上下文
与视图的上下文合并"] + D --> E[渲染模板] + E --> F[套上布局] + F --> G["以 text/html 发送"] ``` -templates\ - index.html - layout\ - layout.html - helpers\ - getUser.js - fortune.js -``` - -你可以如下去配置: -```javascript +这个顺序在实际中意味着: + +- 模板不存在时,handler 一返回就会以 `500` 失败,早于任何 `onPreResponse` 钩子。 +- `onPreResponse` 钩子在渲染之前运行,所以它仍然可以把视图替换成其他响应,例如错误页。参见下文的[错误页](#errors)。 +- [响应编组](/tutorials/zh_CN/response-marshalling)介绍了所有响应类型都遵循的这种先准备、后渲染的划分。 + +## server.views() 选项 + +| 选项 | 默认值 | 作用 | +| --------------------- | -------------- | ----------------------------------------------------------------- | +| `engines` | 必填 | 扩展名到引擎的映射,例如 `{ html: Handlebars, pug: Pug }` | +| `path` | 无 | 存放模板的文件夹(或文件夹数组) | +| `relativeTo` | 工作目录 | 其他所有路径选项的基准文件夹 | +| `partialsPath` | 无 | 存放 partial 的文件夹 | +| `helpersPath` | 无 | 存放 helper 模块的文件夹 | +| `layout` | `false` | `true` 表示使用 `layout.html`,也可以是其他布局文件的名称 | +| `layoutPath` | 无 | 存放布局的文件夹 | +| `context` | 无 | 合并到每个视图中的上下文;可以是对象,也可以是接收 request 的函数 | +| `isCached` | `true` | `false` 表示每个请求都重新读取并编译模板;在开发时很方便 | +| `defaultExtension` | 唯一的那个引擎 | `h.view()` 收到不带扩展名的名称时使用的扩展名 | +| `compileMode` | `'sync'` | 对于 `compile` 接收回调的引擎,使用 `'async'` | +| `allowAbsolutePaths` | `false` | 允许使用绝对路径的模板名 | +| `allowInsecureAccess` | `false` | 允许模板名中出现 `../` | + +在 `server.views()` 上设置的选项会应用到所有引擎: + +```ts server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: './templates', - layoutPath: './templates/layout', - helpersPath: './templates/helpers', + engines: { html: Handlebars }, + relativeTo: import.meta.dirname, + path: 'templates', + isCached: process.env.NODE_ENV === 'production', }); ``` -## 渲染一个视图 - -渲染一个视图有两种选择:你可以使用 `h.view()` 方法,这里的 `h` 是 [response toolkit](/api#response-toolkit) 。而另一种方法是通过视图 handler。 +这里模板从 `templates/` 加载,在非生产环境中 vision 每个请求都会重新读取并编译模板,因此修改模板后无需重启就能看到效果。 -### [`h.view()`](https://github.com/hapijs/vision/blob/master/API.md#hviewtemplate-context-options) +### 引擎 -首先我们来看一下如何使用 `h.view()` 渲染视图。 使用这种方法的路由配置如下: +引擎是任何带有 `compile` 函数的对象: -```javascript -server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return h.view('index'); - }, -}); -``` +| `compileMode` | `compile` 签名 | 返回 | +| ---------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `'sync'`(默认) | `compile(template, compileOptions)` | `render(context, runtimeOptions)`,它返回 HTML | +| `'async'` | `compile(template, compileOptions, callback)` | 调用 `callback(err, render)`;`render(context, runtimeOptions, callback)` 会调用 `callback(err, html)` | -你可以通过第二个参数向 `h.view()` 传递要渲染的内容,如: +`compileOptions` 和 `runtimeOptions` 是透传给引擎的 `server.views()` 选项。API 不匹配的引擎可以包装成一个带有这种 `compile` 函数的对象。 -```javascript -return h.view('index', { title: 'My home page' }); -``` +注册了多个引擎时,`h.view()` 需要带上扩展名(`h.view('index.pug')`),除非你把 `defaultExtension` 设为最常用的那个。 -### 视图 handler +### 路径 -第二种是可以通过使用 hapi 内建的视图 handler 来渲染。 这种方式的路由如下配置: +对于这样的文件夹结构: -```javascript -server.route({ - method: 'GET', - path: '/', - handler: { - view: 'index', - }, -}); ``` - -当使用视图 handler 时,内容作为 `context` 的值传入, 如: - -```json5 -handler: { - view: { - template: 'index', - context: { - title: 'My home page' - } - } -} +templates/ + index.html + layout/ + layout.html + helpers/ + fortune.js ``` -### 全局内容 +配置如下: -我们已经知道了如何传递内容去一个视图,但是我们有一些默认的内容 _都必须_ 显示在所有的模板上该如何呢? +```ts +server.views({ + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', + layoutPath: 'templates/layout', + helpersPath: 'templates/helpers', +}); +``` -最简单的方式是在调用 `server.views()` 使用 `context` 选项 : +### 全局上下文 -```javascript -const context = { - title: 'My personal site', -}; +`context` 会合并到每个视图的上下文中,两者设置了同一个键时,以视图自己的上下文为准。传入函数可以按请求计算上下文,例如在每个页面上显示已登录的用户: +```ts server.views({ engines: { - html: { - module: require('handlebars'), - compileMode: 'sync', // engine specific - }, + html: Handlebars, }, - context, + relativeTo: import.meta.dirname, + path: 'templates', + context: (request) => ({ + siteName: 'My personal site', + user: request?.auth.credentials?.user ?? null, + }), }); ``` -这个默认的全局内容将会以最低的优先级合并到你所有的视图中。 - -### 视图辅助工具 +在请求之外用 `server.render()` 渲染时,`request` 为 undefined,所以函数中要先检查它。 -位于定义在 `helpersPath` 中的 JavaScript 模块可以在模板中使用。在这个例子中,我们将创建一个视图 helper `fortune`,当在模板中使用时,它将从字符串数组中挑选并打印出一个元素。 +### 视图 helper -下面的代码片段是完整的帮助函数,我们将它存储在 `helpers` 目录中名为 `fortune.js` 的文件中。 +对于支持 helper 的引擎,例如 handlebars,`helpersPath` 中的每个模块都会成为一个以文件名命名的 helper。下面这个 helper 保存为 `templates/helpers/fortune.js`,它会随机返回一句签语: -```javascript -module.exports = function () { +```js +export default function () { const fortunes = [ 'Heisenberg may have slept here...', 'Wanna buy a duck?', @@ -216,96 +197,114 @@ module.exports = function () { const x = Math.floor(Math.random() * fortunes.length); return fortunes[x]; -}; +} ``` -现在我们可以在模板中使用刚刚创建的视图助手。 这里是一个在 `templates/index.html` 使用 handlebars 作为渲染引擎的代码示例: +helper 仍然是 JavaScript 文件:vision 用 `require()` 加载它们,并且会跳过扩展名没有在 Node.js 的 `require.extensions` 中注册的文件,而其中不包括 `.ts`。 + +在 `templates/index.html` 中使用它: ```html

Your fortune

-

{{fortune}}

-``` - -现在,当我们启动服务器并用浏览器访问使用我们模板的路径(使用我们的视图helper)时,我们应该会在标题下方看到一个随机显示的段落。 - -作为参考,这是一个完整的服务器代码,它将在模板中使用 fortune 视图 helper 方法。 - -```javascript -'use strict'; - -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ port: 8080 }); - -const start = async () => { - await server.register(require('@hapi/vision')); - - server.views({ - engines: { - html: require('handlebars'), - }, - relativeTo: __dirname, - path: 'templates', - helpersPath: 'helpers', - }); - - server.route({ - method: 'GET', - path: '/', - handler: function (request, h) { - return h.view('index'); - }, - }); -}; - -start(); +

{{fortune}}

``` -### 布局 - -vision 对于视图布局有一些内建的支持,这个功能默认是关闭的,因为它可能会和一些特定的模板引擎冲突,因此我们建议只使用一种布局系统。 +### 布局 -如果要使用内建的布局系统,首先需要设置视图引擎: +vision 的布局功能默认关闭,因为很多引擎有自己的布局系统。两者选其一即可。要开启 vision 的布局: -```javascript +```ts server.views({ - // ... + engines: { + html: Handlebars, + }, + relativeTo: import.meta.dirname, + path: 'templates', layout: true, layoutPath: 'templates/layout', }); ``` -这将开启内建的布局系统,并且将默认的布局页定义在 `templates/layout/layout.html` (或者你正在使用的其他扩展名)。 - -在你的 `layout.html` 中设置一块内容区域: +`templates/layout/layout.html` 用 `content` 标出每个视图插入的位置: ```html - {{{content}}} + {{{content}}} ``` -之后你的视图应该只包含有: +而视图只包含它自己的那部分: ```html
Content
``` -当渲染这个视图时 `{{{content}}}` 将会被视图中的内容替换。 +| 目的 | 做法 | +| ------------------------ | --------------------------------------------------------- | +| 使用另一个默认布局 | 在 `server.views()` 中设置 `layout: 'another_default'` | +| 为某个视图使用另一个布局 | `h.view('myview', context, { layout: 'another_layout' })` | +| 渲染某个视图时不使用布局 | `h.view('myview', context, { layout: false })` | -你可以通过全局配置来修改这个默认布局: +开启布局时,你的上下文中不能有 `content` 键;vision 用这个名字存放渲染后的视图,冲突时会以 `500` 失败。可以用 `layoutKeyword` 选项修改这个名字。 -```javascript -server.views({ - // ... - layout: 'another_default', +## 渲染视图 + +### h.view() + +当 handler 需要先做一些工作,例如加载数据时,使用 [`h.view()`](/module/vision#h-view-template-context-options): + +```ts +server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + return h.view('index', { title: 'My home page' }); + }, +}); +``` + +`h.view()` 返回一个普通的响应对象,所以你可以链式调用 `.code()`、`.header()` 以及其他[响应对象](/api#response-object)方法。 + +### View handler + +当路由只渲染模板时,使用 `view` handler: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'My home page', + }, + }, + }, }); ``` -你也可以为每一个视图指定一个布局: +`handler: { view: 'index' }` 是简写形式。view handler 总是会把 `params`、`payload`、`query` 和 `pre` 加入上下文,所以 `/users/{id}` 上的模板无需任何 handler 代码就能输出 `params.id`。 + +## 错误页 + +hapi 用 JSON 响应错误。渲染 HTML 的网站通常希望改用 HTML 错误页。由于渲染发生在 `onPreResponse` 之后,在这里注册的钩子可以把任何错误换成一个视图: -```javascript -return h.view('myview', null, { layout: 'another_layout' }); +```ts +import Boom from '@hapi/boom'; + +server.ext('onPreResponse', (request, h) => { + const response = request.response; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload } = response.output; + + return h.view('error', { statusCode, message: payload.message }).code(statusCode); +}); ``` + +现在请求一个不存在的页面会得到 `templates/error.html`,它在布局中渲染,状态码为 `404`。这个钩子也会捕获 handler 抛出的错误以及验证失败。关于 `onPreResponse` 的更多内容,请参阅[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)。 From ff2f3bdd0cff934e9e2f391fadbdf7ec1c8bfc51 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:54:57 -0400 Subject: [PATCH 17/22] docs: rework express to hapi guide TypeScript examples on both sides, Express 5 APIs, mapping tables in place of long prose, and translations updated. --- docs/tutorials/en_US/express-to-hapi.md | 675 +++++++++-------- docs/tutorials/es_ES/express-to-hapi.md | 922 ++++++++++++++++++++++++ docs/tutorials/ko_KR/express-to-hapi.md | 919 ++++++++++++++++++++++- docs/tutorials/pt_BR/express-to-hapi.md | 919 ++++++++++++++++++++++- docs/tutorials/tr_TR/express-to-hapi.md | 919 ++++++++++++++++++++++- docs/tutorials/zh_CN/express-to-hapi.md | 788 ++++++++++---------- 6 files changed, 4446 insertions(+), 696 deletions(-) create mode 100644 docs/tutorials/es_ES/express-to-hapi.md diff --git a/docs/tutorials/en_US/express-to-hapi.md b/docs/tutorials/en_US/express-to-hapi.md index 9e4b2480..91196a7c 100644 --- a/docs/tutorials/en_US/express-to-hapi.md +++ b/docs/tutorials/en_US/express-to-hapi.md @@ -6,39 +6,55 @@ title: Express to hapi # Express Migration -_This guide is compatible with hapi v17 and newer_ +_This guide is compatible with hapi v21 and newer_ ## Overview -This Express to hapi guide will show you how to take what you know how to do in Express, and do it in hapi. While Express relies heavily on middleware for much of its functionality, hapi has more built into the core. Body parsing, cookie handling, input/output validation, and HTTP-friendly error objects are already built-in to the hapi framework. For additional functionality, hapi has a robust selection of plugins in its core ecosystem. hapi is also the only framework that doesn't rely on outside dependencies. Every dependency is managed by the core hapi team, which makes security and reliability some of hapi's greatest strengths. +This guide shows how to take what you do in Express and do it in hapi. The examples use Express 5. + +The biggest difference: Express adds features through middleware you install and order with `app.use()`, while hapi builds most of them into its core and configures them per route. Where hapi does need a plugin, the plugin comes from the hapi team. hapi's own runtime dependencies are all `@hapi/*` packages maintained by the same team. + +| In Express you use | In hapi you use | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `express.json()`, `body-parser` | built in: [payload parsing](#bodyparser) | +| `cookie-parser` | built in: [`server.state()`](#config) | +| `express-validator` | built in: route [`validate`](#joi) with [joi](https://joi.dev) | +| `passport` | auth framework built in; schemes from plugins such as `@hapi/basic`, `@hapi/cookie`, `@hapi/jwt`, and [bell](#bell) ([auth tutorial](/tutorials/en_US/auth)) | +| `cors` | built in: route [`cors`](/api#route-options-cors) option | +| `helmet` | partly built in: route [`security`](/api#route-options-security) option (no Content-Security-Policy) | +| `compression` | built in, on by default | +| `app.set('view engine')` | [vision](#vision) plugin | +| `express.static()` | [inert](#inert) plugin | +| custom middleware | [extension points](#extension) and [plugins](#creating) | +| error-handling middleware | an `onPreResponse` [extension](#extension); throw [boom](#boom) errors in place of `next(err)` | ## Setup ### Installation -Express: -`npm install express` - -hapi: -`npm install @hapi/hapi` +| | Express | hapi | +| ---------------- | --------------------------------------- | ------------------------ | +| Install | `npm install express` | `npm install @hapi/hapi` | +| TypeScript types | `npm install --save-dev @types/express` | included | ### Creating a Server Express: -```js -var express = require('express'); -var app = express(); +```ts +import express from 'express'; + +const app = express(); app.listen(3000, function () { - console.log('Server is running on port 3000'); -})); + console.log('Server is running on port 3000'); +}); ``` hapi: -```js -const Hapi = require('@hapi/hapi'); +```ts +import Hapi from '@hapi/hapi'; const init = async () => { const server = Hapi.server({ @@ -53,17 +69,13 @@ const init = async () => { init(); ``` -Unlike Express, in hapi you create a server object that will be the focal point of your application. The properties set in the server object will determine how your application behaves. Once you create your server object, you can start your server by calling `server.start()`. +In hapi, the `server` object is the center of the application. Its options (port, host, default route settings) decide how the server behaves, and routes, plugins, and extensions are all added to it. `server.start()` returns a promise. ## Routes -Routes in hapi get called in a specific order, so you will never have an issue where two routes are conflicting with one another. Routes are called from most specific to least specific. For example, a route with a path `'/home'` will be called before `'/{any*}'`. - -Lets look at how to set up a basic route in hapi: - Express: -```js +```ts app.get('/hello', function (req, res) { res.send('Hello World!'); }); @@ -71,7 +83,7 @@ app.get('/hello', function (req, res) { hapi: -```js +```ts server.route({ method: 'GET', path: '/hello', @@ -81,13 +93,25 @@ server.route({ }); ``` -To create a route, Express has the structure of `app.METHOD(PATH, HANDLER)` and hapi has the structure `server.route({METHOD, PATH, HANDLER})`. The method, path, and handler are passed to the hapi server as an object. As you can see, to return a string in Express, you call `res.send()`, whereas in hapi, you simply return the string. +| | Express | hapi | +| ------------------ | --------------------------------------- | -------------------------------------------------------- | +| Define a route | `app.METHOD(path, handler)` | `server.route({ method, path, handler })` | +| Send a response | call `res.send()` | return the value | +| Which route runs | the first registered route that matches | the most specific match, whatever the registration order | +| Overlapping routes | both registered; the first one wins | `server.route()` throws when two routes conflict | + +Because hapi picks the most specific route, `'/home'` always wins over `'/{any*}'`, and registration order never decides which route answers. See the [routing tutorial](/tutorials/en_US/routing) for the rules. ### Methods -hapi can use all the route methods that Express can, except `HEAD`. hapi also has the ability to use multiple methods on a single route object. For example: +| Express | hapi | +| --------------------- | --------------------------------------------- | +| `app.get(path, ...)` | `method: 'GET'` | +| one route per method | `method: ['PUT', 'POST']` | +| `app.all(path, ...)` | `method: '*'` | +| `app.head(path, ...)` | not allowed: every `GET` route answers `HEAD` | -```js +```ts server.route({ method: ['PUT', 'POST'], path: '/', @@ -97,21 +121,23 @@ server.route({ }); ``` -To use all available methods, like in Express `app.all()`, use `method: '*'`. - ### Path -Like in Express, the path option in hapi must be a string, which can also contain parameters. Parameters in Express are preceded by `:`, such as: `'/users/:userId'`. In hapi, you would put the parameter in curly braces, like: `path: '/users/{userId}'`. +Parameters go in curly braces in hapi: -### Parameters +| | Express 5 | hapi | +| ------------------ | ---------------- | ----------------- | +| Parameter | `/users/:userId` | `/users/{userId}` | +| Optional parameter | `/hello{/:user}` | `/hello/{user?}` | +| Wildcard | `/files/*path` | `/files/{path*}` | -You saw above how hapi handles simple parameters as compared to Express. Both hapi and Express handle optional parameters the same way. Just like Express, to make a parameter optional in hapi, just include a `?` after the parameter: `path: '/hello/{user?}`. +In hapi, `{user*}` and a whole-segment `{user?}` must be the last segment; `{user*2}` and a partial-segment optional such as `/a{user?}/b` can appear anywhere. -Accessing the parameters in hapi is very similar to Express. As you know, in Express, the parameters are populated in the `req.params` object. In hapi, the parameters are available via the `request.params` object. Here is an example of both: +### Parameters Express: -```js +```ts app.get('/hello/:name', function (req, res) { const name = req.params.name; res.send('Hello ' + name); @@ -120,7 +146,7 @@ app.get('/hello/:name', function (req, res) { hapi: -```js +```ts server.route({ method: 'GET', path: '/hello/{name}', @@ -131,84 +157,116 @@ server.route({ }); ``` -Query parameters are also similar in both frameworks. In Express, they are available via `req.query` and hapi they are available via `request.query`. +| Express | hapi | +| ------------ | ----------------- | +| `req.params` | `request.params` | +| `req.query` | `request.query` | +| `req.body` | `request.payload` | ### Handler -There are differences in the way Express and hapi structure their route handlers. Unlike Express, which has a handler with parameters of `req` and `res`, hapi has a handler with parameters of `request` and `h`. The second parameter, `h` is the response toolkit, which is an object with several methods used to respond to the request. - -Here is an example of route with a handler that redirects to another route in Express and hapi: - -Express: +An Express handler receives `req` and `res`, and calls methods on `res` to respond. A hapi handler receives `request` and `h`, the [response toolkit](/api#response-toolkit), and returns the response: -```js -app.get('/home', function (req, res) { - res.redirect('/'); -}); -``` - -hapi: - -```js -server.route({ - method: 'GET', - path: '/home', - handler: function (request, h) { - return h.redirect('/'); - }, -}); -``` +| Express | hapi | +| ------------------------------ | -------------------------------------------------------- | +| `res.send('text')` | `return 'text'` | +| `res.json(object)` | `return object` | +| `res.status(201).send(object)` | `return h.response(object).code(201)` | +| `res.set('x-name', 'value')` | `return h.response(object).header('x-name', 'value')` | +| `res.redirect('/')` | `return h.redirect('/')` | +| `res.cookie('name', 'value')` | `h.state('name', 'value')`, see [cookies](#config) | +| `res.sendFile(file)` | `return h.file(file)`, see [inert](#inert) | +| `res.render('index', context)` | `return h.view('index', context)`, see [vision](#vision) | +| `next(err)` | `throw Boom.notFound()`, see [boom](#boom) | -Both routes will redirect to the `'/'` route. Express uses the response method `res.redirect` whereas hapi uses `h.redirect` which is part of the response toolkit. There are Express response methods that hapi can accomplish by just using `return`. Some of these methods include `res.send` and `res.json`. Here is an example of how hapi will respond with JSON data: +When the handler needs to set a status code or headers, it wraps the value in `h.response()`: -```js +```ts server.route({ - method: 'GET', + method: 'POST', path: '/user', handler: function (request, h) { const user = { firstName: 'John', lastName: 'Doe', - userName: 'JohnDoe', id: 123, }; - return user; + return h.response(user).code(201).header('location', '/user/123'); }, }); ``` -hapi has the functionality to respond with JSON data by default. The only thing you have to do is just return a valid JavaScript object and hapi will take care of the rest for you. +hapi serializes the returned object as JSON and sets the `content-type`. See the [response marshalling tutorial](/tutorials/en_US/response-marshalling) for how hapi turns a returned value into a response. ## Middleware vs Plugins and Extensions -To extend its functionality, Express uses middleware. Middleware essentially is a sequence of functions using callbacks to execute the next function. The issue with this is as your application grows in size and complexity, the order at which middleware executes becomes more crucial and more difficult to maintain. Having a middleware execute before one it is dependant on will cause your application to fail. hapi fixes this issue with its robust plugin and extension system. +Express runs middleware in the order you call `app.use()`. If one middleware depends on another, you keep them in the right order by hand, and a wrong order fails at request time. + +hapi runs every request through a fixed sequence of steps, the [request lifecycle](/api#request-lifecycle). Instead of ordering middleware, you attach code to a named step. Each step below also shows the Express middleware whose job it does: + +| hapi step | Extension point | Express equivalent | +| ---------------------------- | -------------------------------- | ---------------------------- | +| request received | `onRequest` | `app.use()` before routing | +| route lookup, cookie parsing | | `app.get()`, `cookie-parser` | +| before authentication | `onPreAuth` | | +| authentication | | `passport` | +| payload parsing | | `express.json()` | +| after authentication | `onCredentials`, `onPostAuth` | | +| validation | | `express-validator` | +| before the handler | `onPreHandler` | | +| handler | | route callback | +| after the handler | `onPostHandler`, `onPreResponse` | error-handling middleware | +| after the response is sent | `onPostResponse` | | -Plugins allow you to break your application logic into isolated pieces of business logic, and reusable utilities. Each plugin comes with its own dependencies which are explicitly specified in the plugins themselves. This means you don't have to install dependencies yourself to make your plugins work. You can either add an existing hapi plugin, or write your own. For a more extensive tutorial on plugins, please see the [plugins](/tutorials/plugins/?lang=en_US) tutorial. +The extension points are where you add your own code with `server.ext()`. hapi runs the other steps from each route's configuration. -Each request in hapi follows a predefined path, the request lifecycle. hapi has extension points that let you create custom functionality along the lifecycle. Extension points in hapi let you know the precise order at which your application will run. For more info, please see the hapi [request lifecycle](/api/#request-lifecycle). +Plugins replace reusable middleware. A plugin is a named unit that registers routes, extensions, and decorations on the server. A plugin can declare the other plugins it needs in its `dependencies` property, and hapi refuses to start when one is missing, instead of failing at request time. See the [plugins tutorial](/tutorials/en_US/plugins). ### Extension Points -hapi has 7 extension points along the request lifecycle. In order, they are `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler`, and `onPreResponse`. To add a function to an extension point, you call `server.ext()`. Lets look at an example: +A middleware that runs for every request becomes an extension: -```js +Express: + +```ts +app.use(function (req, res, next) { + console.log(req.method, req.url); + next(); +}); +``` + +hapi: + +```ts server.ext('onRequest', function (request, h) { - request.setUrl('/test'); + console.log(request.method, request.path); return h.continue; }); ``` -This function will run at `onRequest`, which is the first extension point. `onRequest` is run just after the server receives the request object, before the route lookup. What this function will do is reroute all requests to the `'/test'` route. +`return h.continue` plays the role of `next()`: the request moves on to the next step. Returning a takeover response (`h.response(...).takeover()`), or throwing an error, ends the request there instead. + +hapi has eight request extension points: `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler`, `onPreResponse`, and `onPostResponse`. The [lifecycle hooks tutorial](/tutorials/en_US/lifecycle-hooks) covers when to use each one, and [`server.ext()`](/api#server-ext-events) lists the options. ### Creating a Plugin -As you know, you can write your own middleware in Express. The same is true with hapi plugins. A plugin is an object with required `name` and `register` properties. The `register` property is a function with the signature of `async function (server, option)`. Lets look at how to create a basic plugin: +A middleware that adds a helper to every request becomes a plugin that adds a [decoration](/tutorials/en_US/decorators). A plugin is an object with a `name` (or `pkg`) and an `async register(server, options)` function. Express: -```js -const getDate = function (req, res, next) { +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): Date; + } + } +} + +const getDate = function (req: Request, res: Response, next: NextFunction) { req.getDate = function () { const date = new Date(); return date; @@ -220,8 +278,16 @@ const getDate = function (req, res, next) { hapi: -```js -const getDate = { +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): Date; + } +} + +const getDate: Plugin = { name: 'getDate', version: '1.0.0', register: async function (server, options) { @@ -235,37 +301,44 @@ const getDate = { }; ``` -The hapi plugin will save the current date in `h.getDate()`. We can then use this in any of our route handlers. +- The Express middleware sets `req.getDate` again on every request. The hapi plugin calls `server.decorate()` once, at registration, and every handler then has `h.getDate()`. +- TypeScript only knows about the new method once you declare it: Express through its global `Express.Request` interface, hapi by adding it to the `ResponseToolkit` interface of `@hapi/hapi`. ### Loading a Plugin -In Express, you load middleware by calling the `app.use()` method. In hapi, you call the `server.register()` method. Lets load the plugin we created in the previous section: - Express: -```js +```ts app.use(getDate); ``` hapi: -```js +```ts await server.register({ plugin: getDate, }); ``` -You can also provide options to your plugin by setting the `options` property on `server.register()`. - ### Options -You can add options to Express middleware by exporting a function that accepts an options parameter, which then returns the middleware. In hapi, you set the options when you register the plugin. Lets have a look: +An Express middleware takes options by being a function that returns the middleware. A hapi plugin receives its options as the second argument of `register`. Express: -```js -module.exports = function (options) { - return function getDate(req, res, next) { +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): string; + } + } +} + +export default function (options: { name: string }) { + return function getDate(req: Request, res: Response, next: NextFunction) { req.getDate = function () { const date = 'Hello ' + options.name + ', the date is ' + new Date(); return date; @@ -273,13 +346,21 @@ module.exports = function (options) { next(); }; -}; +} ``` hapi: -```js -const getDate = { +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): string; + } +} + +const getDate: Plugin<{ name: string }> = { name: 'getDate', version: '1.0.0', register: async function (server, options) { @@ -293,20 +374,20 @@ const getDate = { }; ``` -To get access to the options in hapi, you simply refer to the `options` object when you create the plugin: +You pass the options when you register the plugin. The type argument of `Plugin<{ name: string }>` types `options` inside `register`, and TypeScript flags a `server.register()` call whose options don't match it; hapi does not check them at runtime: Express: -```js -const getDate = require('./mw/getDate.js'); +```ts +import getDate from './mw/getDate.ts'; app.use(getDate({ name: 'Tom' })); ``` hapi: -```js -server.register({ +```ts +await server.register({ plugin: getDate, options: { name: 'Tom', @@ -314,83 +395,84 @@ server.register({ }); ``` -## body-parser +## body-parser -hapi has parsing abilities built into its core. Unlike Express, you do not need middleware to parse payload data. In fact, you may need to install up to four additional middlewares in Express depending on what kind of data you would like to parse. In hapi the payload data, whether its JSON or plain text, is readily available in the `request.payload` object. Here is a side by side comparison of parsing simple payload data: +hapi parses the request payload before the handler runs, with no middleware. The result is in `request.payload`. + +| Content type | Express | hapi | +| ----------------------------------- | ------------------------------------ | ------------------------------------------- | +| `application/json` | `app.use(express.json())` | parsed by default | +| `application/x-www-form-urlencoded` | `app.use(express.urlencoded())` | parsed by default | +| `text/*` | `app.use(express.text())` | parsed by default | +| `multipart/form-data` | a separate package, such as `multer` | route option `payload: { multipart: true }` | Express: -```js -var bodyParser = require('body-parser'); +```ts +import express from 'express'; + +const app = express(); -app.use(bodyParser.urlencoded({ extend: true })); +app.use(express.urlencoded()); app.post('/hello', function (req, res) { - var name = req.body.name; + const name = req.body.name; res.send('Hello ' + name); }); ``` hapi: -```js -server.route({ +```ts +server.route<{ Payload: { name: string } }>({ method: 'POST', path: '/hello', handler: function (request, h) { const name = request.payload.name; - return `Hello ` + name; + return 'Hello ' + name; }, }); ``` -To parse a JSON object in express, you have to specify it: - -```js -app.use(bodyParser.json()); -``` - -JSON parsing is built into hapi, so there are no further steps needed. +`request.payload` has no fixed shape, since hapi can't know what clients will send. The type argument `server.route<{ Payload: { name: string } }>()` tells TypeScript what the handler receives. It is a type only: to check the payload at runtime, add [validation](#joi). Size limits, timeouts, and file uploads are set with the route's [`payload` options](/api#route-options-payload). ## cookie-parser -Setting and parsing cookies in Express require you to install the `cookie-parser` middleware. hapi has cookie functionality built right into the core, so there is no need to install additional middleware. To use cookies in hapi, you first configure the cookie with `server.state()`. Lets have a look: +hapi reads and writes cookies without middleware. Where Express takes cookie options on every `res.cookie()` call, hapi defines each cookie once with `server.state()`, and every route then sets it by name. -```js -const Hapi = require('@hapi/hapi'); +| Option | Express `res.cookie()` | hapi `server.state()` | +| --------------- | -------------------------- | ----------------------------------- | +| Secure flag | `secure`, off by default | `isSecure`, on by default | +| HttpOnly flag | `httpOnly`, off by default | `isHttpOnly`, on by default | +| SameSite | `sameSite` | `isSameSite`, `'Strict'` by default | +| Lifetime | `maxAge` (ms) | `ttl` (ms) | +| Signed value | `signed` | `sign: { password }` | +| Encrypted value | not built in | `encoding: 'iron'` | -const server = Hapi.server({ port: 8000 }); - -server.state('data', { - ttl: null, - isSecure: true, - isHttpOnly: true, -}); -``` +See [`server.state()`](/api#server-state-name-options) for every option, and the [cookies tutorial](/tutorials/en_US/cookies). ### Setting a Cookie -Once the cookie is configured, you can now set the cookie with `h.state()`. Here is an example: - Express: -```js -var express = require('express'); -var app = express(); -var cookieParser = require('cookie-parser'); +```ts +import express from 'express'; +import cookieParser from 'cookie-parser'; + +const app = express(); app.use(cookieParser()); app.get('/', function (req, res) { - res.cookie('username', 'tom', { maxAge: null, secure: true, httpOnly: true }); + res.cookie('username', 'tom', { secure: true, httpOnly: true }); res.send('Hello'); }); ``` hapi: -```js -const Hapi = require('@hapi/hapi'); +```ts +import Hapi from '@hapi/hapi'; const server = Hapi.server({ port: 8000 }); @@ -405,59 +487,60 @@ server.route({ path: '/', handler: function (request, h) { h.state('username', 'tom'); - return h.response('Hello'); + return 'Hello'; }, }); ``` -In express, you configure cookie with the `options` object in `res.cookie`. In hapi, the cookie config is saved to the server object with `server.state`. You then use `h.state()` to attach data to the cookie. - ### Getting a Cookie Value -To get a cookie value in hapi, you call `request.state`. Lets have look: - Express: -```js -var express = require('express'); -var app = express(); -var cookieParser = require('cookie-parser); - -app.use(cookieParser()); - -app.get('/', (req, res) => { - - res.cookie('username', 'tom', { maxAge: null, secure: true, httpOnly: true }) +```ts +app.get('/whoami', (req, res) => { res.send(req.cookies.username); }); ``` hapi: -```js -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ port: 8000 }); - -server.state('username', { - ttl: null, - isSecure: true, - isHttpOnly: true, -}); - +```ts server.route({ method: 'GET', - path: '/', - handler: async (request, h) => { - h.state('username', 'tom'); - return h.response(request.state.username); + path: '/whoami', + handler: (request, h) => { + const username = request.state.username; + if (typeof username !== 'string') { + return 'No username cookie'; + } + + return username; }, }); ``` +`request.state` holds whatever cookies the client sent, so hapi types each value as `unknown`. Check the type before you use it. + ## Passport -> bell -In Express, third party authentication is handled with Passport. In hapi, you use the [bell](/module/bell) module for third party authentication. `bell` has over 30 predefined configurations for OAuth providers including Twitter, Facebook, Google, GitHub, and more. It will also allow you to set up your own custom provider. For a complete list, please see the [bell providers documentation](https://github.com/hapijs/bell/blob/master/Providers.md). `bell` was developed and is maintained by the core hapi team, so you know stability and reliability won't be an issue. Lets look how to authenticate using your Twitter credentials: +Express handles third-party login with Passport. hapi uses [bell](/module/bell), which has built-in configurations for over 30 OAuth providers, including Twitter, Facebook, Google, and GitHub, and supports custom providers. See the [bell providers list](/module/bell/providers). + +Passport needs two routes, one to start the login and one for the provider's callback. bell uses one route for both: + +```mermaid +sequenceDiagram + participant B as Browser + participant S as hapi server + participant P as Twitter + B->>S: GET /auth/twitter + Note over S: no OAuth response in the query,
so bell starts the login + S-->>B: 302 redirect to Twitter + B->>P: log in and approve + P-->>B: 302 redirect to /auth/twitter?oauth_token=... + B->>S: GET /auth/twitter?oauth_token=... + Note over S: bell exchanges the token with Twitter
and sets request.auth.credentials + S-->>B: handler response +``` Express: @@ -465,51 +548,50 @@ Express: npm install passport passport-twitter ``` -
+If you use TypeScript, also run `npm install --save-dev @types/passport @types/passport-twitter`. -```js -var passport = require('passport'); -var TwitterStrategy = require('passport-twitter').Strategy +```ts +import passport from 'passport'; +import { Strategy as TwitterStrategy } from 'passport-twitter'; -passport.use(new TwitterStrategy({ - consumerKey: TWITTER_CONSUMER_KEY, - consumerSecret: TWITTER_CONSUMER_SECRET, - callbackURL: '/auth/twitter/callback' -}, - function(token, tokenSecret, profile, cb) { - User.findOrCreate({ twitterId: profile.id }, function (err, user) { - return cb(err, user); - } - } -)); +passport.use( + new TwitterStrategy( + { + consumerKey: TWITTER_CONSUMER_KEY, + consumerSecret: TWITTER_CONSUMER_SECRET, + callbackURL: '/auth/twitter/callback', + }, + function (token, tokenSecret, profile, cb) { + User.findOrCreate({ twitterId: profile.id }, function (err, user) { + return cb(err, user); + }); + }, + ), +); -passport.seralizeUser(function(user, cb) { +passport.serializeUser(function (user, cb) { cb(null, user); -}) +}); -passport.deserializeUser(function(user, cb) { - cb(null, obj); -}) +passport.deserializeUser(function (user: Express.User, cb) { + cb(null, user); +}); app.get('/auth/twitter', passport.authenticate('twitter')); -app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login'}), - function(req, res) { - - res.redirect('/'); - }); +app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login' }), function (req, res) { + res.redirect('/'); +}); ``` hapi: ``` -npm install '@hapi/bell' +npm install @hapi/bell ``` -
- -```js -const Hapi = require('@hapi/hapi'); -const Bell = require('@hapi/bell'); +```ts +import Hapi from '@hapi/hapi'; +import Bell from '@hapi/bell'; const server = Hapi.server({ port: 8000 }); @@ -531,10 +613,8 @@ server.route({ return `Authentication failed due to: ${request.auth.error.message}`; } - // Perform any account lookup or registration, setup local session, - // and redirect to the application. The third-party credentials are - // stored in request.auth.credentials. Any query parameters from - // the initial request are passed back via request.auth.credentials.query. + // Look up or register the account, start a local session, and redirect. + // request.auth.credentials holds the provider's profile and tokens. return h.redirect('/home'); }, @@ -547,48 +627,39 @@ server.route({ }); ``` -To use bell, simply register the plugin and configure the strategy with `server.auth.strategy`. - -`provider` is the name of the third-party provider. - -`password` is the cookie encryption password. - -`clientId` is the OAuth client identifier, which is available from the provider. - -`clientSecret` is the OAuth client secret, which is available from the provider. +| Option | What it is | +| -------------- | ---------------------------------------------------------------------------------------------------------- | +| `provider` | the name of the built-in provider configuration | +| `password` | encrypts the temporary cookie bell keeps between the two requests; at least 32 characters | +| `clientId` | the OAuth client ID from the provider | +| `clientSecret` | the OAuth client secret from the provider | +| `isSecure` | the cookie's Secure flag. `false` allows plain HTTP in development; leave the default `true` in production | -`isSecure` sets the cookie secure flag. For production, this should be set to `true`, which is the default value. +`mode: 'try'` lets the handler run when the login fails, so it can show the error instead of a bare 401. Query parameters from the request that started the login come back on `request.auth.credentials.query`, so you can carry a return URL through the provider round trip. ## express-validator -> joi -To validate data in Express, you make use of the `express-validator` plugin. One of the biggest drawbacks to `express-validator` is that while you can validate a request, there is no clear way of validating a response. In hapi, you use the [joi](https://joi.dev) module, which can validate requests and responses with ease. Joi allows you to create your own validations with a simple and clean object syntax. For a more in-depth look at validation in hapi, please see the [validation](/tutorials/validation/?lang=en_US) tutorial. +hapi validates requests and responses from the route's configuration, using [joi](https://joi.dev) schemas. express-validator checks requests only; hapi can also check what your handler returns. See the [validation tutorial](/tutorials/en_US/validation) for more. ### Input Validation -Input validation allows you to validate any input data coming into the server, whether its parameters, payload, etc. Here is a look at how to validate a blog post entry in Express and hapi: - Express: ``` npm install express-validator ``` -
- -```js -const bodyParser = require('body-parser'); -const expressValidator = require('express-validator'); - -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); -app.use(expressValidator()); +```ts +import express from 'express'; +import { body, validationResult } from 'express-validator'; -app.post('/post', function (req, res) { - req.check('post', 'Post too long').isLength({ max: 140 }); +app.use(express.json()); +app.use(express.urlencoded()); - let errors = req.validationErrors(); - if (errors) { - res.status(400).send(errors); +app.post('/post', body('post', 'Post too long').isLength({ max: 140 }), function (req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).send(errors.array()); } else { res.send('Blog post added!'); } @@ -601,10 +672,8 @@ hapi: npm install joi ``` -
- -```js -const Joi = require('joi'); +```ts +import Joi from 'joi'; server.route({ method: 'POST', @@ -622,19 +691,13 @@ server.route({ }); ``` -First you install `joi`, then require it in your project. To validate the input data, you specify what data type you are expecting, then set rules on that data. In this case, `post` will be a `string` with a maximum number of characters of 140. In `joi` you can string rules together like: - -```js -Joi.string().min(1).max(140). -``` +- The handler only runs when the payload passes. Otherwise hapi responds with a 400 on its own, so the handler has no error branch. +- Rules chain, such as `Joi.string().min(1).max(140)`. +- `validate` also accepts `params`, `query`, `headers`, and `state` schemas. ### Output Validation -As stated above, there is no clear way of doing response validation with `express-validator`. With `joi`, response validation is fast and simple. Lets have a look: - -hapi: - -```js +```ts const bookSchema = Joi.object({ title: Joi.string().required(), author: Joi.string().required(), @@ -658,52 +721,52 @@ server.route({ }); ``` -This route will return a list of books. In the `options` route property, we can specify what rules the list of books should follow. By setting the `failAction` to `log`, if there is an error, the server will log the error. +`response.schema` checks the list of books the handler returns. With `failAction: 'log'`, a response that fails the schema is still sent and the failure is logged; the default, `'error'`, replaces it with a 500. ## app.set('view engine') -> vision -hapi has extensive support for template rendering, including the ability to load and leverage multiple templating engines, partials, helpers (functions used in templates to manipulate data), and layouts. Express enables views capabilities by using `app.set('view engine')`, where hapi's capabilities are provided by the [vision](/module/vision) plugin. For a more extensive tutorial on views in hapi, please see the [views](/tutorials/views/?lang=en_US) tutorial. +hapi renders templates through the [vision](/module/vision) plugin. It supports several template engines at once, plus partials, helpers, and layouts. See the [views tutorial](/tutorials/en_US/views) for more. ### Setting the View Engine -Setting the views engine in express looks like the following: +Express: -```js +```ts app.set('view engine', 'pug'); ``` -To set the views engine in hapi, you first must register the vision plugin, then configure `server.views`: +hapi: + +```ts +import Vision from '@hapi/vision'; +import Pug from 'pug'; -```js -await server.register(require('@hapi/vision')); +await server.register(Vision); server.views({ engines: { - pug: require('pug'), + pug: Pug, }, - relativeTo: __dirname, + relativeTo: import.meta.dirname, path: 'views', }); ``` -By default, Express will look for views or templates in the `views` folder. In hapi, you specify where the views are located using the `relativeTo` and `path` properties. Like Express, hapi supports a wide variety of templating engines, such as pug, ejs, handlebars, etc. - -hapi has many more configurable options in `server.views`. To see the full list of capabilities, please go to the [views](/tutorials/views/?lang=en_US) tutorial. +Express looks for templates in `./views` by default. In hapi, you set the folder with `relativeTo` and `path`. ### Rendering a View -To render a view in Express, you would call `res.render()`. hapi, by way of vision, has two methods for rendering views, `h.view` and the view handler. Lets look at how to do both. -First, rendering a view in Express: +Express: -```js +```ts app.get('/', function (req, res) { res.render('index', { title: 'Homepage', message: 'Welcome' }); }); ``` -Using `h.view` in hapi: +hapi, with `h.view()`: -```js +```ts server.route({ method: 'GET', path: '/', @@ -713,9 +776,9 @@ server.route({ }); ``` -And using the view handler in hapi: +hapi, with the `view` handler, when the route only renders a template: -```js +```ts server.route({ method: 'GET', path: '/', @@ -731,38 +794,42 @@ server.route({ }); ``` -To pass context in `h.view`, you pass an object as the second parameter. To pass context in the view handler, you use the `context` key. - ## express.static() -> inert -hapi gets its ability to serve static content from a plugin called [inert](/module/inert). inert provides new handler methods for serving static files and directories, as well as adding a `h.file()` method to the response toolkit. For a more in-depth tutorial of server static files in hapi, please see the [serving static files](/tutorials/servingfiles/?lang=en_US) tutorial. +hapi serves static files through the [inert](/module/inert) plugin. It adds the `file` and `directory` handlers and the `h.file()` method. See the [serving files tutorial](/tutorials/en_US/serving-files) for more. -### Serving Single Files +Both examples below use this setup, which registers inert and sets the folder that file paths are relative to: -In Express, you would use the `res.sendFile` method to return a single file. In hapi, you can either use the `h.file()` method or the file handler, which is available via [inert](/module/inert). Once you register the inert plugin, you will be able to serve your static files: +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; -Express: - -```js -app.get('/image', function (req, res) { - res.sendFile('image.jpg', { root: './public' }); -}); -``` - -hapi with `h.file()`: - -```js -const server = new Hapi.Server({ +const server = Hapi.server({ port: 3000, routes: { files: { - relativeTo: Path.join(__dirname, 'public'), + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -await server.register(require('@hapi/inert')); +await server.register(Inert); +``` + +### Serving Single Files + +Express: +```ts +app.get('/image', function (req, res) { + res.sendFile('image.jpg', { root: './public' }); +}); +``` + +hapi, with `h.file()`: + +```ts server.route({ method: 'GET', path: '/image', @@ -772,20 +839,9 @@ server.route({ }); ``` -hapi with file handler: - -```js -const server = new Hapi.Server({ - port: 3000, - routes: { - files: { - relativeTo: Path.join(__dirname, 'public'), - }, - }, -}); - -await server.register(require('@hapi/inert')); +hapi, with the `file` handler: +```ts server.route({ method: 'GET', path: '/image', @@ -795,32 +851,17 @@ server.route({ }); ``` -To serve static files in hapi, you first must tell hapi where the static files are located. You do this by configuring the `server.options.routes` object. You set the `relativeTo` to the folder where the files are located, much like you do in the options object of the `res.sendFile` in Express. Next, you need to register the inert plugin. This will give you access to the methods that allows you to serve static files. Now in your route handler, you can use the `h.file()` method or the file handler to server your static file. - ### Static File Server -To set up a static file server in Express, you would use the `express.static()` middleware. In hapi, you use the file handler made available by the [inert](/module/inert) plugin. You would setup the server in the same way as you did to serve a single static file, by telling where the files are located. You then would setup a route to catch all of the requests and return the correct files. Lets have a look: - Express: -```js -app.use(express.static('/public')); +```ts +app.use(express.static('public')); ``` hapi: -```js -const server = new Hapi.Server({ - port: 3000, - routes: { - files: { - relativeTo: Path.join(__dirname, 'public'), - }, - }, -}); - -await server.register(require('@hapi/inert')); - +```ts server.route({ method: 'GET', path: '/{param*}', @@ -832,15 +873,15 @@ server.route({ }); ``` -Now, you can access any static files by going to `localhost:3000/filename`. [inert](/module/inert) has many other options and capabilities. To see what all it can do, please see the [serving static files](/tutorials/servingfiles/?lang=en_US) tutorial. +The `{param*}` wildcard matches any path, and the `directory` handler serves the matching file from the `public` folder, so `/logo.png` returns `public/logo.png`. Because hapi picks the most specific route, your other routes still win over this catch-all. ## Error Handling -> boom -hapi uses the [boom](/module/boom) module to handle errors. By default, boom will return the errors in JSON format. Express on the other hand will return a text response by default, which is suboptimal with a JSON API. Lets look a 404 error response with the default settings by submitting a `GET` request to `'/hello'`, which does not exists: +hapi responds to errors with JSON, built from [boom](/module/boom) error objects. Express responds with an HTML page. For a `GET /hello` that matches no route: Express: -```js +```text Cannot GET /hello ``` @@ -856,18 +897,26 @@ hapi: ### Custom Messages -`boom` allows you to easily change the error message for any status code. Lets take the 404 error above and return a new message: - Express: -```js -res.status(400).send({ status: 404, error: 'Page not found' }); +```ts +res.status(404).send({ status: 404, error: 'Page not found' }); ``` hapi: -```js +```ts throw Boom.notFound('Page not found'); ``` -In Express, you set the status code, then send the error message body. In this case we return a JSON object with the status code and the error message. In `boom`, there is no need to return a JSON object with the status code, it does this by default. In the example above, you throw `Boom.notFound()` to set the error message. `boom` has a long list of 4xx and 5xx errors, such as `Boom.unauthorized()`, `Boom.badRequest()`, `Boom.badImplementation()`, etc. For a complete list, please see the [boom](/module/boom) documentation. +The thrown error becomes this response: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Page not found" +} +``` + +boom has a function for the common 4xx and 5xx statuses, such as `Boom.badRequest()`, `Boom.unauthorized()`, and `Boom.badImplementation()`. Throw one anywhere in a handler, extension, or plugin, and hapi turns it into the response. Errors that are not boom errors become a 500, and their message is hidden from the client. See the [boom documentation](/module/boom) for the full list. diff --git a/docs/tutorials/es_ES/express-to-hapi.md b/docs/tutorials/es_ES/express-to-hapi.md new file mode 100644 index 00000000..a8c93f1a --- /dev/null +++ b/docs/tutorials/es_ES/express-to-hapi.md @@ -0,0 +1,922 @@ +--- +title: De Express a hapi +--- + + + +# Migrar desde Express + +_Esta guía es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Esta guía muestra cómo hacer en hapi lo que haces en Express. Los ejemplos usan Express 5. + +La mayor diferencia: Express añade funcionalidades mediante middleware que instalas y ordenas con `app.use()`, mientras que hapi incluye la mayoría en su núcleo y las configura por ruta. Cuando hapi sí necesita un plugin, el plugin viene del equipo de hapi. Todas las dependencias de runtime de hapi son paquetes `@hapi/*` mantenidos por el mismo equipo. + +| En Express usas | En hapi usas | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `express.json()`, `body-parser` | integrado: [parseo del payload](#bodyparser) | +| `cookie-parser` | integrado: [`server.state()`](#config) | +| `express-validator` | integrado: [`validate`](#joi) de la ruta con [joi](https://joi.dev) | +| `passport` | framework de autenticación integrado; esquemas en plugins como `@hapi/basic`, `@hapi/cookie`, `@hapi/jwt` y [bell](#bell) ([tutorial de autenticación](/tutorials/es_ES/auth)) | +| `cors` | integrado: opción [`cors`](/api#route-options-cors) de la ruta | +| `helmet` | en parte integrado: opción [`security`](/api#route-options-security) de la ruta (sin Content-Security-Policy) | +| `compression` | integrado, activado por defecto | +| `app.set('view engine')` | plugin [vision](#vision) | +| `express.static()` | plugin [inert](#inert) | +| middleware propio | [puntos de extensión](#extension) y [plugins](#creating) | +| middleware de gestión de errores | una [extensión](#extension) `onPreResponse`; lanza errores de [boom](#boom) en lugar de `next(err)` | + +## Configuración inicial + +### Instalación + +| | Express | hapi | +| ---------------- | --------------------------------------- | ------------------------ | +| Instalación | `npm install express` | `npm install @hapi/hapi` | +| Tipos TypeScript | `npm install --save-dev @types/express` | incluidos | + +### Crear un servidor + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.listen(3000, function () { + console.log('Server is running on port 3000'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + await server.start(); + console.log('Server running on port 3000'); +}; + +init(); +``` + +En hapi, el objeto `server` es el centro de la aplicación. Sus opciones (puerto, host, configuración por defecto de las rutas) deciden cómo se comporta el servidor, y las rutas, los plugins y las extensiones se añaden todos a él. `server.start()` devuelve una promesa. + +## Rutas + +Express: + +```ts +app.get('/hello', function (req, res) { + res.send('Hello World!'); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello', + handler: (request, h) => { + return 'Hello World!'; + }, +}); +``` + +| | Express | hapi | +| -------------------- | --------------------------------------- | ------------------------------------------------------------------------- | +| Definir una ruta | `app.METHOD(path, handler)` | `server.route({ method, path, handler })` | +| Enviar una respuesta | llamar a `res.send()` | devolver el valor | +| Qué ruta se ejecuta | la primera ruta registrada que coincide | la coincidencia más específica, sea cual sea el orden de registro | +| Rutas solapadas | se registran las dos; gana la primera | `server.route()` lanza una excepción cuando dos rutas entran en conflicto | + +Como hapi elige la ruta más específica, `'/home'` siempre gana a `'/{any*}'`, y el orden de registro nunca decide qué ruta responde. Consulta el [tutorial de enrutamiento](/tutorials/es_ES/routing) para ver las reglas. + +### Métodos + +| Express | hapi | +| --------------------- | ---------------------------------------------------- | +| `app.get(path, ...)` | `method: 'GET'` | +| una ruta por método | `method: ['PUT', 'POST']` | +| `app.all(path, ...)` | `method: '*'` | +| `app.head(path, ...)` | no está permitido: toda ruta `GET` responde a `HEAD` | + +```ts +server.route({ + method: ['PUT', 'POST'], + path: '/', + handler: function (request, h) { + return 'I did something!'; + }, +}); +``` + +### Path + +En hapi los parámetros van entre llaves: + +| | Express 5 | hapi | +| ------------------ | ---------------- | ----------------- | +| Parámetro | `/users/:userId` | `/users/{userId}` | +| Parámetro opcional | `/hello{/:user}` | `/hello/{user?}` | +| Comodín | `/files/*path` | `/files/{path*}` | + +En hapi, `{user*}` y un `{user?}` que ocupa todo el segmento tienen que ser el último segmento; `{user*2}` y un opcional que ocupa parte del segmento, como `/a{user?}/b`, pueden aparecer en cualquier sitio. + +### Parámetros + +Express: + +```ts +app.get('/hello/:name', function (req, res) { + const name = req.params.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: function (request, h) { + const name = request.params.name; + return 'Hello ' + name; + }, +}); +``` + +| Express | hapi | +| ------------ | ----------------- | +| `req.params` | `request.params` | +| `req.query` | `request.query` | +| `req.body` | `request.payload` | + +### Handler + +Un handler de Express recibe `req` y `res`, y llama a métodos de `res` para responder. Un handler de hapi recibe `request` y `h`, el [response toolkit](/api#response-toolkit), y devuelve la respuesta: + +| Express | hapi | +| ------------------------------ | ------------------------------------------------------------- | +| `res.send('text')` | `return 'text'` | +| `res.json(object)` | `return object` | +| `res.status(201).send(object)` | `return h.response(object).code(201)` | +| `res.set('x-name', 'value')` | `return h.response(object).header('x-name', 'value')` | +| `res.redirect('/')` | `return h.redirect('/')` | +| `res.cookie('name', 'value')` | `h.state('name', 'value')`, consulta [cookies](#config) | +| `res.sendFile(file)` | `return h.file(file)`, consulta [inert](#inert) | +| `res.render('index', context)` | `return h.view('index', context)`, consulta [vision](#vision) | +| `next(err)` | `throw Boom.notFound()`, consulta [boom](#boom) | + +Cuando el handler necesita fijar un código de estado o cabeceras, envuelve el valor en `h.response()`: + +```ts +server.route({ + method: 'POST', + path: '/user', + handler: function (request, h) { + const user = { + firstName: 'John', + lastName: 'Doe', + id: 123, + }; + + return h.response(user).code(201).header('location', '/user/123'); + }, +}); +``` + +hapi serializa el objeto devuelto como JSON y pone el `content-type`. Consulta el [tutorial de serialización de respuestas](/tutorials/es_ES/response-marshalling) para ver cómo convierte hapi un valor devuelto en una respuesta. + +## Middleware frente a plugins y extensiones + +Express ejecuta el middleware en el orden en que llamas a `app.use()`. Si un middleware depende de otro, tienes que mantener el orden correcto a mano, y un orden incorrecto falla al atender la petición. + +hapi hace pasar cada petición por una secuencia fija de pasos, el [ciclo de vida de la petición](/api#request-lifecycle). En lugar de ordenar middleware, enganchas código a un paso con nombre. Cada paso de abajo muestra también el middleware de Express que hace ese trabajo: + +| Paso de hapi | Punto de extensión | Equivalente en Express | +| -------------------------------------- | -------------------------------- | ---------------------------------- | +| petición recibida | `onRequest` | `app.use()` antes del enrutamiento | +| búsqueda de la ruta, parseo de cookies | | `app.get()`, `cookie-parser` | +| antes de la autenticación | `onPreAuth` | | +| autenticación | | `passport` | +| parseo del payload | | `express.json()` | +| después de la autenticación | `onCredentials`, `onPostAuth` | | +| validación | | `express-validator` | +| antes del handler | `onPreHandler` | | +| handler | | callback de la ruta | +| después del handler | `onPostHandler`, `onPreResponse` | middleware de gestión de errores | +| después de enviar la respuesta | `onPostResponse` | | + +Los puntos de extensión son donde añades tu propio código con `server.ext()`. hapi ejecuta los demás pasos a partir de la configuración de cada ruta. + +Los plugins sustituyen al middleware reutilizable. Un plugin es una unidad con nombre que registra rutas, extensiones y decoraciones en el servidor. Un plugin puede declarar los otros plugins que necesita en su propiedad `dependencies`, y hapi se niega a arrancar cuando falta alguno, en lugar de fallar al atender una petición. Consulta el [tutorial de plugins](/tutorials/es_ES/plugins). + +### Puntos de extensión + +Un middleware que se ejecuta en todas las peticiones se convierte en una extensión: + +Express: + +```ts +app.use(function (req, res, next) { + console.log(req.method, req.url); + next(); +}); +``` + +hapi: + +```ts +server.ext('onRequest', function (request, h) { + console.log(request.method, request.path); + return h.continue; +}); +``` + +`return h.continue` hace el papel de `next()`: la petición pasa al siguiente paso. Devolver una respuesta takeover (`h.response(...).takeover()`), o lanzar un error, termina la petición en ese punto. + +hapi tiene ocho puntos de extensión de petición: `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler`, `onPreResponse` y `onPostResponse`. El [tutorial de hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) explica cuándo usar cada uno, y [`server.ext()`](/api#server-ext-events) enumera las opciones. + +### Crear un plugin + +Un middleware que añade un helper a cada petición se convierte en un plugin que añade una [decoración](/tutorials/es_ES/decorators). Un plugin es un objeto con un `name` (o `pkg`) y una función `async register(server, options)`. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): Date; + } + } +} + +const getDate = function (req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = new Date(); + return date; + }; + + next(); +}; +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): Date; + } +} + +const getDate: Plugin = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +- El middleware de Express vuelve a asignar `req.getDate` en cada petición. El plugin de hapi llama a `server.decorate()` una sola vez, al registrarse, y a partir de ahí todos los handlers tienen `h.getDate()`. +- TypeScript solo conoce el nuevo método una vez que lo declaras: en Express mediante su interfaz global `Express.Request`, en hapi añadiéndolo a la interfaz `ResponseToolkit` de `@hapi/hapi`. + +### Cargar un plugin + +Express: + +```ts +app.use(getDate); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, +}); +``` + +### Opciones + +Un middleware de Express recibe opciones siendo una función que devuelve el middleware. Un plugin de hapi recibe sus opciones como segundo argumento de `register`. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): string; + } + } +} + +export default function (options: { name: string }) { + return function getDate(req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + next(); + }; +} +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): string; + } +} + +const getDate: Plugin<{ name: string }> = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +Las opciones se pasan al registrar el plugin. El argumento de tipo de `Plugin<{ name: string }>` tipa `options` dentro de `register`, y TypeScript marca una llamada a `server.register()` cuyas opciones no encajan; hapi no las comprueba en tiempo de ejecución: + +Express: + +```ts +import getDate from './mw/getDate.ts'; + +app.use(getDate({ name: 'Tom' })); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, + options: { + name: 'Tom', + }, +}); +``` + +## body-parser + +hapi parsea el payload de la petición antes de que se ejecute el handler, sin middleware. El resultado está en `request.payload`. + +| Tipo de contenido | Express | hapi | +| ----------------------------------- | -------------------------------- | --------------------------------------------- | +| `application/json` | `app.use(express.json())` | se parsea por defecto | +| `application/x-www-form-urlencoded` | `app.use(express.urlencoded())` | se parsea por defecto | +| `text/*` | `app.use(express.text())` | se parsea por defecto | +| `multipart/form-data` | un paquete aparte, como `multer` | opción de ruta `payload: { multipart: true }` | + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.use(express.urlencoded()); + +app.post('/hello', function (req, res) { + const name = req.body.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/hello', + handler: function (request, h) { + const name = request.payload.name; + return 'Hello ' + name; + }, +}); +``` + +`request.payload` no tiene una forma fija, porque hapi no puede saber qué enviarán los clientes. El argumento de tipo `server.route<{ Payload: { name: string } }>()` le dice a TypeScript qué recibe el handler. Es solo un tipo: para comprobar el payload en tiempo de ejecución, añade [validación](#joi). Los límites de tamaño, los timeouts y las subidas de ficheros se configuran con las [opciones `payload`](/api#route-options-payload) de la ruta. + +## cookie-parser + +hapi lee y escribe cookies sin middleware. Mientras que Express recibe las opciones de la cookie en cada llamada a `res.cookie()`, hapi define cada cookie una sola vez con `server.state()`, y después cada ruta la pone por su nombre. + +| Opción | `res.cookie()` de Express | `server.state()` de hapi | +| ------------- | ----------------------------------- | ------------------------------------ | +| Flag Secure | `secure`, desactivado por defecto | `isSecure`, activado por defecto | +| Flag HttpOnly | `httpOnly`, desactivado por defecto | `isHttpOnly`, activado por defecto | +| SameSite | `sameSite` | `isSameSite`, `'Strict'` por defecto | +| Duración | `maxAge` (ms) | `ttl` (ms) | +| Valor firmado | `signed` | `sign: { password }` | +| Valor cifrado | no integrado | `encoding: 'iron'` | + +Consulta [`server.state()`](/api#server-state-name-options) para ver todas las opciones, y el [tutorial de cookies](/tutorials/es_ES/cookies). + +### Poner una cookie + +Express: + +```ts +import express from 'express'; +import cookieParser from 'cookie-parser'; + +const app = express(); + +app.use(cookieParser()); + +app.get('/', function (req, res) { + res.cookie('username', 'tom', { secure: true, httpOnly: true }); + res.send('Hello'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 8000 }); + +server.state('username', { + ttl: null, + isSecure: true, + isHttpOnly: true, +}); + +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + h.state('username', 'tom'); + return 'Hello'; + }, +}); +``` + +### Leer el valor de una cookie + +Express: + +```ts +app.get('/whoami', (req, res) => { + res.send(req.cookies.username); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + const username = request.state.username; + if (typeof username !== 'string') { + return 'No username cookie'; + } + + return username; + }, +}); +``` + +`request.state` contiene las cookies que haya enviado el cliente, sean cuales sean, así que hapi tipa cada valor como `unknown`. Comprueba el tipo antes de usarlo. + +## Passport -> bell + +Express gestiona el login con terceros mediante Passport. hapi usa [bell](/module/bell), que trae configuraciones integradas para más de 30 proveedores OAuth, como Twitter, Facebook, Google y GitHub, y soporta proveedores personalizados. Consulta la [lista de proveedores de bell](/module/bell/providers). + +Passport necesita dos rutas, una para iniciar el login y otra para el callback del proveedor. bell usa una sola ruta para ambas cosas: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as servidor hapi + participant P as Twitter + B->>S: GET /auth/twitter + Note over S: no hay respuesta OAuth en la query,
así que bell inicia el login + S-->>B: 302, redirección a Twitter + B->>P: inicia sesión y autoriza + P-->>B: 302, redirección a /auth/twitter?oauth_token=... + B->>S: GET /auth/twitter?oauth_token=... + Note over S: bell intercambia el token con Twitter
y rellena request.auth.credentials + S-->>B: respuesta del handler +``` + +Express: + +``` +npm install passport passport-twitter +``` + +Si usas TypeScript, ejecuta también `npm install --save-dev @types/passport @types/passport-twitter`. + +```ts +import passport from 'passport'; +import { Strategy as TwitterStrategy } from 'passport-twitter'; + +passport.use( + new TwitterStrategy( + { + consumerKey: TWITTER_CONSUMER_KEY, + consumerSecret: TWITTER_CONSUMER_SECRET, + callbackURL: '/auth/twitter/callback', + }, + function (token, tokenSecret, profile, cb) { + User.findOrCreate({ twitterId: profile.id }, function (err, user) { + return cb(err, user); + }); + }, + ), +); + +passport.serializeUser(function (user, cb) { + cb(null, user); +}); + +passport.deserializeUser(function (user: Express.User, cb) { + cb(null, user); +}); + +app.get('/auth/twitter', passport.authenticate('twitter')); +app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login' }), function (req, res) { + res.redirect('/'); +}); +``` + +hapi: + +``` +npm install @hapi/bell +``` + +```ts +import Hapi from '@hapi/hapi'; +import Bell from '@hapi/bell'; + +const server = Hapi.server({ port: 8000 }); + +await server.register(Bell); + +server.auth.strategy('twitter', 'bell', { + provider: 'twitter', + password: 'cookie_encryption_password_secure', + clientId: TWITTER_CONSUMER_KEY, + clientSecret: TWITTER_CONSUMER_SECRET, + isSecure: false, +}); + +server.route({ + method: '*', + path: '/auth/twitter', // The callback endpoint registered with the provider + handler: function (request, h) { + if (!request.auth.isAuthenticated) { + return `Authentication failed due to: ${request.auth.error.message}`; + } + + // Look up or register the account, start a local session, and redirect. + // request.auth.credentials holds the provider's profile and tokens. + + return h.redirect('/home'); + }, + options: { + auth: { + strategy: 'twitter', + mode: 'try', + }, + }, +}); +``` + +| Opción | Qué es | +| -------------- | -------------------------------------------------------------------------------------------------------------------- | +| `provider` | el nombre de la configuración de proveedor integrada | +| `password` | cifra la cookie temporal que bell mantiene entre las dos peticiones; al menos 32 caracteres | +| `clientId` | el client ID de OAuth que te da el proveedor | +| `clientSecret` | el client secret de OAuth que te da el proveedor | +| `isSecure` | el flag Secure de la cookie. `false` permite HTTP sin cifrar en desarrollo; deja el `true` por defecto en producción | + +`mode: 'try'` deja que el handler se ejecute cuando el login falla, para que pueda mostrar el error en lugar de un 401 sin más. Los parámetros de query de la petición que inició el login vuelven en `request.auth.credentials.query`, así que puedes conservar una URL de retorno durante todo el viaje de ida y vuelta al proveedor. + +## express-validator -> joi + +hapi valida las peticiones y las respuestas a partir de la configuración de la ruta, con esquemas de [joi](https://joi.dev). express-validator solo comprueba las peticiones; hapi también puede comprobar lo que devuelve tu handler. Consulta el [tutorial de validación](/tutorials/es_ES/validation) para saber más. + +### Validación de la entrada + +Express: + +``` +npm install express-validator +``` + +```ts +import express from 'express'; +import { body, validationResult } from 'express-validator'; + +app.use(express.json()); +app.use(express.urlencoded()); + +app.post('/post', body('post', 'Post too long').isLength({ max: 140 }), function (req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).send(errors.array()); + } else { + res.send('Blog post added!'); + } +}); +``` + +hapi: + +``` +npm install joi +``` + +```ts +import Joi from 'joi'; + +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added!'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().max(140), + }), + }, + }, +}); +``` + +- El handler solo se ejecuta cuando el payload es válido. Si no, hapi responde con un 400 por su cuenta, así que el handler no tiene una rama de error. +- Las reglas se encadenan, como en `Joi.string().min(1).max(140)`. +- `validate` también acepta esquemas `params`, `query`, `headers` y `state`. + +### Validación de la salida + +```ts +const bookSchema = Joi.object({ + title: Joi.string().required(), + author: Joi.string().required(), + isbn: Joi.string().length(10), + pageCount: Joi.number(), + datePublished: Joi.date().iso(), +}); + +server.route({ + method: 'GET', + path: '/books', + handler: async function (request, h) { + return await getBooks(); + }, + options: { + response: { + schema: Joi.array().items(bookSchema), + failAction: 'log', + }, + }, +}); +``` + +`response.schema` comprueba la lista de libros que devuelve el handler. Con `failAction: 'log'`, una respuesta que no cumple el esquema se envía igualmente y el fallo se registra; el valor por defecto, `'error'`, la sustituye por un 500. + +## app.set('view engine') -> vision + +hapi renderiza plantillas a través del plugin [vision](/module/vision). Soporta varios motores de plantillas a la vez, además de partials, helpers y layouts. Consulta el [tutorial de vistas](/tutorials/es_ES/views) para saber más. + +### Configurar el motor de vistas + +Express: + +```ts +app.set('view engine', 'pug'); +``` + +hapi: + +```ts +import Vision from '@hapi/vision'; +import Pug from 'pug'; + +await server.register(Vision); + +server.views({ + engines: { + pug: Pug, + }, + relativeTo: import.meta.dirname, + path: 'views', +}); +``` + +Express busca las plantillas en `./views` por defecto. En hapi, la carpeta se configura con `relativeTo` y `path`. + +### Renderizar una vista + +Express: + +```ts +app.get('/', function (req, res) { + res.render('index', { title: 'Homepage', message: 'Welcome' }); +}); +``` + +hapi, con `h.view()`: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + return h.view('index', { title: 'Homepage', message: 'Welcome' }); + }, +}); +``` + +hapi, con el handler `view`, cuando la ruta solo renderiza una plantilla: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'Homepage', + message: 'Welcome', + }, + }, + }, +}); +``` + +## express.static() -> inert + +hapi sirve ficheros estáticos a través del plugin [inert](/module/inert). Añade los handlers `file` y `directory` y el método `h.file()`. Consulta el [tutorial de servir ficheros](/tutorials/es_ES/serving-files) para saber más. + +Los dos ejemplos de abajo usan esta configuración, que registra inert y fija la carpeta respecto a la cual se resuelven las rutas de los ficheros: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), + }, + }, +}); + +await server.register(Inert); +``` + +### Servir un solo fichero + +Express: + +```ts +app.get('/image', function (req, res) { + res.sendFile('image.jpg', { root: './public' }); +}); +``` + +hapi, con `h.file()`: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: function (request, h) { + return h.file('image.jpg'); + }, +}); +``` + +hapi, con el handler `file`: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: { + file: 'image.jpg', + }, +}); +``` + +### Servidor de ficheros estáticos + +Express: + +```ts +app.use(express.static('public')); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/{param*}', + handler: { + directory: { + path: '.', + }, + }, +}); +``` + +El comodín `{param*}` coincide con cualquier path, y el handler `directory` sirve el fichero correspondiente de la carpeta `public`, así que `/logo.png` devuelve `public/logo.png`. Como hapi elige la ruta más específica, tus otras rutas siguen ganando a esta ruta comodín. + +## Gestión de errores -> boom + +hapi responde a los errores con JSON, construido a partir de objetos de error de [boom](/module/boom). Express responde con una página HTML. Para un `GET /hello` que no coincide con ninguna ruta: + +Express: + +```text +Cannot GET /hello +``` + +hapi: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Not Found" +} +``` + +### Mensajes personalizados + +Express: + +```ts +res.status(404).send({ status: 404, error: 'Page not found' }); +``` + +hapi: + +```ts +throw Boom.notFound('Page not found'); +``` + +El error lanzado se convierte en esta respuesta: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Page not found" +} +``` + +boom tiene una función para cada uno de los estados 4xx y 5xx habituales, como `Boom.badRequest()`, `Boom.unauthorized()` y `Boom.badImplementation()`. Lánzala en cualquier punto de un handler, una extensión o un plugin, y hapi la convierte en la respuesta. Los errores que no son de boom se convierten en un 500, y su mensaje se oculta al cliente. Consulta la [documentación de boom](/module/boom) para ver la lista completa. diff --git a/docs/tutorials/ko_KR/express-to-hapi.md b/docs/tutorials/ko_KR/express-to-hapi.md index 51576c5c..d46704b6 100644 --- a/docs/tutorials/ko_KR/express-to-hapi.md +++ b/docs/tutorials/ko_KR/express-to-hapi.md @@ -1,11 +1,922 @@ --- -title: Express to hapi +title: Express에서 hapi로 --- -# Express to hapi Guide +# Express 마이그레이션 -## No Translation Yet +_이 가이드는 hapi v21 이상과 호환됩니다_ -This guide has not been translated yet. If you would like to translate it, please submit a pull request. +## 개요 + +이 가이드는 Express에서 하던 일을 hapi에서 어떻게 하는지 보여 줍니다. 예제는 Express 5를 사용합니다. + +가장 큰 차이는 이것입니다. Express는 직접 설치하고 `app.use()`로 순서를 정하는 미들웨어로 기능을 추가하지만, hapi는 대부분의 기능을 코어에 내장하고 라우트별로 설정합니다. hapi에 플러그인이 필요한 경우에도 그 플러그인은 hapi 팀이 만듭니다. hapi 자체의 런타임 의존성은 모두 같은 팀이 관리하는 `@hapi/*` 패키지입니다. + +| Express에서 쓰는 것 | hapi에서 쓰는 것 | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `express.json()`, `body-parser` | 내장: [페이로드 파싱](#bodyparser) | +| `cookie-parser` | 내장: [`server.state()`](#config) | +| `express-validator` | 내장: [joi](https://joi.dev)를 사용하는 라우트 [`validate`](#joi) | +| `passport` | 인증 프레임워크 내장. 스킴은 `@hapi/basic`, `@hapi/cookie`, `@hapi/jwt`, [bell](#bell) 같은 플러그인에서 제공([인증 튜토리얼](/tutorials/ko_KR/auth)) | +| `cors` | 내장: 라우트 [`cors`](/api#route-options-cors) 옵션 | +| `helmet` | 일부 내장: 라우트 [`security`](/api#route-options-security) 옵션(Content-Security-Policy 제외) | +| `compression` | 내장, 기본으로 켜져 있음 | +| `app.set('view engine')` | [vision](#vision) 플러그인 | +| `express.static()` | [inert](#inert) 플러그인 | +| 커스텀 미들웨어 | [확장 지점](#extension)과 [플러그인](#creating) | +| 에러 처리 미들웨어 | `onPreResponse` [확장](#extension). `next(err)` 대신 [boom](#boom) 에러를 던짐 | + +## 설정 + +### 설치 + +| | Express | hapi | +| --------------- | --------------------------------------- | ------------------------ | +| 설치 | `npm install express` | `npm install @hapi/hapi` | +| TypeScript 타입 | `npm install --save-dev @types/express` | 포함됨 | + +### 서버 만들기 + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.listen(3000, function () { + console.log('Server is running on port 3000'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + await server.start(); + console.log('Server running on port 3000'); +}; + +init(); +``` + +hapi에서는 `server` 객체가 애플리케이션의 중심입니다. 서버 옵션(포트, 호스트, 기본 라우트 설정)이 서버의 동작을 결정하고, 라우트, 플러그인, 확장은 모두 서버에 추가됩니다. `server.start()`는 프로미스를 반환합니다. + +## 라우트 + +Express: + +```ts +app.get('/hello', function (req, res) { + res.send('Hello World!'); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello', + handler: (request, h) => { + return 'Hello World!'; + }, +}); +``` + +| | Express | hapi | +| --------------- | -------------------------------------- | --------------------------------------------------- | +| 라우트 정의 | `app.METHOD(path, handler)` | `server.route({ method, path, handler })` | +| 응답 보내기 | `res.send()` 호출 | 값을 반환 | +| 실행되는 라우트 | 일치하는 라우트 중 먼저 등록된 것 | 등록 순서와 관계없이 가장 구체적으로 일치하는 것 | +| 겹치는 라우트 | 둘 다 등록되고 먼저 등록된 것이 선택됨 | 두 라우트가 충돌하면 `server.route()`가 예외를 던짐 | + +hapi는 가장 구체적인 라우트를 고르기 때문에 `'/home'`은 항상 `'/{any*}'`보다 우선하며, 등록 순서가 응답할 라우트를 결정하는 일은 없습니다. 규칙은 [라우팅 튜토리얼](/tutorials/ko_KR/routing)을 참고하세요. + +### 메서드 + +| Express | hapi | +| ---------------------- | ------------------------------------------------ | +| `app.get(path, ...)` | `method: 'GET'` | +| 메서드마다 라우트 하나 | `method: ['PUT', 'POST']` | +| `app.all(path, ...)` | `method: '*'` | +| `app.head(path, ...)` | 허용되지 않음. 모든 `GET` 라우트가 `HEAD`에 응답 | + +```ts +server.route({ + method: ['PUT', 'POST'], + path: '/', + handler: function (request, h) { + return 'I did something!'; + }, +}); +``` + +### 경로 + +hapi에서는 파라미터를 중괄호로 감쌉니다: + +| | Express 5 | hapi | +| --------------- | ---------------- | ----------------- | +| 파라미터 | `/users/:userId` | `/users/{userId}` | +| 선택적 파라미터 | `/hello{/:user}` | `/hello/{user?}` | +| 와일드카드 | `/files/*path` | `/files/{path*}` | + +hapi에서 `{user*}`와 세그먼트 전체를 차지하는 `{user?}`는 마지막 세그먼트여야 합니다. `{user*2}`와 `/a{user?}/b` 같은 세그먼트 일부의 선택적 파라미터는 어디에나 올 수 있습니다. + +### 파라미터 + +Express: + +```ts +app.get('/hello/:name', function (req, res) { + const name = req.params.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: function (request, h) { + const name = request.params.name; + return 'Hello ' + name; + }, +}); +``` + +| Express | hapi | +| ------------ | ----------------- | +| `req.params` | `request.params` | +| `req.query` | `request.query` | +| `req.body` | `request.payload` | + +### 핸들러 + +Express 핸들러는 `req`와 `res`를 받고 `res`의 메서드를 호출해 응답합니다. hapi 핸들러는 `request`와 [응답 툴킷](/api#response-toolkit)인 `h`를 받고 응답을 반환합니다: + +| Express | hapi | +| ------------------------------ | --------------------------------------------------------- | +| `res.send('text')` | `return 'text'` | +| `res.json(object)` | `return object` | +| `res.status(201).send(object)` | `return h.response(object).code(201)` | +| `res.set('x-name', 'value')` | `return h.response(object).header('x-name', 'value')` | +| `res.redirect('/')` | `return h.redirect('/')` | +| `res.cookie('name', 'value')` | `h.state('name', 'value')`, [쿠키](#config) 참고 | +| `res.sendFile(file)` | `return h.file(file)`, [inert](#inert) 참고 | +| `res.render('index', context)` | `return h.view('index', context)`, [vision](#vision) 참고 | +| `next(err)` | `throw Boom.notFound()`, [boom](#boom) 참고 | + +핸들러가 상태 코드나 헤더를 설정해야 할 때는 값을 `h.response()`로 감쌉니다: + +```ts +server.route({ + method: 'POST', + path: '/user', + handler: function (request, h) { + const user = { + firstName: 'John', + lastName: 'Doe', + id: 123, + }; + + return h.response(user).code(201).header('location', '/user/123'); + }, +}); +``` + +hapi는 반환된 객체를 JSON으로 직렬화하고 `content-type`을 설정합니다. hapi가 반환 값을 응답으로 바꾸는 방법은 [응답 마샬링 튜토리얼](/tutorials/ko_KR/response-marshalling)을 참고하세요. + +## 미들웨어 대 플러그인과 확장 + +Express는 `app.use()`를 호출한 순서대로 미들웨어를 실행합니다. 한 미들웨어가 다른 미들웨어에 의존하면 직접 올바른 순서를 유지해야 하고, 순서가 틀리면 요청 시점에 실패합니다. + +hapi는 모든 요청을 정해진 단계의 순서, 즉 [요청 라이프사이클](/api#request-lifecycle)에 통과시킵니다. 미들웨어의 순서를 정하는 대신, 이름이 붙은 단계에 코드를 연결합니다. 아래 표에는 각 단계가 대신하는 Express 미들웨어도 함께 나와 있습니다: + +| hapi 단계 | 확장 지점 | Express에서 대응하는 것 | +| ---------------------- | -------------------------------- | ---------------------------- | +| 요청 수신 | `onRequest` | 라우팅 전의 `app.use()` | +| 라우트 조회, 쿠키 파싱 | | `app.get()`, `cookie-parser` | +| 인증 전 | `onPreAuth` | | +| 인증 | | `passport` | +| 페이로드 파싱 | | `express.json()` | +| 인증 후 | `onCredentials`, `onPostAuth` | | +| 검증 | | `express-validator` | +| 핸들러 전 | `onPreHandler` | | +| 핸들러 | | 라우트 콜백 | +| 핸들러 후 | `onPostHandler`, `onPreResponse` | 에러 처리 미들웨어 | +| 응답 전송 후 | `onPostResponse` | | + +확장 지점은 `server.ext()`로 직접 코드를 추가하는 곳입니다. 나머지 단계는 hapi가 각 라우트의 설정에 따라 실행합니다. + +재사용 가능한 미들웨어는 플러그인으로 대체합니다. 플러그인은 서버에 라우트, 확장, 데코레이션을 등록하는 이름 있는 단위입니다. 플러그인은 `dependencies` 속성에 필요한 다른 플러그인을 선언할 수 있으며, 그중 하나가 없으면 hapi는 요청 시점에 실패하는 대신 시작 자체를 거부합니다. [플러그인 튜토리얼](/tutorials/ko_KR/plugins)을 참고하세요. + +### 확장 지점 + +모든 요청에 실행되는 미들웨어는 확장이 됩니다: + +Express: + +```ts +app.use(function (req, res, next) { + console.log(req.method, req.url); + next(); +}); +``` + +hapi: + +```ts +server.ext('onRequest', function (request, h) { + console.log(request.method, request.path); + return h.continue; +}); +``` + +`return h.continue`는 `next()`의 역할을 합니다. 요청이 다음 단계로 넘어갑니다. 대신 takeover 응답(`h.response(...).takeover()`)을 반환하거나 에러를 던지면 요청이 그 자리에서 끝납니다. + +hapi에는 요청 확장 지점이 여덟 개 있습니다: `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler`, `onPreResponse`, `onPostResponse`. 각각을 언제 쓰는지는 [라이프사이클 훅 튜토리얼](/tutorials/ko_KR/lifecycle-hooks)에서 다루고, 옵션은 [`server.ext()`](/api#server-ext-events)에 나와 있습니다. + +### 플러그인 만들기 + +모든 요청에 헬퍼를 추가하는 미들웨어는 [데코레이션](/tutorials/ko_KR/decorators)을 추가하는 플러그인이 됩니다. 플러그인은 `name`(또는 `pkg`)과 `async register(server, options)` 함수를 가진 객체입니다. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): Date; + } + } +} + +const getDate = function (req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = new Date(); + return date; + }; + + next(); +}; +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): Date; + } +} + +const getDate: Plugin = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +- Express 미들웨어는 요청마다 `req.getDate`를 다시 설정합니다. hapi 플러그인은 등록할 때 `server.decorate()`를 한 번 호출하고, 이후 모든 핸들러에서 `h.getDate()`를 쓸 수 있습니다. +- TypeScript는 새 메서드를 선언해야만 알 수 있습니다. Express는 전역 `Express.Request` 인터페이스로, hapi는 `@hapi/hapi`의 `ResponseToolkit` 인터페이스에 추가해서 선언합니다. + +### 플러그인 불러오기 + +Express: + +```ts +app.use(getDate); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, +}); +``` + +### 옵션 + +Express 미들웨어는 미들웨어를 반환하는 함수 형태로 옵션을 받습니다. hapi 플러그인은 `register`의 두 번째 인자로 옵션을 받습니다. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): string; + } + } +} + +export default function (options: { name: string }) { + return function getDate(req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + next(); + }; +} +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): string; + } +} + +const getDate: Plugin<{ name: string }> = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +옵션은 플러그인을 등록할 때 넘깁니다. `Plugin<{ name: string }>`의 타입 인자가 `register` 안의 `options` 타입을 정하고, 옵션이 맞지 않는 `server.register()` 호출은 TypeScript가 잡아냅니다. hapi는 런타임에 옵션을 검사하지 않습니다: + +Express: + +```ts +import getDate from './mw/getDate.ts'; + +app.use(getDate({ name: 'Tom' })); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, + options: { + name: 'Tom', + }, +}); +``` + +## body-parser + +hapi는 미들웨어 없이 핸들러가 실행되기 전에 요청 페이로드를 파싱합니다. 결과는 `request.payload`에 있습니다. + +| 콘텐츠 타입 | Express | hapi | +| ----------------------------------- | ------------------------------- | ------------------------------------------ | +| `application/json` | `app.use(express.json())` | 기본으로 파싱 | +| `application/x-www-form-urlencoded` | `app.use(express.urlencoded())` | 기본으로 파싱 | +| `text/*` | `app.use(express.text())` | 기본으로 파싱 | +| `multipart/form-data` | `multer` 같은 별도 패키지 | 라우트 옵션 `payload: { multipart: true }` | + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.use(express.urlencoded()); + +app.post('/hello', function (req, res) { + const name = req.body.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/hello', + handler: function (request, h) { + const name = request.payload.name; + return 'Hello ' + name; + }, +}); +``` + +hapi는 클라이언트가 무엇을 보낼지 알 수 없으므로 `request.payload`에는 정해진 형태가 없습니다. 타입 인자 `server.route<{ Payload: { name: string } }>()`는 핸들러가 받는 값을 TypeScript에 알려 줍니다. 이것은 타입일 뿐이므로, 런타임에 페이로드를 검사하려면 [검증](#joi)을 추가하세요. 크기 제한, 타임아웃, 파일 업로드는 라우트의 [`payload` 옵션](/api#route-options-payload)으로 설정합니다. + +## cookie-parser + +hapi는 미들웨어 없이 쿠키를 읽고 씁니다. Express는 `res.cookie()`를 호출할 때마다 쿠키 옵션을 받지만, hapi는 `server.state()`로 각 쿠키를 한 번 정의하고 이후 모든 라우트에서 이름으로 설정합니다. + +| 옵션 | Express `res.cookie()` | hapi `server.state()` | +| --------------- | ------------------------- | ------------------------------- | +| Secure 플래그 | `secure`, 기본으로 꺼짐 | `isSecure`, 기본으로 켜짐 | +| HttpOnly 플래그 | `httpOnly`, 기본으로 꺼짐 | `isHttpOnly`, 기본으로 켜짐 | +| SameSite | `sameSite` | `isSameSite`, 기본값 `'Strict'` | +| 수명 | `maxAge` (ms) | `ttl` (ms) | +| 서명된 값 | `signed` | `sign: { password }` | +| 암호화된 값 | 내장되지 않음 | `encoding: 'iron'` | + +모든 옵션은 [`server.state()`](/api#server-state-name-options)를, 자세한 내용은 [쿠키 튜토리얼](/tutorials/ko_KR/cookies)을 참고하세요. + +### 쿠키 설정하기 + +Express: + +```ts +import express from 'express'; +import cookieParser from 'cookie-parser'; + +const app = express(); + +app.use(cookieParser()); + +app.get('/', function (req, res) { + res.cookie('username', 'tom', { secure: true, httpOnly: true }); + res.send('Hello'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 8000 }); + +server.state('username', { + ttl: null, + isSecure: true, + isHttpOnly: true, +}); + +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + h.state('username', 'tom'); + return 'Hello'; + }, +}); +``` + +### 쿠키 값 읽기 + +Express: + +```ts +app.get('/whoami', (req, res) => { + res.send(req.cookies.username); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + const username = request.state.username; + if (typeof username !== 'string') { + return 'No username cookie'; + } + + return username; + }, +}); +``` + +`request.state`에는 클라이언트가 보낸 쿠키가 무엇이든 들어 있으므로 hapi는 각 값의 타입을 `unknown`으로 지정합니다. 사용하기 전에 타입을 확인하세요. + +## Passport -> bell + +Express는 Passport로 서드파티 로그인을 처리합니다. hapi는 [bell](/module/bell)을 사용하는데, Twitter, Facebook, Google, GitHub를 포함해 30개가 넘는 OAuth 제공자에 대한 설정이 내장되어 있고 커스텀 제공자도 지원합니다. [bell 제공자 목록](/module/bell/providers)을 참고하세요. + +Passport는 로그인을 시작하는 라우트와 제공자의 콜백을 받는 라우트, 두 개가 필요합니다. bell은 하나의 라우트로 둘 다 처리합니다: + +```mermaid +sequenceDiagram + participant B as 브라우저 + participant S as hapi 서버 + participant P as Twitter + B->>S: GET /auth/twitter + Note over S: 쿼리에 OAuth 응답이 없으므로
bell이 로그인을 시작 + S-->>B: Twitter로 302 리다이렉트 + B->>P: 로그인하고 승인 + P-->>B: /auth/twitter?oauth_token=...으로 302 리다이렉트 + B->>S: GET /auth/twitter?oauth_token=... + Note over S: bell이 Twitter와 토큰을 교환하고
request.auth.credentials를 설정 + S-->>B: 핸들러 응답 +``` + +Express: + +``` +npm install passport passport-twitter +``` + +TypeScript를 사용한다면 `npm install --save-dev @types/passport @types/passport-twitter`도 실행하세요. + +```ts +import passport from 'passport'; +import { Strategy as TwitterStrategy } from 'passport-twitter'; + +passport.use( + new TwitterStrategy( + { + consumerKey: TWITTER_CONSUMER_KEY, + consumerSecret: TWITTER_CONSUMER_SECRET, + callbackURL: '/auth/twitter/callback', + }, + function (token, tokenSecret, profile, cb) { + User.findOrCreate({ twitterId: profile.id }, function (err, user) { + return cb(err, user); + }); + }, + ), +); + +passport.serializeUser(function (user, cb) { + cb(null, user); +}); + +passport.deserializeUser(function (user: Express.User, cb) { + cb(null, user); +}); + +app.get('/auth/twitter', passport.authenticate('twitter')); +app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login' }), function (req, res) { + res.redirect('/'); +}); +``` + +hapi: + +``` +npm install @hapi/bell +``` + +```ts +import Hapi from '@hapi/hapi'; +import Bell from '@hapi/bell'; + +const server = Hapi.server({ port: 8000 }); + +await server.register(Bell); + +server.auth.strategy('twitter', 'bell', { + provider: 'twitter', + password: 'cookie_encryption_password_secure', + clientId: TWITTER_CONSUMER_KEY, + clientSecret: TWITTER_CONSUMER_SECRET, + isSecure: false, +}); + +server.route({ + method: '*', + path: '/auth/twitter', // The callback endpoint registered with the provider + handler: function (request, h) { + if (!request.auth.isAuthenticated) { + return `Authentication failed due to: ${request.auth.error.message}`; + } + + // Look up or register the account, start a local session, and redirect. + // request.auth.credentials holds the provider's profile and tokens. + + return h.redirect('/home'); + }, + options: { + auth: { + strategy: 'twitter', + mode: 'try', + }, + }, +}); +``` + +| 옵션 | 설명 | +| -------------- | ---------------------------------------------------------------------------------------------------------- | +| `provider` | 내장된 제공자 설정의 이름 | +| `password` | bell이 두 요청 사이에 유지하는 임시 쿠키를 암호화. 최소 32자 | +| `clientId` | 제공자에게서 받은 OAuth 클라이언트 ID | +| `clientSecret` | 제공자에게서 받은 OAuth 클라이언트 시크릿 | +| `isSecure` | 쿠키의 Secure 플래그. `false`이면 개발 중에 일반 HTTP를 허용. 프로덕션에서는 기본값 `true`를 그대로 두세요 | + +`mode: 'try'`는 로그인이 실패해도 핸들러가 실행되게 하므로, 그냥 401을 보내는 대신 에러를 보여 줄 수 있습니다. 로그인을 시작한 요청의 쿼리 파라미터는 `request.auth.credentials.query`로 돌아오므로, 제공자를 왕복하는 동안 돌아갈 URL을 전달할 수 있습니다. + +## express-validator -> joi + +hapi는 [joi](https://joi.dev) 스키마를 사용해 라우트 설정에 따라 요청과 응답을 검증합니다. express-validator는 요청만 검사하지만, hapi는 핸들러가 반환하는 값도 검사할 수 있습니다. 자세한 내용은 [검증 튜토리얼](/tutorials/ko_KR/validation)을 참고하세요. + +### 입력 검증 + +Express: + +``` +npm install express-validator +``` + +```ts +import express from 'express'; +import { body, validationResult } from 'express-validator'; + +app.use(express.json()); +app.use(express.urlencoded()); + +app.post('/post', body('post', 'Post too long').isLength({ max: 140 }), function (req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).send(errors.array()); + } else { + res.send('Blog post added!'); + } +}); +``` + +hapi: + +``` +npm install joi +``` + +```ts +import Joi from 'joi'; + +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added!'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().max(140), + }), + }, + }, +}); +``` + +- 핸들러는 페이로드가 통과할 때만 실행됩니다. 그렇지 않으면 hapi가 알아서 400으로 응답하므로 핸들러에는 에러 분기가 없습니다. +- 규칙은 `Joi.string().min(1).max(140)`처럼 체이닝합니다. +- `validate`는 `params`, `query`, `headers`, `state` 스키마도 받습니다. + +### 출력 검증 + +```ts +const bookSchema = Joi.object({ + title: Joi.string().required(), + author: Joi.string().required(), + isbn: Joi.string().length(10), + pageCount: Joi.number(), + datePublished: Joi.date().iso(), +}); + +server.route({ + method: 'GET', + path: '/books', + handler: async function (request, h) { + return await getBooks(); + }, + options: { + response: { + schema: Joi.array().items(bookSchema), + failAction: 'log', + }, + }, +}); +``` + +`response.schema`는 핸들러가 반환하는 책 목록을 검사합니다. `failAction: 'log'`이면 스키마를 통과하지 못한 응답도 그대로 전송되고 실패는 로그로 남습니다. 기본값인 `'error'`는 응답을 500으로 바꿉니다. + +## app.set('view engine') -> vision + +hapi는 [vision](/module/vision) 플러그인으로 템플릿을 렌더링합니다. 여러 템플릿 엔진을 동시에 지원하고 partial, 헬퍼, 레이아웃도 지원합니다. 자세한 내용은 [뷰 튜토리얼](/tutorials/ko_KR/views)을 참고하세요. + +### 뷰 엔진 설정하기 + +Express: + +```ts +app.set('view engine', 'pug'); +``` + +hapi: + +```ts +import Vision from '@hapi/vision'; +import Pug from 'pug'; + +await server.register(Vision); + +server.views({ + engines: { + pug: Pug, + }, + relativeTo: import.meta.dirname, + path: 'views', +}); +``` + +Express는 기본적으로 `./views`에서 템플릿을 찾습니다. hapi에서는 `relativeTo`와 `path`로 폴더를 지정합니다. + +### 뷰 렌더링하기 + +Express: + +```ts +app.get('/', function (req, res) { + res.render('index', { title: 'Homepage', message: 'Welcome' }); +}); +``` + +hapi, `h.view()` 사용: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + return h.view('index', { title: 'Homepage', message: 'Welcome' }); + }, +}); +``` + +hapi, 라우트가 템플릿만 렌더링할 때 `view` 핸들러 사용: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'Homepage', + message: 'Welcome', + }, + }, + }, +}); +``` + +## express.static() -> inert + +hapi는 [inert](/module/inert) 플러그인으로 정적 파일을 제공합니다. inert는 `file`과 `directory` 핸들러, 그리고 `h.file()` 메서드를 추가합니다. 자세한 내용은 [파일 제공 튜토리얼](/tutorials/ko_KR/serving-files)을 참고하세요. + +아래 두 예제는 모두 이 설정을 사용합니다. inert를 등록하고 파일 경로의 기준이 되는 폴더를 지정합니다: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), + }, + }, +}); + +await server.register(Inert); +``` + +### 단일 파일 제공 + +Express: + +```ts +app.get('/image', function (req, res) { + res.sendFile('image.jpg', { root: './public' }); +}); +``` + +hapi, `h.file()` 사용: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: function (request, h) { + return h.file('image.jpg'); + }, +}); +``` + +hapi, `file` 핸들러 사용: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: { + file: 'image.jpg', + }, +}); +``` + +### 정적 파일 서버 + +Express: + +```ts +app.use(express.static('public')); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/{param*}', + handler: { + directory: { + path: '.', + }, + }, +}); +``` + +`{param*}` 와일드카드는 모든 경로와 일치하고, `directory` 핸들러는 `public` 폴더에서 일치하는 파일을 제공하므로 `/logo.png`는 `public/logo.png`를 반환합니다. hapi는 가장 구체적인 라우트를 고르므로 다른 라우트는 여전히 이 모든 경로를 받는 라우트보다 우선합니다. + +## 에러 처리 -> boom + +hapi는 [boom](/module/boom) 에러 객체로 만든 JSON으로 에러에 응답합니다. Express는 HTML 페이지로 응답합니다. 일치하는 라우트가 없는 `GET /hello`의 경우: + +Express: + +```text +Cannot GET /hello +``` + +hapi: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Not Found" +} +``` + +### 커스텀 메시지 + +Express: + +```ts +res.status(404).send({ status: 404, error: 'Page not found' }); +``` + +hapi: + +```ts +throw Boom.notFound('Page not found'); +``` + +던진 에러는 다음 응답이 됩니다: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Page not found" +} +``` + +boom에는 `Boom.badRequest()`, `Boom.unauthorized()`, `Boom.badImplementation()`처럼 자주 쓰는 4xx와 5xx 상태마다 함수가 있습니다. 핸들러, 확장, 플러그인 어디서든 던지면 hapi가 이를 응답으로 바꿉니다. boom 에러가 아닌 에러는 500이 되고 메시지는 클라이언트에게 숨겨집니다. 전체 목록은 [boom 문서](/module/boom)를 참고하세요. diff --git a/docs/tutorials/pt_BR/express-to-hapi.md b/docs/tutorials/pt_BR/express-to-hapi.md index 51576c5c..676e443a 100644 --- a/docs/tutorials/pt_BR/express-to-hapi.md +++ b/docs/tutorials/pt_BR/express-to-hapi.md @@ -1,11 +1,922 @@ --- -title: Express to hapi +title: Do Express para o hapi --- -# Express to hapi Guide +# Migrando do Express -## No Translation Yet +_Este guia é compatível com o hapi v21 e versões mais recentes_ -This guide has not been translated yet. If you would like to translate it, please submit a pull request. +## Visão geral + +Este guia mostra como fazer no hapi o que você faz no Express. Os exemplos usam o Express 5. + +A maior diferença: o Express adiciona recursos por meio de middlewares que você instala e ordena com `app.use()`, enquanto o hapi traz a maioria deles no próprio core e os configura por rota. Quando o hapi precisa de um plugin, o plugin vem do time do hapi. As dependências de runtime do próprio hapi são todas pacotes `@hapi/*` mantidos pelo mesmo time. + +| No Express você usa | No hapi você usa | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `express.json()`, `body-parser` | embutido: [interpretação do payload](#bodyparser) | +| `cookie-parser` | embutido: [`server.state()`](#config) | +| `express-validator` | embutido: [`validate`](#joi) da rota com [joi](https://joi.dev) | +| `passport` | framework de autenticação embutido; schemes vindos de plugins como `@hapi/basic`, `@hapi/cookie`, `@hapi/jwt` e [bell](#bell) (tutorial [Autenticação](/tutorials/pt_BR/auth)) | +| `cors` | embutido: opção [`cors`](/api#route-options-cors) da rota | +| `helmet` | parcialmente embutido: opção [`security`](/api#route-options-security) da rota (sem Content-Security-Policy) | +| `compression` | embutido, ativado por padrão | +| `app.set('view engine')` | plugin [vision](#vision) | +| `express.static()` | plugin [inert](#inert) | +| middleware próprio | [pontos de extensão](#extension) e [plugins](#creating) | +| middleware de tratamento de erros | uma [extensão](#extension) `onPreResponse`; lance erros do [boom](#boom) no lugar de `next(err)` | + +## Configuração + +### Instalação + +| | Express | hapi | +| ---------------- | --------------------------------------- | ------------------------ | +| Instalação | `npm install express` | `npm install @hapi/hapi` | +| Tipos TypeScript | `npm install --save-dev @types/express` | incluídos | + +### Criando um servidor + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.listen(3000, function () { + console.log('Server is running on port 3000'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + await server.start(); + console.log('Server running on port 3000'); +}; + +init(); +``` + +No hapi, o objeto `server` é o centro da aplicação. As opções dele (porta, host, configurações padrão de rota) definem como o servidor se comporta, e rotas, plugins e extensões são todos adicionados a ele. `server.start()` retorna uma promise. + +## Rotas + +Express: + +```ts +app.get('/hello', function (req, res) { + res.send('Hello World!'); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello', + handler: (request, h) => { + return 'Hello World!'; + }, +}); +``` + +| | Express | hapi | +| ------------------- | ------------------------------------------ | -------------------------------------------------------------------- | +| Definir uma rota | `app.METHOD(path, handler)` | `server.route({ method, path, handler })` | +| Enviar uma resposta | chamar `res.send()` | retornar o valor | +| Qual rota executa | a primeira rota registrada que corresponde | a correspondência mais específica, seja qual for a ordem de registro | +| Rotas sobrepostas | as duas são registradas; a primeira vence | `server.route()` lança um erro quando duas rotas entram em conflito | + +Como o hapi escolhe a rota mais específica, `'/home'` sempre vence `'/{any*}'`, e a ordem de registro nunca decide qual rota responde. Veja as regras no tutorial [Roteamento](/tutorials/pt_BR/routing). + +### Métodos + +| Express | hapi | +| --------------------- | ------------------------------------------------ | +| `app.get(path, ...)` | `method: 'GET'` | +| uma rota por método | `method: ['PUT', 'POST']` | +| `app.all(path, ...)` | `method: '*'` | +| `app.head(path, ...)` | não permitido: toda rota `GET` responde a `HEAD` | + +```ts +server.route({ + method: ['PUT', 'POST'], + path: '/', + handler: function (request, h) { + return 'I did something!'; + }, +}); +``` + +### Caminho + +No hapi, os parâmetros ficam entre chaves: + +| | Express 5 | hapi | +| ------------------ | ---------------- | ----------------- | +| Parâmetro | `/users/:userId` | `/users/{userId}` | +| Parâmetro opcional | `/hello{/:user}` | `/hello/{user?}` | +| Curinga | `/files/*path` | `/files/{path*}` | + +No hapi, `{user*}` e um `{user?}` de segmento inteiro precisam ser o último segmento; `{user*2}` e um opcional de segmento parcial, como `/a{user?}/b`, podem aparecer em qualquer lugar. + +### Parâmetros + +Express: + +```ts +app.get('/hello/:name', function (req, res) { + const name = req.params.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: function (request, h) { + const name = request.params.name; + return 'Hello ' + name; + }, +}); +``` + +| Express | hapi | +| ------------ | ----------------- | +| `req.params` | `request.params` | +| `req.query` | `request.query` | +| `req.body` | `request.payload` | + +### Handler + +Um handler do Express recebe `req` e `res`, e chama métodos de `res` para responder. Um handler do hapi recebe `request` e `h`, o [response toolkit](/api#response-toolkit), e retorna a resposta: + +| Express | hapi | +| ------------------------------ | --------------------------------------------------------- | +| `res.send('text')` | `return 'text'` | +| `res.json(object)` | `return object` | +| `res.status(201).send(object)` | `return h.response(object).code(201)` | +| `res.set('x-name', 'value')` | `return h.response(object).header('x-name', 'value')` | +| `res.redirect('/')` | `return h.redirect('/')` | +| `res.cookie('name', 'value')` | `h.state('name', 'value')`, veja [cookies](#config) | +| `res.sendFile(file)` | `return h.file(file)`, veja [inert](#inert) | +| `res.render('index', context)` | `return h.view('index', context)`, veja [vision](#vision) | +| `next(err)` | `throw Boom.notFound()`, veja [boom](#boom) | + +Quando o handler precisa definir um status code ou headers, ele envolve o valor em `h.response()`: + +```ts +server.route({ + method: 'POST', + path: '/user', + handler: function (request, h) { + const user = { + firstName: 'John', + lastName: 'Doe', + id: 123, + }; + + return h.response(user).code(201).header('location', '/user/123'); + }, +}); +``` + +O hapi serializa o objeto retornado como JSON e define o `content-type`. Veja o tutorial [Marshalling de respostas](/tutorials/pt_BR/response-marshalling) para entender como o hapi transforma um valor retornado em uma resposta. + +## Middleware vs. plugins e extensões + +O Express executa os middlewares na ordem em que você chama `app.use()`. Se um middleware depende de outro, você mantém a ordem certa manualmente, e uma ordem errada falha em tempo de requisição. + +O hapi passa toda requisição por uma sequência fixa de etapas, o [ciclo de vida da requisição](/api#request-lifecycle). Em vez de ordenar middlewares, você conecta o código a uma etapa com nome. Cada etapa abaixo também mostra o middleware do Express que faz o mesmo trabalho: + +| Etapa do hapi | Ponto de extensão | Equivalente no Express | +| ---------------------------------- | -------------------------------- | --------------------------------- | +| requisição recebida | `onRequest` | `app.use()` antes do roteamento | +| busca da rota, leitura dos cookies | | `app.get()`, `cookie-parser` | +| antes da autenticação | `onPreAuth` | | +| autenticação | | `passport` | +| interpretação do payload | | `express.json()` | +| depois da autenticação | `onCredentials`, `onPostAuth` | | +| validação | | `express-validator` | +| antes do handler | `onPreHandler` | | +| handler | | callback da rota | +| depois do handler | `onPostHandler`, `onPreResponse` | middleware de tratamento de erros | +| depois que a resposta é enviada | `onPostResponse` | | + +Os pontos de extensão são onde você adiciona o seu próprio código com `server.ext()`. O hapi executa as outras etapas a partir da configuração de cada rota. + +Plugins substituem os middlewares reutilizáveis. Um plugin é uma unidade com nome que registra rotas, extensões e decorações no servidor. Um plugin pode declarar os outros plugins de que precisa na propriedade `dependencies`, e o hapi se recusa a iniciar quando falta algum, em vez de falhar em tempo de requisição. Veja o tutorial [Plugins](/tutorials/pt_BR/plugins). + +### Pontos de extensão + +Um middleware que roda em toda requisição vira uma extensão: + +Express: + +```ts +app.use(function (req, res, next) { + console.log(req.method, req.url); + next(); +}); +``` + +hapi: + +```ts +server.ext('onRequest', function (request, h) { + console.log(request.method, request.path); + return h.continue; +}); +``` + +`return h.continue` faz o papel de `next()`: a requisição segue para a próxima etapa. Retornar uma resposta de takeover (`h.response(...).takeover()`), ou lançar um erro, encerra a requisição ali. + +O hapi tem oito pontos de extensão de requisição: `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler`, `onPreResponse` e `onPostResponse`. O tutorial [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks) explica quando usar cada um, e [`server.ext()`](/api#server-ext-events) lista as opções. + +### Criando um plugin + +Um middleware que adiciona um helper a toda requisição vira um plugin que adiciona uma [decoração](/tutorials/pt_BR/decorators). Um plugin é um objeto com um `name` (ou `pkg`) e uma função `async register(server, options)`. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): Date; + } + } +} + +const getDate = function (req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = new Date(); + return date; + }; + + next(); +}; +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): Date; + } +} + +const getDate: Plugin = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +- O middleware do Express define `req.getDate` de novo a cada requisição. O plugin do hapi chama `server.decorate()` uma vez, no registro, e a partir daí todo handler tem `h.getDate()`. +- O TypeScript só conhece o novo método depois que você o declara: no Express, pela interface global `Express.Request`; no hapi, adicionando-o à interface `ResponseToolkit` de `@hapi/hapi`. + +### Carregando um plugin + +Express: + +```ts +app.use(getDate); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, +}); +``` + +### Opções + +Um middleware do Express recebe opções sendo uma função que retorna o middleware. Um plugin do hapi recebe as opções como segundo argumento de `register`. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): string; + } + } +} + +export default function (options: { name: string }) { + return function getDate(req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + next(); + }; +} +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): string; + } +} + +const getDate: Plugin<{ name: string }> = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +Você passa as opções ao registrar o plugin. O argumento de tipo de `Plugin<{ name: string }>` tipa `options` dentro de `register`, e o TypeScript aponta uma chamada a `server.register()` cujas opções não correspondem a ele; o hapi não as verifica em tempo de execução: + +Express: + +```ts +import getDate from './mw/getDate.ts'; + +app.use(getDate({ name: 'Tom' })); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, + options: { + name: 'Tom', + }, +}); +``` + +## body-parser + +O hapi interpreta o payload da requisição antes de o handler rodar, sem nenhum middleware. O resultado fica em `request.payload`. + +| Content type | Express | hapi | +| ----------------------------------- | ----------------------------------- | -------------------------------------------- | +| `application/json` | `app.use(express.json())` | interpretado por padrão | +| `application/x-www-form-urlencoded` | `app.use(express.urlencoded())` | interpretado por padrão | +| `text/*` | `app.use(express.text())` | interpretado por padrão | +| `multipart/form-data` | um pacote separado, como o `multer` | opção de rota `payload: { multipart: true }` | + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.use(express.urlencoded()); + +app.post('/hello', function (req, res) { + const name = req.body.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/hello', + handler: function (request, h) { + const name = request.payload.name; + return 'Hello ' + name; + }, +}); +``` + +`request.payload` não tem um formato fixo, já que o hapi não tem como saber o que os clientes vão enviar. O argumento de tipo `server.route<{ Payload: { name: string } }>()` informa ao TypeScript o que o handler recebe. É apenas um tipo: para verificar o payload em tempo de execução, adicione [validação](#joi). Limites de tamanho, timeouts e upload de arquivos são definidos nas [opções `payload`](/api#route-options-payload) da rota. + +## cookie-parser + +O hapi lê e escreve cookies sem middleware. Enquanto o Express recebe as opções do cookie em toda chamada a `res.cookie()`, o hapi define cada cookie uma única vez com `server.state()`, e a partir daí qualquer rota o define pelo nome. + +| Opção | Express `res.cookie()` | hapi `server.state()` | +| ------------------- | --------------------------------- | ----------------------------------- | +| Flag Secure | `secure`, desativada por padrão | `isSecure`, ativada por padrão | +| Flag HttpOnly | `httpOnly`, desativada por padrão | `isHttpOnly`, ativada por padrão | +| SameSite | `sameSite` | `isSameSite`, `'Strict'` por padrão | +| Tempo de vida | `maxAge` (ms) | `ttl` (ms) | +| Valor assinado | `signed` | `sign: { password }` | +| Valor criptografado | não embutido | `encoding: 'iron'` | + +Veja [`server.state()`](/api#server-state-name-options) para todas as opções, e o tutorial [Cookies](/tutorials/pt_BR/cookies). + +### Definindo um cookie + +Express: + +```ts +import express from 'express'; +import cookieParser from 'cookie-parser'; + +const app = express(); + +app.use(cookieParser()); + +app.get('/', function (req, res) { + res.cookie('username', 'tom', { secure: true, httpOnly: true }); + res.send('Hello'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 8000 }); + +server.state('username', { + ttl: null, + isSecure: true, + isHttpOnly: true, +}); + +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + h.state('username', 'tom'); + return 'Hello'; + }, +}); +``` + +### Lendo o valor de um cookie + +Express: + +```ts +app.get('/whoami', (req, res) => { + res.send(req.cookies.username); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + const username = request.state.username; + if (typeof username !== 'string') { + return 'No username cookie'; + } + + return username; + }, +}); +``` + +`request.state` contém os cookies que o cliente enviou, sejam quais forem, então o hapi tipa cada valor como `unknown`. Verifique o tipo antes de usá-lo. + +## Passport -> bell + +O Express faz o login com terceiros usando o Passport. O hapi usa o [bell](/module/bell), que tem configurações embutidas para mais de 30 provedores OAuth, incluindo Twitter, Facebook, Google e GitHub, e suporta provedores próprios. Veja a [lista de provedores do bell](/module/bell/providers). + +O Passport precisa de duas rotas, uma para iniciar o login e outra para o callback do provedor. O bell usa uma única rota para as duas coisas: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as servidor hapi + participant P as Twitter + B->>S: GET /auth/twitter + Note over S: sem resposta OAuth na query,
então o bell inicia o login + S-->>B: redirecionamento 302 para o Twitter + B->>P: faz login e autoriza + P-->>B: redirecionamento 302 para /auth/twitter?oauth_token=... + B->>S: GET /auth/twitter?oauth_token=... + Note over S: o bell troca o token com o Twitter
e define request.auth.credentials + S-->>B: resposta do handler +``` + +Express: + +``` +npm install passport passport-twitter +``` + +Se você usa TypeScript, rode também `npm install --save-dev @types/passport @types/passport-twitter`. + +```ts +import passport from 'passport'; +import { Strategy as TwitterStrategy } from 'passport-twitter'; + +passport.use( + new TwitterStrategy( + { + consumerKey: TWITTER_CONSUMER_KEY, + consumerSecret: TWITTER_CONSUMER_SECRET, + callbackURL: '/auth/twitter/callback', + }, + function (token, tokenSecret, profile, cb) { + User.findOrCreate({ twitterId: profile.id }, function (err, user) { + return cb(err, user); + }); + }, + ), +); + +passport.serializeUser(function (user, cb) { + cb(null, user); +}); + +passport.deserializeUser(function (user: Express.User, cb) { + cb(null, user); +}); + +app.get('/auth/twitter', passport.authenticate('twitter')); +app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login' }), function (req, res) { + res.redirect('/'); +}); +``` + +hapi: + +``` +npm install @hapi/bell +``` + +```ts +import Hapi from '@hapi/hapi'; +import Bell from '@hapi/bell'; + +const server = Hapi.server({ port: 8000 }); + +await server.register(Bell); + +server.auth.strategy('twitter', 'bell', { + provider: 'twitter', + password: 'cookie_encryption_password_secure', + clientId: TWITTER_CONSUMER_KEY, + clientSecret: TWITTER_CONSUMER_SECRET, + isSecure: false, +}); + +server.route({ + method: '*', + path: '/auth/twitter', // The callback endpoint registered with the provider + handler: function (request, h) { + if (!request.auth.isAuthenticated) { + return `Authentication failed due to: ${request.auth.error.message}`; + } + + // Look up or register the account, start a local session, and redirect. + // request.auth.credentials holds the provider's profile and tokens. + + return h.redirect('/home'); + }, + options: { + auth: { + strategy: 'twitter', + mode: 'try', + }, + }, +}); +``` + +| Opção | O que é | +| -------------- | -------------------------------------------------------------------------------------------------------------- | +| `provider` | o nome da configuração de provedor embutida | +| `password` | criptografa o cookie temporário que o bell guarda entre as duas requisições; no mínimo 32 caracteres | +| `clientId` | o client ID OAuth fornecido pelo provedor | +| `clientSecret` | o client secret OAuth fornecido pelo provedor | +| `isSecure` | a flag Secure do cookie. `false` permite HTTP simples em desenvolvimento; mantenha o padrão `true` em produção | + +`mode: 'try'` deixa o handler rodar quando o login falha, para que ele mostre o erro em vez de um 401 sem explicação. Os parâmetros de query da requisição que iniciou o login voltam em `request.auth.credentials.query`, então você pode levar uma URL de retorno durante a ida e volta pelo provedor. + +## express-validator -> joi + +O hapi valida requisições e respostas a partir da configuração da rota, usando schemas do [joi](https://joi.dev). O express-validator só verifica requisições; o hapi também pode verificar o que o seu handler retorna. Veja o tutorial [Validação](/tutorials/pt_BR/validation) para saber mais. + +### Validação de entrada + +Express: + +``` +npm install express-validator +``` + +```ts +import express from 'express'; +import { body, validationResult } from 'express-validator'; + +app.use(express.json()); +app.use(express.urlencoded()); + +app.post('/post', body('post', 'Post too long').isLength({ max: 140 }), function (req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).send(errors.array()); + } else { + res.send('Blog post added!'); + } +}); +``` + +hapi: + +``` +npm install joi +``` + +```ts +import Joi from 'joi'; + +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added!'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().max(140), + }), + }, + }, +}); +``` + +- O handler só roda quando o payload passa. Caso contrário, o hapi responde com 400 por conta própria, então o handler não tem um ramo de erro. +- As regras são encadeadas, como em `Joi.string().min(1).max(140)`. +- `validate` também aceita schemas para `params`, `query`, `headers` e `state`. + +### Validação de saída + +```ts +const bookSchema = Joi.object({ + title: Joi.string().required(), + author: Joi.string().required(), + isbn: Joi.string().length(10), + pageCount: Joi.number(), + datePublished: Joi.date().iso(), +}); + +server.route({ + method: 'GET', + path: '/books', + handler: async function (request, h) { + return await getBooks(); + }, + options: { + response: { + schema: Joi.array().items(bookSchema), + failAction: 'log', + }, + }, +}); +``` + +`response.schema` verifica a lista de livros que o handler retorna. Com `failAction: 'log'`, uma resposta que não passa no schema ainda é enviada e a falha é registrada no log; o padrão, `'error'`, a substitui por um 500. + +## app.set('view engine') -> vision + +O hapi renderiza templates por meio do plugin [vision](/module/vision). Ele suporta várias template engines ao mesmo tempo, além de partials, helpers e layouts. Veja o tutorial [Views](/tutorials/pt_BR/views) para saber mais. + +### Definindo a view engine + +Express: + +```ts +app.set('view engine', 'pug'); +``` + +hapi: + +```ts +import Vision from '@hapi/vision'; +import Pug from 'pug'; + +await server.register(Vision); + +server.views({ + engines: { + pug: Pug, + }, + relativeTo: import.meta.dirname, + path: 'views', +}); +``` + +O Express procura os templates em `./views` por padrão. No hapi, você define a pasta com `relativeTo` e `path`. + +### Renderizando uma view + +Express: + +```ts +app.get('/', function (req, res) { + res.render('index', { title: 'Homepage', message: 'Welcome' }); +}); +``` + +hapi, com `h.view()`: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + return h.view('index', { title: 'Homepage', message: 'Welcome' }); + }, +}); +``` + +hapi, com o handler `view`, quando a rota só renderiza um template: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'Homepage', + message: 'Welcome', + }, + }, + }, +}); +``` + +## express.static() -> inert + +O hapi serve arquivos estáticos por meio do plugin [inert](/module/inert). Ele adiciona os handlers `file` e `directory` e o método `h.file()`. Veja o tutorial [Servindo arquivos](/tutorials/pt_BR/serving-files) para saber mais. + +Os dois exemplos abaixo usam esta configuração, que registra o inert e define a pasta a partir da qual os caminhos de arquivo são resolvidos: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), + }, + }, +}); + +await server.register(Inert); +``` + +### Servindo arquivos individuais + +Express: + +```ts +app.get('/image', function (req, res) { + res.sendFile('image.jpg', { root: './public' }); +}); +``` + +hapi, com `h.file()`: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: function (request, h) { + return h.file('image.jpg'); + }, +}); +``` + +hapi, com o handler `file`: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: { + file: 'image.jpg', + }, +}); +``` + +### Servidor de arquivos estáticos + +Express: + +```ts +app.use(express.static('public')); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/{param*}', + handler: { + directory: { + path: '.', + }, + }, +}); +``` + +O curinga `{param*}` corresponde a qualquer caminho, e o handler `directory` serve o arquivo correspondente da pasta `public`, então `/logo.png` retorna `public/logo.png`. Como o hapi escolhe a rota mais específica, suas outras rotas continuam vencendo esta rota pega-tudo. + +## Tratamento de erros -> boom + +O hapi responde aos erros com JSON, montado a partir de objetos de erro do [boom](/module/boom). O Express responde com uma página HTML. Para um `GET /hello` que não corresponde a nenhuma rota: + +Express: + +```text +Cannot GET /hello +``` + +hapi: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Not Found" +} +``` + +### Mensagens próprias + +Express: + +```ts +res.status(404).send({ status: 404, error: 'Page not found' }); +``` + +hapi: + +```ts +throw Boom.notFound('Page not found'); +``` + +O erro lançado vira esta resposta: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Page not found" +} +``` + +O boom tem uma função para os status 4xx e 5xx mais comuns, como `Boom.badRequest()`, `Boom.unauthorized()` e `Boom.badImplementation()`. Lance uma delas em qualquer ponto de um handler, extensão ou plugin, e o hapi a transforma na resposta. Erros que não são do boom viram um 500, e a mensagem fica oculta para o cliente. Veja a [documentação do boom](/module/boom) para a lista completa. diff --git a/docs/tutorials/tr_TR/express-to-hapi.md b/docs/tutorials/tr_TR/express-to-hapi.md index 51576c5c..42dcf243 100644 --- a/docs/tutorials/tr_TR/express-to-hapi.md +++ b/docs/tutorials/tr_TR/express-to-hapi.md @@ -1,11 +1,922 @@ --- -title: Express to hapi +title: Express'ten hapi'ye --- -# Express to hapi Guide +# Express'ten Geçiş -## No Translation Yet +_Bu rehber hapi v21 ve sonraki sürümlerle uyumludur_ -This guide has not been translated yet. If you would like to translate it, please submit a pull request. +## Genel Bakış + +Bu rehber, Express'te yaptığın şeyleri hapi'de nasıl yapacağını gösteriyor. Örneklerde Express 5 kullanılıyor. + +En büyük fark şu: Express özellikleri, kurup `app.use()` ile sıraladığın middleware'ler aracılığıyla ekler; hapi ise bunların çoğunu çekirdeğine dahil eder ve route bazında yapılandırır. hapi'nin bir plugin'e ihtiyaç duyduğu yerlerde plugin hapi ekibinden gelir. hapi'nin kendi çalışma zamanı bağımlılıklarının hepsi aynı ekibin bakımını yaptığı `@hapi/*` paketleridir. + +| Express'te kullandığın | hapi'de kullandığın | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `express.json()`, `body-parser` | yerleşik: [payload ayrıştırma](#bodyparser) | +| `cookie-parser` | yerleşik: [`server.state()`](#config) | +| `express-validator` | yerleşik: [joi](https://joi.dev) ile route [`validate`](#joi) seçeneği | +| `passport` | auth altyapısı yerleşik; şemalar `@hapi/basic`, `@hapi/cookie`, `@hapi/jwt` ve [bell](#bell) gibi plugin'lerden gelir ([Kimlik Doğrulama](/tutorials/tr_TR/auth) eğitimi) | +| `cors` | yerleşik: route [`cors`](/api#route-options-cors) seçeneği | +| `helmet` | kısmen yerleşik: route [`security`](/api#route-options-security) seçeneği (Content-Security-Policy yok) | +| `compression` | yerleşik, varsayılan olarak açık | +| `app.set('view engine')` | [vision](#vision) plugin'i | +| `express.static()` | [inert](#inert) plugin'i | +| özel middleware | [extension point'ler](#extension) ve [plugin'ler](#creating) | +| hata yönetimi middleware'i | bir `onPreResponse` [extension'ı](#extension); `next(err)` yerine [boom](#boom) hataları fırlat | + +## Kurulum + +### Yükleme + +| | Express | hapi | +| ------------------ | --------------------------------------- | ------------------------ | +| Yükleme | `npm install express` | `npm install @hapi/hapi` | +| TypeScript tipleri | `npm install --save-dev @types/express` | dahil | + +### Sunucu Oluşturmak + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.listen(3000, function () { + console.log('Server is running on port 3000'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const init = async () => { + const server = Hapi.server({ + port: 3000, + host: 'localhost', + }); + + await server.start(); + console.log('Server running on port 3000'); +}; + +init(); +``` + +hapi'de `server` nesnesi uygulamanın merkezidir. Seçenekleri (port, host, varsayılan route ayarları) sunucunun nasıl davranacağını belirler; route'lar, plugin'ler ve extension'ların hepsi ona eklenir. `server.start()` bir promise döndürür. + +## Route'lar + +Express: + +```ts +app.get('/hello', function (req, res) { + res.send('Hello World!'); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello', + handler: (request, h) => { + return 'Hello World!'; + }, +}); +``` + +| | Express | hapi | +| ------------------- | --------------------------------- | ----------------------------------------------------- | +| Route tanımlamak | `app.METHOD(path, handler)` | `server.route({ method, path, handler })` | +| Yanıt göndermek | `res.send()` çağır | değeri döndür | +| Hangi route çalışır | eşleşen ve ilk kaydedilen route | kayıt sırasından bağımsız olarak en özel eşleşme | +| Çakışan route'lar | ikisi de kaydedilir; ilki kazanır | iki route çakıştığında `server.route()` hata fırlatır | + +hapi en özel route'u seçtiği için `'/home'` her zaman `'/{any*}'`'a karşı kazanır ve hangi route'un yanıt vereceğine asla kayıt sırası karar vermez. Kurallar için [Routing](/tutorials/tr_TR/routing) eğitimine bak. + +### Metotlar + +| Express | hapi | +| ---------------------- | -------------------------------------------------------- | +| `app.get(path, ...)` | `method: 'GET'` | +| metot başına bir route | `method: ['PUT', 'POST']` | +| `app.all(path, ...)` | `method: '*'` | +| `app.head(path, ...)` | izin verilmez: her `GET` route'u `HEAD`'e de yanıt verir | + +```ts +server.route({ + method: ['PUT', 'POST'], + path: '/', + handler: function (request, h) { + return 'I did something!'; + }, +}); +``` + +### Path + +hapi'de parametreler süslü parantez içine yazılır: + +| | Express 5 | hapi | +| ---------------------- | ---------------- | ----------------- | +| Parametre | `/users/:userId` | `/users/{userId}` | +| İsteğe bağlı parametre | `/hello{/:user}` | `/hello/{user?}` | +| Joker | `/files/*path` | `/files/{path*}` | + +hapi'de `{user*}` ve tüm segmenti kaplayan `{user?}` son segment olmalıdır; `{user*2}` ve `/a{user?}/b` gibi segmentin bir kısmını kaplayan isteğe bağlı parametreler her yerde bulunabilir. + +### Parametreler + +Express: + +```ts +app.get('/hello/:name', function (req, res) { + const name = req.params.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/hello/{name}', + handler: function (request, h) { + const name = request.params.name; + return 'Hello ' + name; + }, +}); +``` + +| Express | hapi | +| ------------ | ----------------- | +| `req.params` | `request.params` | +| `req.query` | `request.query` | +| `req.body` | `request.payload` | + +### Handler + +Bir Express handler'ı `req` ve `res` alır ve yanıt vermek için `res` üzerindeki metotları çağırır. Bir hapi handler'ı ise `request` ile [response toolkit](/api#response-toolkit) olan `h`'yi alır ve yanıtı döndürür: + +| Express | hapi | +| ------------------------------ | --------------------------------------------------------- | +| `res.send('text')` | `return 'text'` | +| `res.json(object)` | `return object` | +| `res.status(201).send(object)` | `return h.response(object).code(201)` | +| `res.set('x-name', 'value')` | `return h.response(object).header('x-name', 'value')` | +| `res.redirect('/')` | `return h.redirect('/')` | +| `res.cookie('name', 'value')` | `h.state('name', 'value')`, bkz. [cookie'ler](#config) | +| `res.sendFile(file)` | `return h.file(file)`, bkz. [inert](#inert) | +| `res.render('index', context)` | `return h.view('index', context)`, bkz. [vision](#vision) | +| `next(err)` | `throw Boom.notFound()`, bkz. [boom](#boom) | + +Handler'ın bir durum kodu ya da header ayarlaması gerektiğinde değeri `h.response()` ile sarmalar: + +```ts +server.route({ + method: 'POST', + path: '/user', + handler: function (request, h) { + const user = { + firstName: 'John', + lastName: 'Doe', + id: 123, + }; + + return h.response(user).code(201).header('location', '/user/123'); + }, +}); +``` + +hapi döndürülen nesneyi JSON olarak serileştirir ve `content-type` değerini ayarlar. hapi'nin döndürülen bir değeri nasıl yanıta dönüştürdüğünü öğrenmek için [Yanıt Hazırlama](/tutorials/tr_TR/response-marshalling) eğitimine bak. + +## Middleware ile Plugin'ler ve Extension'lar + +Express middleware'leri `app.use()` çağırdığın sırayla çalıştırır. Bir middleware başka birine bağımlıysa onları doğru sırada elle tutarsın ve yanlış bir sıra istek anında hata verir. + +hapi her isteği sabit bir adımlar dizisinden, yani [istek yaşam döngüsünden](/api#request-lifecycle) geçirir. Middleware'leri sıralamak yerine kodunu adlandırılmış bir adıma bağlarsın. Aşağıdaki her adım, aynı işi yapan Express middleware'ini de gösteriyor: + +| hapi adımı | Extension point | Express karşılığı | +| ------------------------------ | -------------------------------- | ---------------------------- | +| istek alındı | `onRequest` | routing öncesi `app.use()` | +| route arama, cookie ayrıştırma | | `app.get()`, `cookie-parser` | +| kimlik doğrulama öncesi | `onPreAuth` | | +| kimlik doğrulama | | `passport` | +| payload ayrıştırma | | `express.json()` | +| kimlik doğrulama sonrası | `onCredentials`, `onPostAuth` | | +| doğrulama | | `express-validator` | +| handler öncesi | `onPreHandler` | | +| handler | | route callback'i | +| handler sonrası | `onPostHandler`, `onPreResponse` | hata yönetimi middleware'i | +| yanıt gönderildikten sonra | `onPostResponse` | | + +Extension point'ler, `server.ext()` ile kendi kodunu eklediğin yerlerdir. Diğer adımları hapi her route'un yapılandırmasına göre çalıştırır. + +Plugin'ler yeniden kullanılabilir middleware'lerin yerini alır. Plugin, sunucuya route'lar, extension'lar ve decoration'lar kaydeden adlandırılmış bir birimdir. Bir plugin ihtiyaç duyduğu diğer plugin'leri `dependencies` özelliğinde bildirebilir; biri eksikse hapi istek anında hata vermek yerine başlamayı reddeder. [Plugin'ler](/tutorials/tr_TR/plugins) eğitimine bak. + +### Extension Point'ler + +Her istek için çalışan bir middleware bir extension'a dönüşür: + +Express: + +```ts +app.use(function (req, res, next) { + console.log(req.method, req.url); + next(); +}); +``` + +hapi: + +```ts +server.ext('onRequest', function (request, h) { + console.log(request.method, request.path); + return h.continue; +}); +``` + +`return h.continue`, `next()`'in rolünü üstlenir: istek bir sonraki adıma geçer. Bunun yerine bir takeover yanıtı (`h.response(...).takeover()`) döndürmek ya da bir hata fırlatmak isteği orada sonlandırır. + +hapi'nin sekiz istek extension point'i vardır: `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler`, `onPreResponse` ve `onPostResponse`. Hangisinin ne zaman kullanılacağını [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks) eğitimi anlatıyor, seçenekleri ise [`server.ext()`](/api#server-ext-events) listeliyor. + +### Plugin Oluşturmak + +Her isteğe bir yardımcı ekleyen bir middleware, bir [decoration](/tutorials/tr_TR/decorators) ekleyen bir plugin'e dönüşür. Plugin, bir `name` (ya da `pkg`) ve bir `async register(server, options)` fonksiyonu olan bir nesnedir. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): Date; + } + } +} + +const getDate = function (req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = new Date(); + return date; + }; + + next(); +}; +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): Date; + } +} + +const getDate: Plugin = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +- Express middleware'i `req.getDate`'i her istekte yeniden ayarlar. hapi plugin'i ise `server.decorate()`'i kayıt sırasında bir kez çağırır ve ardından her handler'da `h.getDate()` bulunur. +- TypeScript yeni metodu ancak sen bildirdiğinde tanır: Express'te global `Express.Request` arayüzü üzerinden, hapi'de ise onu `@hapi/hapi`'nin `ResponseToolkit` arayüzüne ekleyerek. + +### Plugin Yüklemek + +Express: + +```ts +app.use(getDate); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, +}); +``` + +### Seçenekler + +Bir Express middleware'i seçenekleri, middleware'i döndüren bir fonksiyon olarak alır. Bir hapi plugin'i ise seçeneklerini `register`'ın ikinci argümanı olarak alır. + +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): string; + } + } +} + +export default function (options: { name: string }) { + return function getDate(req: Request, res: Response, next: NextFunction) { + req.getDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + next(); + }; +} +``` + +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): string; + } +} + +const getDate: Plugin<{ name: string }> = { + name: 'getDate', + version: '1.0.0', + register: async function (server, options) { + const currentDate = function () { + const date = 'Hello ' + options.name + ', the date is ' + new Date(); + return date; + }; + + server.decorate('toolkit', 'getDate', currentDate); + }, +}; +``` + +Seçenekleri plugin'i kaydederken verirsin. `Plugin<{ name: string }>` tip argümanı `register` içindeki `options`'ı tipler ve TypeScript, seçenekleri buna uymayan bir `server.register()` çağrısını işaretler; hapi bunları çalışma zamanında kontrol etmez: + +Express: + +```ts +import getDate from './mw/getDate.ts'; + +app.use(getDate({ name: 'Tom' })); +``` + +hapi: + +```ts +await server.register({ + plugin: getDate, + options: { + name: 'Tom', + }, +}); +``` + +## body-parser + +hapi istek payload'unu handler çalışmadan önce, hiçbir middleware olmadan ayrıştırır. Sonuç `request.payload` içindedir. + +| İçerik tipi | Express | hapi | +| ----------------------------------- | ------------------------------- | --------------------------------------------- | +| `application/json` | `app.use(express.json())` | varsayılan olarak ayrıştırılır | +| `application/x-www-form-urlencoded` | `app.use(express.urlencoded())` | varsayılan olarak ayrıştırılır | +| `text/*` | `app.use(express.text())` | varsayılan olarak ayrıştırılır | +| `multipart/form-data` | `multer` gibi ayrı bir paket | `payload: { multipart: true }` route seçeneği | + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.use(express.urlencoded()); + +app.post('/hello', function (req, res) { + const name = req.body.name; + res.send('Hello ' + name); +}); +``` + +hapi: + +```ts +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/hello', + handler: function (request, h) { + const name = request.payload.name; + return 'Hello ' + name; + }, +}); +``` + +hapi istemcilerin ne göndereceğini bilemeyeceği için `request.payload`'un sabit bir yapısı yoktur. `server.route<{ Payload: { name: string } }>()` tip argümanı TypeScript'e handler'ın ne alacağını söyler. Bu yalnızca bir tiptir: payload'u çalışma zamanında kontrol etmek için [doğrulama](#joi) ekle. Boyut sınırları, zaman aşımları ve dosya yüklemeleri route'un [`payload` seçenekleriyle](/api#route-options-payload) ayarlanır. + +## cookie-parser + +hapi cookie'leri middleware olmadan okur ve yazar. Express cookie seçeneklerini her `res.cookie()` çağrısında alırken, hapi her cookie'yi `server.state()` ile bir kez tanımlar ve ardından her route onu adıyla ayarlar. + +| Seçenek | Express `res.cookie()` | hapi `server.state()` | +| ---------------- | ------------------------------------ | ------------------------------------ | +| Secure bayrağı | `secure`, varsayılan olarak kapalı | `isSecure`, varsayılan olarak açık | +| HttpOnly bayrağı | `httpOnly`, varsayılan olarak kapalı | `isHttpOnly`, varsayılan olarak açık | +| SameSite | `sameSite` | `isSameSite`, varsayılan `'Strict'` | +| Ömür | `maxAge` (ms) | `ttl` (ms) | +| İmzalı değer | `signed` | `sign: { password }` | +| Şifreli değer | yerleşik değil | `encoding: 'iron'` | + +Tüm seçenekler için [`server.state()`](/api#server-state-name-options) referansına ve [Cookie'ler](/tutorials/tr_TR/cookies) eğitimine bak. + +### Cookie Ayarlamak + +Express: + +```ts +import express from 'express'; +import cookieParser from 'cookie-parser'; + +const app = express(); + +app.use(cookieParser()); + +app.get('/', function (req, res) { + res.cookie('username', 'tom', { secure: true, httpOnly: true }); + res.send('Hello'); +}); +``` + +hapi: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 8000 }); + +server.state('username', { + ttl: null, + isSecure: true, + isHttpOnly: true, +}); + +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + h.state('username', 'tom'); + return 'Hello'; + }, +}); +``` + +### Cookie Değerini Okumak + +Express: + +```ts +app.get('/whoami', (req, res) => { + res.send(req.cookies.username); +}); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + const username = request.state.username; + if (typeof username !== 'string') { + return 'No username cookie'; + } + + return username; + }, +}); +``` + +`request.state` istemcinin gönderdiği cookie'leri olduğu gibi tutar, bu yüzden hapi her değeri `unknown` olarak tipler. Kullanmadan önce tipini kontrol et. + +## Passport -> bell + +Express üçüncü taraf girişleri Passport ile yönetir. hapi ise Twitter, Facebook, Google ve GitHub dahil 30'dan fazla OAuth sağlayıcısı için yerleşik yapılandırmalara sahip olan ve özel sağlayıcıları da destekleyen [bell](/module/bell)'i kullanır. [bell sağlayıcı listesine](/module/bell/providers) bak. + +Passport iki route'a ihtiyaç duyar: biri girişi başlatmak, diğeri sağlayıcının callback'i için. bell ikisi için tek bir route kullanır: + +```mermaid +sequenceDiagram + participant B as Tarayıcı + participant S as hapi sunucusu + participant P as Twitter + B->>S: GET /auth/twitter + Note over S: query'de OAuth yanıtı yok,
bu yüzden bell girişi başlatır + S-->>B: Twitter'a 302 yönlendirme + B->>P: giriş yap ve onayla + P-->>B: /auth/twitter?oauth_token=... adresine 302 yönlendirme + B->>S: GET /auth/twitter?oauth_token=... + Note over S: bell token'ı Twitter ile değiş tokuş eder
ve request.auth.credentials değerini ayarlar + S-->>B: handler yanıtı +``` + +Express: + +``` +npm install passport passport-twitter +``` + +TypeScript kullanıyorsan ayrıca `npm install --save-dev @types/passport @types/passport-twitter` çalıştır. + +```ts +import passport from 'passport'; +import { Strategy as TwitterStrategy } from 'passport-twitter'; + +passport.use( + new TwitterStrategy( + { + consumerKey: TWITTER_CONSUMER_KEY, + consumerSecret: TWITTER_CONSUMER_SECRET, + callbackURL: '/auth/twitter/callback', + }, + function (token, tokenSecret, profile, cb) { + User.findOrCreate({ twitterId: profile.id }, function (err, user) { + return cb(err, user); + }); + }, + ), +); + +passport.serializeUser(function (user, cb) { + cb(null, user); +}); + +passport.deserializeUser(function (user: Express.User, cb) { + cb(null, user); +}); + +app.get('/auth/twitter', passport.authenticate('twitter')); +app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login' }), function (req, res) { + res.redirect('/'); +}); +``` + +hapi: + +``` +npm install @hapi/bell +``` + +```ts +import Hapi from '@hapi/hapi'; +import Bell from '@hapi/bell'; + +const server = Hapi.server({ port: 8000 }); + +await server.register(Bell); + +server.auth.strategy('twitter', 'bell', { + provider: 'twitter', + password: 'cookie_encryption_password_secure', + clientId: TWITTER_CONSUMER_KEY, + clientSecret: TWITTER_CONSUMER_SECRET, + isSecure: false, +}); + +server.route({ + method: '*', + path: '/auth/twitter', // The callback endpoint registered with the provider + handler: function (request, h) { + if (!request.auth.isAuthenticated) { + return `Authentication failed due to: ${request.auth.error.message}`; + } + + // Look up or register the account, start a local session, and redirect. + // request.auth.credentials holds the provider's profile and tokens. + + return h.redirect('/home'); + }, + options: { + auth: { + strategy: 'twitter', + mode: 'try', + }, + }, +}); +``` + +| Seçenek | Nedir | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `provider` | yerleşik sağlayıcı yapılandırmasının adı | +| `password` | bell'in iki istek arasında tuttuğu geçici cookie'yi şifreler; en az 32 karakter | +| `clientId` | sağlayıcıdan alınan OAuth client ID | +| `clientSecret` | sağlayıcıdan alınan OAuth client secret | +| `isSecure` | cookie'nin Secure bayrağı. `false` geliştirme sırasında düz HTTP'ye izin verir; production'da varsayılan `true` değerini bırak | + +`mode: 'try'`, giriş başarısız olduğunda handler'ın çalışmasını sağlar; böylece handler çıplak bir 401 yerine hatayı gösterebilir. Girişi başlatan istekteki query parametreleri `request.auth.credentials.query` üzerinde geri gelir; böylece bir dönüş URL'ini sağlayıcıya gidiş dönüş boyunca taşıyabilirsin. + +## express-validator -> joi + +hapi istekleri ve yanıtları route'un yapılandırmasından, [joi](https://joi.dev) şemalarını kullanarak doğrular. express-validator yalnızca istekleri kontrol eder; hapi ise handler'ının döndürdüğü değeri de kontrol edebilir. Daha fazlası için [Doğrulama](/tutorials/tr_TR/validation) eğitimine bak. + +### Girdi Doğrulama + +Express: + +``` +npm install express-validator +``` + +```ts +import express from 'express'; +import { body, validationResult } from 'express-validator'; + +app.use(express.json()); +app.use(express.urlencoded()); + +app.post('/post', body('post', 'Post too long').isLength({ max: 140 }), function (req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).send(errors.array()); + } else { + res.send('Blog post added!'); + } +}); +``` + +hapi: + +``` +npm install joi +``` + +```ts +import Joi from 'joi'; + +server.route({ + method: 'POST', + path: '/post', + handler: (request, h) => { + return 'Blog post added!'; + }, + options: { + validate: { + payload: Joi.object({ + post: Joi.string().max(140), + }), + }, + }, +}); +``` + +- Handler yalnızca payload doğrulamadan geçtiğinde çalışır. Aksi halde hapi kendiliğinden 400 ile yanıt verir, bu yüzden handler'da hata dalı yoktur. +- Kurallar zincirlenir, örneğin `Joi.string().min(1).max(140)`. +- `validate` ayrıca `params`, `query`, `headers` ve `state` şemalarını da kabul eder. + +### Çıktı Doğrulama + +```ts +const bookSchema = Joi.object({ + title: Joi.string().required(), + author: Joi.string().required(), + isbn: Joi.string().length(10), + pageCount: Joi.number(), + datePublished: Joi.date().iso(), +}); + +server.route({ + method: 'GET', + path: '/books', + handler: async function (request, h) { + return await getBooks(); + }, + options: { + response: { + schema: Joi.array().items(bookSchema), + failAction: 'log', + }, + }, +}); +``` + +`response.schema`, handler'ın döndürdüğü kitap listesini kontrol eder. `failAction: 'log'` ile şemaya uymayan bir yanıt yine de gönderilir ve hata loglanır; varsayılan `'error'` ise onu bir 500 ile değiştirir. + +## app.set('view engine') -> vision + +hapi şablonları [vision](/module/vision) plugin'i aracılığıyla render eder. Aynı anda birden fazla şablon motorunu, ayrıca partial'ları, helper'ları ve layout'ları destekler. Daha fazlası için [View'lar](/tutorials/tr_TR/views) eğitimine bak. + +### View Engine'i Ayarlamak + +Express: + +```ts +app.set('view engine', 'pug'); +``` + +hapi: + +```ts +import Vision from '@hapi/vision'; +import Pug from 'pug'; + +await server.register(Vision); + +server.views({ + engines: { + pug: Pug, + }, + relativeTo: import.meta.dirname, + path: 'views', +}); +``` + +Express şablonları varsayılan olarak `./views` içinde arar. hapi'de klasörü `relativeTo` ve `path` ile ayarlarsın. + +### View Render Etmek + +Express: + +```ts +app.get('/', function (req, res) { + res.render('index', { title: 'Homepage', message: 'Welcome' }); +}); +``` + +hapi, `h.view()` ile: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: function (request, h) { + return h.view('index', { title: 'Homepage', message: 'Welcome' }); + }, +}); +``` + +hapi, route yalnızca bir şablon render ediyorsa `view` handler'ı ile: + +```ts +server.route({ + method: 'GET', + path: '/', + handler: { + view: { + template: 'index', + context: { + title: 'Homepage', + message: 'Welcome', + }, + }, + }, +}); +``` + +## express.static() -> inert + +hapi statik dosyaları [inert](/module/inert) plugin'i aracılığıyla sunar. inert, `file` ve `directory` handler'larını ve `h.file()` metodunu ekler. Daha fazlası için [Dosya Sunma](/tutorials/tr_TR/serving-files) eğitimine bak. + +Aşağıdaki iki örnek de inert'i kaydeden ve dosya path'lerinin göreli olduğu klasörü ayarlayan şu kurulumu kullanıyor: + +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; + +const server = Hapi.server({ + port: 3000, + routes: { + files: { + relativeTo: Path.join(import.meta.dirname, 'public'), + }, + }, +}); + +await server.register(Inert); +``` + +### Tek Dosya Sunmak + +Express: + +```ts +app.get('/image', function (req, res) { + res.sendFile('image.jpg', { root: './public' }); +}); +``` + +hapi, `h.file()` ile: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: function (request, h) { + return h.file('image.jpg'); + }, +}); +``` + +hapi, `file` handler'ı ile: + +```ts +server.route({ + method: 'GET', + path: '/image', + handler: { + file: 'image.jpg', + }, +}); +``` + +### Statik Dosya Sunucusu + +Express: + +```ts +app.use(express.static('public')); +``` + +hapi: + +```ts +server.route({ + method: 'GET', + path: '/{param*}', + handler: { + directory: { + path: '.', + }, + }, +}); +``` + +`{param*}` jokeri her path ile eşleşir ve `directory` handler'ı eşleşen dosyayı `public` klasöründen sunar; yani `/logo.png`, `public/logo.png` dosyasını döndürür. hapi en özel route'u seçtiği için diğer route'ların bu her şeyi yakalayan route'a karşı yine kazanır. + +## Hata Yönetimi -> boom + +hapi hatalara [boom](/module/boom) hata nesnelerinden oluşturulan JSON ile yanıt verir. Express ise bir HTML sayfasıyla yanıt verir. Hiçbir route ile eşleşmeyen bir `GET /hello` için: + +Express: + +```text +Cannot GET /hello +``` + +hapi: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Not Found" +} +``` + +### Özel Mesajlar + +Express: + +```ts +res.status(404).send({ status: 404, error: 'Page not found' }); +``` + +hapi: + +```ts +throw Boom.notFound('Page not found'); +``` + +Fırlatılan hata şu yanıta dönüşür: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Page not found" +} +``` + +boom, `Boom.badRequest()`, `Boom.unauthorized()` ve `Boom.badImplementation()` gibi yaygın 4xx ve 5xx durum kodları için birer fonksiyon sunar. Birini bir handler, extension ya da plugin içinde herhangi bir yerde fırlat, hapi onu yanıta dönüştürür. boom hatası olmayan hatalar 500'e dönüşür ve mesajları istemciden gizlenir. Tam liste için [boom belgelerine](/module/boom) bak. diff --git a/docs/tutorials/zh_CN/express-to-hapi.md b/docs/tutorials/zh_CN/express-to-hapi.md index 11fbc2b4..9439479f 100644 --- a/docs/tutorials/zh_CN/express-to-hapi.md +++ b/docs/tutorials/zh_CN/express-to-hapi.md @@ -1,44 +1,60 @@ --- -title: Express to hapi +title: 从 Express 迁移到 hapi --- -# Express 迁移 +# 从 Express 迁移到 hapi -_当前文献适用于 hapi v17 或者更新的版本_ +_本指南适用于 hapi v21 及更高版本_ -## 总揽 +## 概述 -这个Express 到 hapi的指南将向您展示如何使用Express中的知识,以及如何在hapi中做到这一点。 Express的大部分功能都严重依赖中间件,而hapi则更多地内置于核心中。 正文解析 (Body Parsing),cookie的处理,输入/输出验证以及HTTP友好错误处理对象已内置在hapi的框架中。 为了获得更多功能,hapi在其核心生态系统中提供了丰富的插件选择。 hapi也是唯一一个不依赖外部依赖项的框架。每个依赖项均由核心hapi团队进行管理,这使安全性和可靠性成为了hapi的最大优势。 +本指南展示如何把你在 Express 中的做法搬到 hapi 中。示例使用 Express 5。 -## 配置 +最大的区别在于:Express 通过中间件添加功能,你需要安装它们并用 `app.use()` 排好顺序;而 hapi 把大部分功能内置在核心中,并按路由进行配置。在 hapi 确实需要插件的地方,插件也来自 hapi 团队。hapi 自身的运行时依赖全部是由同一团队维护的 `@hapi/*` 包。 + +| 在 Express 中使用 | 在 hapi 中使用 | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `express.json()`、`body-parser` | 内置:[payload 解析](#bodyparser) | +| `cookie-parser` | 内置:[`server.state()`](#config) | +| `express-validator` | 内置:路由的 [`validate`](#joi),配合 [joi](https://joi.dev) | +| `passport` | 内置身份认证框架;scheme 来自 `@hapi/basic`、`@hapi/cookie`、`@hapi/jwt` 和 [bell](#bell) 等插件([身份认证教程](/tutorials/zh_CN/auth)) | +| `cors` | 内置:路由的 [`cors`](/api#route-options-cors) 选项 | +| `helmet` | 部分内置:路由的 [`security`](/api#route-options-security) 选项(不含 Content-Security-Policy) | +| `compression` | 内置,默认开启 | +| `app.set('view engine')` | [vision](#vision) 插件 | +| `express.static()` | [inert](#inert) 插件 | +| 自定义中间件 | [扩展点](#extension)和[插件](#creating) | +| 错误处理中间件 | `onPreResponse` [扩展](#extension);用抛出 [boom](#boom) 错误代替 `next(err)` | + +## 准备工作 ### 安装 -Express: -`npm install express` +| | Express | hapi | +| --------------- | --------------------------------------- | ------------------------ | +| 安装 | `npm install express` | `npm install @hapi/hapi` | +| TypeScript 类型 | `npm install --save-dev @types/express` | 已内置 | -hapi: -`npm install @hapi/hapi` +### 创建服务器 -### 创建一个 `Server` +Express: -Express: +```ts +import express from 'express'; -```js -var express = require('express'); -var app = express(); +const app = express(); app.listen(3000, function () { - console.log('Server is running on port 3000'); -})); + console.log('Server is running on port 3000'); +}); ``` -hapi: +hapi: -```js -const Hapi = require('@hapi/hapi'); +```ts +import Hapi from '@hapi/hapi'; const init = async () => { const server = Hapi.server({ @@ -53,25 +69,21 @@ const init = async () => { init(); ``` -与Express不同,在hapi中,您创建了一个服务器对象,它将成为您应用程序的焦点。 服务器对象中设置的属性将确定您的应用程序的行为。 创建服务器对象后,您可以通过调用以下命令启动服务器。 +在 hapi 中,`server` 对象是整个应用的中心。它的选项(端口、主机、默认路由设置)决定了服务器的行为,路由、插件和扩展都添加到它上面。`server.start()` 返回一个 promise。 -## 路由 `Routes` +## 路由 -hapi中的路由以特定顺序被调用,因此,两条路由之间相互冲突的问题将永远不会出现。 路由从最具体到最不具体。 例如,路径为 `'/ home'` 的路径将在 `'/{any*}'` 之前被调用。 +Express: -让我们来看一下如何在hapi中设置一个基本的路由: - -Express: - -```js +```ts app.get('/hello', function (req, res) { res.send('Hello World!'); }); ``` -hapi: +hapi: -```js +```ts server.route({ method: 'GET', path: '/hello', @@ -81,13 +93,25 @@ server.route({ }); ``` -要创建路由,Express的结构为`app.METHOD(PATH, HANDLER)`,而hapi的结构为`server.route({METHOD, PATH, HANDLER})`。该方法,路径和处理程序作为对象传递给hapi服务器。 如您所见,要在Express中返回字符串,需要调用`res.send()`,而在hapi中,您只需简单的返回字符串。 +| | Express | hapi | +| ------------ | --------------------------- | ------------------------------------------ | +| 定义路由 | `app.METHOD(path, handler)` | `server.route({ method, path, handler })` | +| 发送响应 | 调用 `res.send()` | 返回这个值 | +| 运行哪个路由 | 第一个注册且匹配的路由 | 最具体的匹配,与注册顺序无关 | +| 重叠的路由 | 都会注册;先注册的胜出 | 两个路由冲突时 `server.route()` 会抛出错误 | + +由于 hapi 选择最具体的路由,`'/home'` 总是胜过 `'/{any*}'`,注册顺序永远不会决定由哪个路由响应。具体规则请参阅[路由教程](/tutorials/zh_CN/routing)。 ### 方法 -除了 `HEAD` 之外,hapi可以使用所有Express可以使用的所有路由方法。hapi还可以在单个路由对象上使用多种方法。 例如: +| Express | hapi | +| --------------------- | -------------------------------------- | +| `app.get(path, ...)` | `method: 'GET'` | +| 每个方法一个路由 | `method: ['PUT', 'POST']` | +| `app.all(path, ...)` | `method: '*'` | +| `app.head(path, ...)` | 不允许:每个 `GET` 路由都会响应 `HEAD` | -```js +```ts server.route({ method: ['PUT', 'POST'], path: '/', @@ -97,30 +121,32 @@ server.route({ }); ``` -如需使用所有可用的方法,像Express使用 `app.all()` 一样,使用 `method: '*'`。 - ### 路径 -与在Express中一样,hapi中的path选项必须是字符串,也可以包含参数。 Express中的参数以`:`开头,例如:`'/users/:userId'`。 在hapi中,您可以将参数放在花括号中,例如:`path: '/users/{userId}'`。 +在 hapi 中,参数写在花括号里: -### 参数 +| | Express 5 | hapi | +| -------- | ---------------- | ----------------- | +| 参数 | `/users/:userId` | `/users/{userId}` | +| 可选参数 | `/hello{/:user}` | `/hello/{user?}` | +| 通配符 | `/files/*path` | `/files/{path*}` | -与Express相比,您已经在上面看到了hapi是如何处理简单参数的。 hapi和Express都以相同方式处理可选参数。与Express一样,要使参数在hapi中成为可选参数,只需在参数后添加一个 `?`:`path: '/hello/{user?}`。 +在 hapi 中,`{user*}` 和占据整段的 `{user?}` 必须是最后一段;`{user*2}` 以及只占部分段的可选参数(例如 `/a{user?}/b`)可以出现在任意位置。 -在hapi中访问参数与Express非常相似。 如您所知,在Express中,参数填充在 `req.params` 对象中。 在hapi中,可通过 `request.params` 对象获得参数。 这是两个示例: +### 参数 -Express: +Express: -```js +```ts app.get('/hello/:name', function (req, res) { const name = req.params.name; res.send('Hello ' + name); }); ``` -hapi: +hapi: -```js +```ts server.route({ method: 'GET', path: '/hello/{name}', @@ -131,84 +157,116 @@ server.route({ }); ``` -在两个框架中,查询参数也相似。 在Express中,它们可以通过 `req.query` 获得,而hapi它们可以通过 `request.query` 获得。 +| Express | hapi | +| ------------ | ----------------- | +| `req.params` | `request.params` | +| `req.query` | `request.query` | +| `req.body` | `request.payload` | ### Handler -Express和hapi构造其路由处理程序的方式有所不同。 与Express的处理程序的参数为 `req` 和 `res` 不同,hapi的处理程序的参数为 `request` 和 `h`。 第二个参数 `h` 是响应工具包,它是一个具有几种用于响应请求的方法的对象。 - -这里是一个关于使用Express和hapi的路由和handler用来重定向到一个新的路由的用例: - -Express: - -```js -app.get('/home', function (req, res) { - res.redirect('/'); -}); -``` +Express 的 handler 接收 `req` 和 `res`,通过调用 `res` 上的方法来响应。hapi 的 handler 接收 `request` 和 `h`([响应工具包](/api#response-toolkit)),并返回响应: -hapi: - -```js -server.route({ - method: 'GET', - path: '/home', - handler: function (request, h) { - return h.redirect('/'); - }, -}); -``` +| Express | hapi | +| ------------------------------ | --------------------------------------------------------- | +| `res.send('text')` | `return 'text'` | +| `res.json(object)` | `return object` | +| `res.status(201).send(object)` | `return h.response(object).code(201)` | +| `res.set('x-name', 'value')` | `return h.response(object).header('x-name', 'value')` | +| `res.redirect('/')` | `return h.redirect('/')` | +| `res.cookie('name', 'value')` | `h.state('name', 'value')`,参见 [cookie](#config) | +| `res.sendFile(file)` | `return h.file(file)`,参见 [inert](#inert) | +| `res.render('index', context)` | `return h.view('index', context)`,参见 [vision](#vision) | +| `next(err)` | `throw Boom.notFound()`,参见 [boom](#boom) | -这两个路由都将重定向到 `'/'` 路由。 Express使用响应方法 `res.redirect`,而hapi使用 `h.redirect`,它是响应工具包的一部分。 hapi可以通过使用`return`来完成Express响应方法。 其中一些方法包括`res.send`和`res.json`。 以下是hapi如何使用JSON数据进行响应的示例: +当 handler 需要设置状态码或响应头时,用 `h.response()` 包装返回值: -```js +```ts server.route({ - method: 'GET', + method: 'POST', path: '/user', handler: function (request, h) { const user = { firstName: 'John', lastName: 'Doe', - userName: 'JohnDoe', id: 123, }; - return user; + return h.response(user).code(201).header('location', '/user/123'); }, }); ``` -hapi具有默认情况下响应JSON数据的功能。唯一要做的就是返回一个有效的JavaScript对象,而hapi将为您处理其余的工作。 +hapi 会把返回的对象序列化为 JSON 并设置 `content-type`。hapi 如何把返回值变成响应,请参阅[响应编组教程](/tutorials/zh_CN/response-marshalling)。 -## 中间件 vs 插件和扩展 `Middleware vs Plugins and Extensions` +## 中间件与插件和扩展 -为了扩展其功能,Express使用中间件。中间件实质上是一系列使用回调执行下一个功能的功能。问题在于,随着您的应用程序规模和复杂性的增长,中间件的执行顺序变得越来越重要,也更加难以维护。 在依赖中间件之前执行中间件会导致您的应用程序失败。hapi通过其强大的插件和扩展系统解决了此问题。 +Express 按照你调用 `app.use()` 的顺序运行中间件。如果一个中间件依赖另一个,你需要手动保证它们的顺序,顺序错了会在处理请求时才出错。 -插件使您可以将应用程序逻辑分解为孤立的业务逻辑和可重用的实用程序。每个插件都有其自己的依赖性,这些依赖性在插件本身中明确指定。这意味着您不必自己安装依赖项即可使插件正常工作。 您可以添加现有的hapi插件,也可以编写自己的插件。有关插件的更详细的教程,请参见[plugins](/tutorials/plugins/?lang=en_US)教程。 +hapi 让每个请求都经过一系列固定的步骤,也就是[请求生命周期](/api#request-lifecycle)。你不需要给中间件排序,而是把代码挂到某个具名的步骤上。下表的每一步也列出了承担相同职责的 Express 中间件: -hapi中的每个请求都遵循预定义的路径,即请求生命周期。hapi具有扩展点,可让您在生命周期中创建自定义功能。hapi中的扩展点让您知道应用程序运行的确切顺序。有关更多信息,请参见hapi [request lifecycle](/api/#request-lifecycle)。 +| hapi 步骤 | 扩展点 | Express 对应物 | +| --------------------- | -------------------------------- | ---------------------------- | +| 收到请求 | `onRequest` | 路由之前的 `app.use()` | +| 查找路由、解析 cookie | | `app.get()`、`cookie-parser` | +| 身份认证之前 | `onPreAuth` | | +| 身份认证 | | `passport` | +| 解析 payload | | `express.json()` | +| 身份认证之后 | `onCredentials`、`onPostAuth` | | +| 验证 | | `express-validator` | +| handler 之前 | `onPreHandler` | | +| handler | | 路由回调 | +| handler 之后 | `onPostHandler`、`onPreResponse` | 错误处理中间件 | +| 响应发送之后 | `onPostResponse` | | -### 延伸点 +扩展点是你用 `server.ext()` 添加自己代码的地方。其余步骤由 hapi 根据每个路由的配置来执行。 -hapi在请求周期内一共具有7个扩展点。 按顺序,它们分别都是 `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler` 以及 `onPreResponse`。要将功能添加到扩展点,请调用 `server.ext()`。让我们看一个例子: +插件取代了可复用的中间件。插件是一个具名单元,它向服务器注册路由、扩展和装饰。插件可以在 `dependencies` 属性中声明它需要的其他插件,缺少依赖时 hapi 会拒绝启动,而不是等到处理请求时才失败。请参阅[插件教程](/tutorials/zh_CN/plugins)。 -```js +### 扩展点 + +对每个请求都运行的中间件,可以改写成扩展: + +Express: + +```ts +app.use(function (req, res, next) { + console.log(req.method, req.url); + next(); +}); +``` + +hapi: + +```ts server.ext('onRequest', function (request, h) { - request.setUrl('/test'); + console.log(request.method, request.path); return h.continue; }); ``` -该函数将在第一个扩展点 `onRequest`处运行。 在服务器接收到请求对象之后,即在路由查找之前,将运行 `onRequest`。此功能将执行的操作是将所有请求重新路由到 `'/test'` 路由。 +`return h.continue` 起到 `next()` 的作用:请求继续进入下一步。如果返回接管响应(`h.response(...).takeover()`)或者抛出错误,请求就在这里结束。 + +hapi 有八个请求扩展点:`onRequest`、`onPreAuth`、`onCredentials`、`onPostAuth`、`onPreHandler`、`onPostHandler`、`onPreResponse` 和 `onPostResponse`。[生命周期钩子教程](/tutorials/zh_CN/lifecycle-hooks)介绍了每个扩展点的使用时机,[`server.ext()`](/api#server-ext-events) 列出了所有选项。 -### 制作一个插件 +### 创建插件 -如您所知,您可以在Express中编写自己的中间件。 hapi插件也是如此。 插件是具有必需的 `name` 和 `register` 属性的对象。`register` 属性是一个带有 `async function (server, option)` 签名的功能。 让我们看一下如何创建一个基本的插件: +给每个请求添加辅助方法的中间件,可以改写成一个添加[装饰](/tutorials/zh_CN/decorators)的插件。插件是一个带有 `name`(或 `pkg`)和 `async register(server, options)` 函数的对象。 -Express: +Express: + +```ts +import type { NextFunction, Request, Response } from 'express'; + +declare global { + namespace Express { + interface Request { + getDate(): Date; + } + } +} -```js -const getDate = function (req, res, next) { +const getDate = function (req: Request, res: Response, next: NextFunction) { req.getDate = function () { const date = new Date(); return date; @@ -218,10 +276,18 @@ const getDate = function (req, res, next) { }; ``` -hapi: +hapi: + +```ts +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): Date; + } +} -```js -const getDate = { +const getDate: Plugin = { name: 'getDate', version: '1.0.0', register: async function (server, options) { @@ -235,37 +301,44 @@ const getDate = { }; ``` -hapi插件会将当前日期保存在 `h.getDate()` 中。然后,我们可以在任何路由处理程序中使用它。 +- Express 中间件在每个请求上都重新设置一次 `req.getDate`。hapi 插件只在注册时调用一次 `server.decorate()`,之后每个 handler 都能使用 `h.getDate()`。 +- 只有在你声明了这个新方法之后,TypeScript 才知道它的存在:Express 通过全局的 `Express.Request` 接口声明,hapi 则把它添加到 `@hapi/hapi` 的 `ResponseToolkit` 接口中。 -### 加载一个插件 +### 加载插件 -在Express中,您可以通过调用 `app.use()` 方法来加载中间件。 在hapi中,您可以调用 `server.register()` 方法。 让我们加载上一节中创建的插件: +Express: -Express: - -```js +```ts app.use(getDate); ``` -hapi: +hapi: -```js +```ts await server.register({ plugin: getDate, }); ``` -您还可以通过在 `server.register()` 上设置 `options` 属性来为插件提供选项。 - ### 选项 -您可以通过导出接受options参数的函数来向Express中间件添加选项,该函数随后返回中间件。在hapi中,您可以在注册插件时设置选项。我们来看一下: +Express 中间件接收选项的方式,是做成一个返回中间件的函数。hapi 插件则通过 `register` 的第二个参数接收选项。 + +Express: -Express: +```ts +import type { NextFunction, Request, Response } from 'express'; -```js -module.exports = function (options) { - return function getDate(req, res, next) { +declare global { + namespace Express { + interface Request { + getDate(): string; + } + } +} + +export default function (options: { name: string }) { + return function getDate(req: Request, res: Response, next: NextFunction) { req.getDate = function () { const date = 'Hello ' + options.name + ', the date is ' + new Date(); return date; @@ -273,34 +346,21 @@ module.exports = function (options) { next(); }; -}; -``` - -hapi: - -```js -server.register({ - plugin: getDate, - options: { - name: 'Tom', - }, -}); +} ``` -要访问hapi中的选项,只需在创建插件时引用 `options` 对象即可: - -Express: +hapi: -```js -const getDate = require('./mw/getDate.js'); +```ts +import type { Plugin } from '@hapi/hapi'; -app.use(getDate({ name: 'Tom' })); -``` - -hapi: +declare module '@hapi/hapi' { + interface ResponseToolkit { + getDate(): string; + } +} -```js -const getDate = { +const getDate: Plugin<{ name: string }> = { name: 'getDate', version: '1.0.0', register: async function (server, options) { @@ -314,83 +374,105 @@ const getDate = { }; ``` -## body-parser +选项在注册插件时传入。`Plugin<{ name: string }>` 的类型参数为 `register` 内部的 `options` 提供类型,如果 `server.register()` 调用传入的选项与之不符,TypeScript 会报错;hapi 在运行时不会检查它们: -hapi的核心具有解析能力。与Express不同,您不需要中间件来解析有效载荷数据。实际上,根据要解析的数据类型,您可能需要在Express中最多安装四个附加的中间件。在hapi中,有效载荷数据(无论是JSON还是纯文本)都可以在 `request.payload` 对象中获得。这是解析简单有效负载数据的并排比较: +Express: -Express: +```ts +import getDate from './mw/getDate.ts'; -```js -var bodyParser = require('body-parser'); +app.use(getDate({ name: 'Tom' })); +``` -app.use(bodyParser.urlencoded({ extend: true })); +hapi: + +```ts +await server.register({ + plugin: getDate, + options: { + name: 'Tom', + }, +}); +``` + +## body-parser + +hapi 在 handler 运行之前就解析请求 payload,无需任何中间件。结果位于 `request.payload`。 + +| 内容类型 | Express | hapi | +| ----------------------------------- | ------------------------------- | --------------------------------------- | +| `application/json` | `app.use(express.json())` | 默认解析 | +| `application/x-www-form-urlencoded` | `app.use(express.urlencoded())` | 默认解析 | +| `text/*` | `app.use(express.text())` | 默认解析 | +| `multipart/form-data` | 单独的包,例如 `multer` | 路由选项 `payload: { multipart: true }` | + +Express: + +```ts +import express from 'express'; + +const app = express(); + +app.use(express.urlencoded()); app.post('/hello', function (req, res) { - var name = req.body.name; + const name = req.body.name; res.send('Hello ' + name); }); ``` -hapi: +hapi: -```js -server.route({ +```ts +server.route<{ Payload: { name: string } }>({ method: 'POST', path: '/hello', handler: function (request, h) { const name = request.payload.name; - return `Hello ` + name; + return 'Hello ' + name; }, }); ``` -要解析express中的JSON对象,您必须指定它: - -```js -app.use(bodyParser.json()); -``` - -JSON解析内置于hapi中,因此不需要其他的步骤。 +`request.payload` 没有固定的结构,因为 hapi 无法知道客户端会发送什么。类型参数 `server.route<{ Payload: { name: string } }>()` 告诉 TypeScript handler 收到的是什么。它只是一个类型:要在运行时检查 payload,请添加[验证](#joi)。大小限制、超时和文件上传通过路由的 [`payload` 选项](/api#route-options-payload)设置。 ## cookie-parser -在Express中设置和解析cookie要求您安装 `cookie-parser` 中间件。hapi的核心已经内置了cookie功能,因此无需安装其他中间件。 要在hapi中使用cookie,首先要使用 `server.state()` 配置cookie。 我们来看一下: +hapi 无需中间件就能读写 cookie。Express 在每次调用 `res.cookie()` 时接收 cookie 选项,而 hapi 用 `server.state()` 为每个 cookie 定义一次,之后每个路由按名称设置它即可。 -```js -const Hapi = require('@hapi/hapi'); +| 选项 | Express `res.cookie()` | hapi `server.state()` | +| ------------- | ---------------------- | ------------------------------- | +| Secure 标志 | `secure`,默认关闭 | `isSecure`,默认开启 | +| HttpOnly 标志 | `httpOnly`,默认关闭 | `isHttpOnly`,默认开启 | +| SameSite | `sameSite` | `isSameSite`,默认为 `'Strict'` | +| 有效期 | `maxAge`(毫秒) | `ttl`(毫秒) | +| 签名值 | `signed` | `sign: { password }` | +| 加密值 | 未内置 | `encoding: 'iron'` | -const server = Hapi.server({ port: 8000 }); +所有选项请参阅 [`server.state()`](/api#server-state-name-options),另见 [Cookie 教程](/tutorials/zh_CN/cookies)。 -server.state('data', { - ttl: null, - isSecure: true, - isHttpOnly: true, -}); -``` +### 设置 cookie -### 设定 Cookie +Express: -设置cookie一旦配置了cookie,就可以使用 `h.state()` 设置cookie。这里有一个例子: +```ts +import express from 'express'; +import cookieParser from 'cookie-parser'; -Express: - -```js -var express = require('express'); -var app = express(); -var cookieParser = require('cookie-parser'); +const app = express(); app.use(cookieParser()); app.get('/', function (req, res) { - res.cookie('username', 'tom', { maxAge: null, secure: true, httpOnly: true }); + res.cookie('username', 'tom', { secure: true, httpOnly: true }); res.send('Hello'); }); ``` -hapi: +hapi: -```js -const Hapi = require('@hapi/hapi'); +```ts +import Hapi from '@hapi/hapi'; const server = Hapi.server({ port: 8000 }); @@ -405,111 +487,111 @@ server.route({ path: '/', handler: function (request, h) { h.state('username', 'tom'); - return h.response('Hello'); + return 'Hello'; }, }); ``` -明确地说,您可以使用 `res.cookie` 中的 `options` 对象配置cookie。在hapi中,cookie配置使用 `server.state` 保存到服务器对象。然后,您可以使用 `h.state()` 将数据附加到cookie。 - -### 得到一个 Cookie 的值 +### 读取 cookie 值 -要获取hapi中的cookie值,请调用 `request.state`。让我们看看: +Express: -Express: - -```js -var express = require('express'); -var app = express(); -var cookieParser = require('cookie-parser); - -app.use(cookieParser()); - -app.get('/', (req, res) => { - - res.cookie('username', 'tom', { maxAge: null, secure: true, httpOnly: true }) +```ts +app.get('/whoami', (req, res) => { res.send(req.cookies.username); }); ``` -hapi: - -```js -const Hapi = require('@hapi/hapi'); - -const server = Hapi.server({ port: 8000 }); - -server.state('username', { - ttl: null, - isSecure: true, - isHttpOnly: true, -}); +hapi: +```ts server.route({ method: 'GET', - path: '/', - handler: async (request, h) => { - h.state('username', 'tom'); - return h.response(request.state.username); + path: '/whoami', + handler: (request, h) => { + const username = request.state.username; + if (typeof username !== 'string') { + return 'No username cookie'; + } + + return username; }, }); ``` +`request.state` 保存的是客户端发来的任意 cookie,所以 hapi 把每个值的类型定为 `unknown`。使用之前要先检查类型。 + ## Passport -> bell -在Express中,第三方身份验证是通过Passport处理的。在hapi中,您可以使用 [bell](/module/bell) 模块进行第三方身份验证。`bell` 为OAuth提供者提供了30多种预定义的配置,包括Twitter,Facebook,Google,GitHub等。 它还将允许您设置自己的自定义提供程序。 有关完整列表,请参阅 [bell providers documentation](https://github.com/hapijs/bell/blob/master/Providers.md)。 `bell` 是由核心hapi团队开发并维护的,因此您知道稳定性和可靠性不会成为问题。 让我们看看如何使用您的Twitter凭据进行身份验证: +Express 用 Passport 处理第三方登录。hapi 使用 [bell](/module/bell),它为 30 多个 OAuth 提供商内置了配置,包括 Twitter、Facebook、Google 和 GitHub,也支持自定义提供商。参见 [bell 提供商列表](/module/bell/providers)。 + +Passport 需要两个路由,一个用于发起登录,一个用于提供商的回调。bell 用一个路由同时处理两者: -Express: +```mermaid +sequenceDiagram + participant B as 浏览器 + participant S as hapi 服务器 + participant P as Twitter + B->>S: GET /auth/twitter + Note over S: 查询参数中没有 OAuth 响应,
所以 bell 发起登录 + S-->>B: 302 重定向到 Twitter + B->>P: 登录并授权 + P-->>B: 302 重定向到 /auth/twitter?oauth_token=... + B->>S: GET /auth/twitter?oauth_token=... + Note over S: bell 与 Twitter 交换 token
并设置 request.auth.credentials + S-->>B: handler 的响应 +``` + +Express: ``` npm install passport passport-twitter ``` -
+如果你使用 TypeScript,还要运行 `npm install --save-dev @types/passport @types/passport-twitter`。 -```js -var passport = require('passport'); -var TwitterStrategy = require('passport-twitter').Strategy +```ts +import passport from 'passport'; +import { Strategy as TwitterStrategy } from 'passport-twitter'; -passport.use(new TwitterStrategy({ - consumerKey: TWITTER_CONSUMER_KEY, - consumerSecret: TWITTER_CONSUMER_SECRET, - callbackURL: '/auth/twitter/callback' -}, - function(token, tokenSecret, profile, cb) { - User.findOrCreate({ twitterId: profile.id }, function (err, user) { - return cb(err, user); - } - } -)); +passport.use( + new TwitterStrategy( + { + consumerKey: TWITTER_CONSUMER_KEY, + consumerSecret: TWITTER_CONSUMER_SECRET, + callbackURL: '/auth/twitter/callback', + }, + function (token, tokenSecret, profile, cb) { + User.findOrCreate({ twitterId: profile.id }, function (err, user) { + return cb(err, user); + }); + }, + ), +); -passport.seralizeUser(function(user, cb) { +passport.serializeUser(function (user, cb) { cb(null, user); -}) +}); -passport.deserializeUser(function(user, cb) { - cb(null, obj); -}) +passport.deserializeUser(function (user: Express.User, cb) { + cb(null, user); +}); app.get('/auth/twitter', passport.authenticate('twitter')); -app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login'}), - function(req, res) { - - res.redirect('/'); - }); +app.get('/auth/twitter/callback', passport.authenticate('twitter', { failureRedirect: '/login' }), function (req, res) { + res.redirect('/'); +}); ``` -hapi: +hapi: ``` -npm install '@hapi/bell' +npm install @hapi/bell ``` -
- -```js -const Hapi = require('@hapi/hapi'); -const Bell = require('@hapi/bell'); +```ts +import Hapi from '@hapi/hapi'; +import Bell from '@hapi/bell'; const server = Hapi.server({ port: 8000 }); @@ -531,10 +613,8 @@ server.route({ return `Authentication failed due to: ${request.auth.error.message}`; } - // Perform any account lookup or registration, setup local session, - // and redirect to the application. The third-party credentials are - // stored in request.auth.credentials. Any query parameters from - // the initial request are passed back via request.auth.credentials.query. + // Look up or register the account, start a local session, and redirect. + // request.auth.credentials holds the provider's profile and tokens. return h.redirect('/home'); }, @@ -547,64 +627,53 @@ server.route({ }); ``` -要使用bell,只需注册插件并使用 `server.auth.strategy` 配置策略。 - -`provider` 是第三方的名称。 +| 选项 | 含义 | +| -------------- | ----------------------------------------------------------------------------------------- | +| `provider` | 内置提供商配置的名称 | +| `password` | 用于加密 bell 在两次请求之间保存的临时 cookie;至少 32 个字符 | +| `clientId` | 提供商分配的 OAuth client ID | +| `clientSecret` | 提供商分配的 OAuth client secret | +| `isSecure` | cookie 的 Secure 标志。`false` 允许在开发环境中使用普通 HTTP;生产环境请保留默认值 `true` | -`password` 是cookie加密密码。 - -`clientId` 是OAuth客户端标识符,可从提供商处获得。 - -`clientSecret` 是OAuth客户端机密,可以从提供商处获得。 - -`isSecure` 设置 cookie 安全 flag。 对于产品,应将其设置为 `true`,这也是默认设置。 +`mode: 'try'` 让 handler 在登录失败时也能运行,这样它可以显示错误信息,而不是只返回一个 401。发起登录的那次请求中的查询参数会出现在 `request.auth.credentials.query` 上,因此你可以让返回地址在提供商往返过程中一路带着。 ## express-validator -> joi -要在Express中验证数据,您可以使用 `express-validator` 插件。`express-validator` 的最大缺点之一是尽管您可以验证请求,但没有明确的方法来验证响应。在hapi中,可以使用 [joi](https://joi.dev) 模块,该模块可以轻松地验证请求和响应。Joi允许您使用简单干净的对象语法创建自己的验证。 要更深入地了解hapi中的验证,请参见 [validation](/tutorials/validation/?lang=en_US) 教程。 +hapi 根据路由的配置,用 [joi](https://joi.dev) schema 验证请求和响应。express-validator 只检查请求;hapi 还可以检查 handler 的返回值。更多内容请参阅[验证教程](/tutorials/zh_CN/validation)。 ### 输入验证 -输入验证使您可以验证任何输入到服务器的输入数据,包括其参数,有效负载等。下面介绍如何验证Express和hapi中的博客文章条目: - -Express: +Express: ``` npm install express-validator ``` -
- -```js -const bodyParser = require('body-parser'); -const expressValidator = require('express-validator'); - -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); -app.use(expressValidator()); +```ts +import express from 'express'; +import { body, validationResult } from 'express-validator'; -app.post('/post', function (req, res) { - req.check('post', 'Post too long').isLength({ max: 140 }); +app.use(express.json()); +app.use(express.urlencoded()); - let errors = req.validationErrors(); - if (errors) { - res.status(400).send(errors); +app.post('/post', body('post', 'Post too long').isLength({ max: 140 }), function (req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).send(errors.array()); } else { res.send('Blog post added!'); } }); ``` -hapi: +hapi: ``` npm install joi ``` -
- -```js -const Joi = require('joi'); +```ts +import Joi from 'joi'; server.route({ method: 'POST', @@ -622,19 +691,13 @@ server.route({ }); ``` -首先安装 `joi`,然后在项目中需要它。 要验证输入日期,请指定所需的数据类型,然后在该数据上设置规则。在这种情况下,`post` 将是一个 `string`,最多包含140个字符。在 `joi` 中,您可以将规则串在一起,例如: - -```js -Joi.string().min(1).max(140). -``` +- handler 只有在 payload 通过验证时才会运行。否则 hapi 会自动返回 400,所以 handler 里不需要错误分支。 +- 规则可以链式组合,例如 `Joi.string().min(1).max(140)`。 +- `validate` 还接受 `params`、`query`、`headers` 和 `state` 的 schema。 ### 输出验证 -如上所述,没有明确的方法可以使用 `express-validator` 进行响应验证。使用 `joi`,响应验证既快速又简单。我们来看一下: - -hapi: - -```js +```ts const bookSchema = Joi.object({ title: Joi.string().required(), author: Joi.string().required(), @@ -658,54 +721,52 @@ server.route({ }); ``` -此路线将返回书籍列表。 在 `options` 路线属性中,我们可以指定书籍清单应遵循的规则。 通过将 `failAction` 设置为 `log`,如果出现错误,服务器将记录该错误。 +`response.schema` 检查 handler 返回的图书列表。使用 `failAction: 'log'` 时,未通过 schema 的响应仍会发送,失败会被记录到日志;默认值 `'error'` 则会把它替换成 500。 ## app.set('view engine') -> vision -hapi对模板渲染提供了广泛的支持,包括加载和利用多个模板引擎,局部函数,助手(模板中用于操纵数据的功能)和布局的能力。Express通过使用 `app.set('view engine')` 启用视图功能,其中hapi的功能由 [vision](/module/vision) 插件提供。有关hapi中视图的更详细的教程,请参见 [views](/tutorials/views/?lang=en_US) 教程。 +hapi 通过 [vision](/module/vision) 插件渲染模板。它可以同时支持多个模板引擎,并支持 partial、helper 和布局。更多内容请参阅[视图教程](/tutorials/zh_CN/views)。 -### 设置 View Engine +### 设置视图引擎 -在 express 中设置 views engine 如下: +Express: -```js +```ts app.set('view engine', 'pug'); ``` -要在hapi中设置view engine,首先必须注册vision plugin,然后配置 `server.views`: +hapi: -```js -await server.register(require('@hapi/vision')); +```ts +import Vision from '@hapi/vision'; +import Pug from 'pug'; + +await server.register(Vision); server.views({ engines: { - pug: require('pug'), + pug: Pug, }, - relativeTo: __dirname, + relativeTo: import.meta.dirname, path: 'views', }); ``` -默认情况下,Express将在 `views` 文件夹中寻找视图或模板。在hapi中,您可以使用 `relativeTo` 和 `path` 属性来指定视图的位置。像Express一样,hapi支持各种模板引擎(`template engines`),例如pug,ejs,handbars等。 -By default, Express will look for views or templates in the `views` folder. In hapi, you specify where the views are located using the `relativeTo` and `path` properties. Like Express, hapi supports a wide variety of templating engines, such as pug, ejs, handlebars, etc. - -hapi在 `server.views` 中有更多可配置的选项。要查看功能的完整列表,请转到 [views](/tutorials/views/?lang=en_US) 教程。 - -### 渲染一个 View +Express 默认在 `./views` 中查找模板。在 hapi 中,你用 `relativeTo` 和 `path` 设置模板文件夹。 -要在Express中渲染视图,您可以调用 `res.render()`。 通过view,hapi有两种渲染view的方法:`h.view` 和view handler。 让我们看一下两者的用法。 +### 渲染视图 -第一个是在Express中渲染 view: +Express: -```js +```ts app.get('/', function (req, res) { res.render('index', { title: 'Homepage', message: 'Welcome' }); }); ``` -Using `h.view` in hapi: +hapi,使用 `h.view()`: -```js +```ts server.route({ method: 'GET', path: '/', @@ -715,9 +776,9 @@ server.route({ }); ``` -然后是在hapi中使用handler来渲染 view: +hapi,当路由只渲染模板时,使用 `view` handler: -```js +```ts server.route({ method: 'GET', path: '/', @@ -733,38 +794,42 @@ server.route({ }); ``` -为了在 `h.view` 中传递上下文,您需要传递一个对象作为第二个参数。 要在 view 处理程序中传递 `context`,请使用 `context` 键。 - ## express.static() -> inert -hapi可以从名为 [inert](/module/inert) 的插件中提供静态内容。inert提供了用于处理静态文件和目录的新处理程序方法,以及向响应工具箱中添加了 `h.file()` 方法。有关hapi中服务器静态文件的更深入的教程,请参阅 [serving static files](/tutorials/servingfiles/?lang=en_US) 教程。 - -### 服务单个文件 - -在Express中,您可以使用 `res.sendFile` 方法返回单个文件。在hapi中,您可以使用 `h.file()` 方法或文件处理程序,可以通过 [inert](/module/inert) 使用。一旦注册了插件,便可以提供静态文件: +hapi 通过 [inert](/module/inert) 插件提供静态文件。它添加了 `file` 和 `directory` handler 以及 `h.file()` 方法。更多内容请参阅[静态文件服务教程](/tutorials/zh_CN/serving-files)。 -Express: +下面两个示例都使用这段设置代码,它注册 inert 并设置文件路径的基准文件夹: -```js -app.get('/image', function (req, res) { - res.sendFile('image.jpg', { root: './public' }); -}); -``` - -hapi with `h.file()`: +```ts +import Path from 'node:path'; +import Hapi from '@hapi/hapi'; +import Inert from '@hapi/inert'; -```js -const server = new Hapi.Server({ +const server = Hapi.server({ port: 3000, routes: { files: { - relativeTo: Path.join(__dirname, 'public'), + relativeTo: Path.join(import.meta.dirname, 'public'), }, }, }); -await server.register(require('@hapi/inert')); +await server.register(Inert); +``` + +### 提供单个文件 + +Express: + +```ts +app.get('/image', function (req, res) { + res.sendFile('image.jpg', { root: './public' }); +}); +``` + +hapi,使用 `h.file()`: +```ts server.route({ method: 'GET', path: '/image', @@ -774,20 +839,9 @@ server.route({ }); ``` -hapi with file handler: - -```js -const server = new Hapi.Server({ - port: 3000, - routes: { - files: { - relativeTo: Path.join(__dirname, 'public'), - }, - }, -}); - -await server.register(require('@hapi/inert')); +hapi,使用 `file` handler: +```ts server.route({ method: 'GET', path: '/image', @@ -797,32 +851,17 @@ server.route({ }); ``` -要在hapi中提供静态文件,您首先必须告诉hapi静态文件位于何处。您可以通过配置 `server.options.routes` 对象来实现。您将 `relativeTo` 设置为文件所在的文件夹,就像在Express中 `res.sendFile` 的 options 对象中所做的一样。接下来,您需要注册惰性插件。这将使您能够访问允许提供静态文件的方法。现在,在路由处理程序中,您可以使用 `h.file()` 方法或文件处理程序来处理您的静态文件。 - ### 静态文件服务器 -要在Express中设置静态文件服务器,可以使用 `express.static()` 中间件。在hapi中,您使用 [inert](/module/inert) 插件提供的文件处理程序。您将通过告诉文件位于何处,以与服务单个静态文件相同的方式设置服务器。 然后,您将设置一条路由以捕获所有请求并返回正确的文件。 我们来看一下: - -Express: +Express: -```js -app.use(express.static('/public')); +```ts +app.use(express.static('public')); ``` -hapi: - -```js -const server = new Hapi.Server({ - port: 3000, - routes: { - files: { - relativeTo: Path.join(__dirname, 'public'), - }, - }, -}); - -await server.register(require('@hapi/inert')); +hapi: +```ts server.route({ method: 'GET', path: '/{param*}', @@ -834,20 +873,19 @@ server.route({ }); ``` -现在,您可以通过访问 `localhost:3000/filename` 来访问任何静态文件。 [inert](/module/inert) 具有许多其他选项和功能。要查看其所有功能,请参阅 [serving static files](/tutorials/servingfiles/?lang=en_US) tutorial 教程。 -Now, you can access any static files by going to `localhost:3000/filename`. [inert](/module/inert) has many other options and capabilities. To see what all it can do, please see the [serving static files](/tutorials/servingfiles/?lang=en_US) tutorial. +`{param*}` 通配符匹配任意路径,`directory` handler 从 `public` 文件夹中提供匹配的文件,所以 `/logo.png` 返回 `public/logo.png`。由于 hapi 选择最具体的路由,你的其他路由仍然会胜过这个兜底路由。 -## Error Handling -> boom +## 错误处理 -> boom -hapi使用 [boom](/module/boom) 模块来处理错误。默认情况下,boom将以JSON格式返回错误。另一方面,Express默认情况下将返回文本响应,该响应在JSON API中并非最佳。通过向不存在的 `'/hello'` 提交 `GET` 请求,以默认设置查看404错误响应: +hapi 用 JSON 响应错误,内容由 [boom](/module/boom) 错误对象构建。Express 则返回一个 HTML 页面。对于一个没有匹配路由的 `GET /hello`: -Express: +Express: -```js +```text Cannot GET /hello ``` -hapi: +hapi: ```json { @@ -859,18 +897,26 @@ hapi: ### 自定义消息 -`boom` 可让您轻松更改任何状态代码的错误消息。让我们使用上面的404错误并返回一条新消息: - -Express: +Express: -```js -res.status(400).send({ status: 404, error: 'Page not found' }); +```ts +res.status(404).send({ status: 404, error: 'Page not found' }); ``` -hapi: +hapi: -```js +```ts throw Boom.notFound('Page not found'); ``` -在Express中,您设置状态代码,然后发送错误消息正文。在这种情况下,我们返回带有状态代码和错误消息的JSON对象。在 `boom` 中,不需要返回带有状态码的JSON对象,默认情况下会这样做。在上面的示例中,您抛出 `Boom.notFound()` 来设置错误消息。 `boom` 包含一长串4xx和5xx错误,例如 `Boom.unauthorized()`,`Boom.badRequest()`,`Boom.badImplementation()`等。有关完整列表,请参见 [boom](/module/boom) 文档。 +抛出的错误会变成这样的响应: + +```json +{ + "statusCode": 404, + "error": "Not Found", + "message": "Page not found" +} +``` + +boom 为常见的 4xx 和 5xx 状态码都提供了函数,例如 `Boom.badRequest()`、`Boom.unauthorized()` 和 `Boom.badImplementation()`。在 handler、扩展或插件中的任何地方抛出它,hapi 都会把它变成响应。不是 boom 错误的错误会变成 500,其消息对客户端隐藏。完整列表请参阅 [boom 文档](/module/boom)。 From 0148544fc17ec82c1a0acd486081ab0a8521e1e5 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:55:11 -0400 Subject: [PATCH 18/22] docs: add decorators tutorial --- .vitepress/config.ts | 1 + docs/tutorials/en_US/decorators.md | 335 +++++++++++++++++++++++++++++ docs/tutorials/es_ES/decorators.md | 335 +++++++++++++++++++++++++++++ docs/tutorials/ko_KR/decorators.md | 335 +++++++++++++++++++++++++++++ docs/tutorials/pt_BR/decorators.md | 335 +++++++++++++++++++++++++++++ docs/tutorials/tr_TR/decorators.md | 335 +++++++++++++++++++++++++++++ docs/tutorials/zh_CN/decorators.md | 335 +++++++++++++++++++++++++++++ 7 files changed, 2011 insertions(+) create mode 100644 docs/tutorials/en_US/decorators.md create mode 100644 docs/tutorials/es_ES/decorators.md create mode 100644 docs/tutorials/ko_KR/decorators.md create mode 100644 docs/tutorials/pt_BR/decorators.md create mode 100644 docs/tutorials/tr_TR/decorators.md create mode 100644 docs/tutorials/zh_CN/decorators.md diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 7bbef0ba..e57e67fa 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -57,6 +57,7 @@ const tutorialItems = [ { slug: 'auth', text: 'Authentication' }, { slug: 'caching', text: 'Caching' }, { slug: 'cookies', text: 'Cookies' }, + { slug: 'decorators', text: 'Decorators' }, { slug: 'logging', text: 'Logging' }, { slug: 'plugins', text: 'Plugins' }, { slug: 'routing', text: 'Routing' }, diff --git a/docs/tutorials/en_US/decorators.md b/docs/tutorials/en_US/decorators.md new file mode 100644 index 00000000..7de36b38 --- /dev/null +++ b/docs/tutorials/en_US/decorators.md @@ -0,0 +1,335 @@ +--- +title: Decorators +--- + + + +# Decorators + +_This tutorial is compatible with hapi v21 and newer_ + +## Overview + +A decoration adds your own property to one of hapi's objects. Once you decorate, every part of your app can use the new property: `server.todos` in a plugin, `request.loadAccount()` in a handler, `h.paginated()` when building a response. + +Most hapi plugins are built on decorations. When you register `@hapi/vision`, `h.view()` exists because vision decorated the response toolkit. When you register `@hapi/inert`, `handler: { file: 'index.html' }` works because inert decorated the route handlers. + +You add decorations with [`server.decorate(type, property, method, [options])`](/api#server-decorate-type-property-method-options). `type` picks the object you are adding to: + +| `type` | Adds to | Real examples | +| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `'server'` | the `server` object | vision's `server.views()` and `server.render()`, nes's `server.publish()`, yar's `server.yar` | +| `'request'` | every `request` object | vision's `request.render()`, @hapi/cookie's `request.cookieAuth`, yar's `request.yar`, nes's `request.socket` | +| `'toolkit'` | the response toolkit `h` | vision's `h.view()`, inert's `h.file()`, h2o2's `h.proxy()` | +| `'handler'` | the handler types a route can use | vision's `{ view }`, inert's `{ file }` and `{ directory }`, h2o2's `{ proxy }` | +| `'response'` | every response object | none of the hapi plugins above; rarely needed | + +Decorations often work together with [lifecycle hooks](/tutorials/en_US/lifecycle-hooks): @hapi/cookie, for example, adds `request.cookieAuth` with a decoration and fills it in with an `onPreAuth` hook. + +## Server decorations: sharing a service + +The most common decoration shares one object with the whole app, such as a database client, a mailer, or a service. A plugin creates it once and decorates the server with it. Handlers reach it through `request.server`, and other plugins through their own `server` argument. + +```ts +import Hapi from '@hapi/hapi'; + +import type { Plugin } from '@hapi/hapi'; + +interface Todo { + id: number; + title: string; +} + +class TodoStore { + #todos = new Map(); + + add(title: string) { + const todo = { id: this.#todos.size + 1, title }; + this.#todos.set(todo.id, todo); + return todo; + } + + list() { + return [...this.#todos.values()]; + } +} + +declare module '@hapi/hapi' { + interface Server { + todos: TodoStore; + } +} + +const storePlugin: Plugin = { + name: 'todo-store', + register: (server) => { + server.decorate('server', 'todos', new TodoStore()); + }, +}; + +const routesPlugin: Plugin = { + name: 'todo-routes', + dependencies: ['todo-store'], + register: (server) => { + server.route({ + method: 'GET', + path: '/todos', + handler: (request, h) => request.server.todos.list(), + }); + + server.route<{ Payload: { title: string } }>({ + method: 'POST', + path: '/todos', + handler: (request, h) => request.server.todos.add(request.payload.title), + }); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register([storePlugin, routesPlugin]); +await server.start(); +``` + +The `declare module` block tells TypeScript about the new property. hapi exports its objects as interfaces you can extend, and after this `server.todos` and `request.server.todos` are typed everywhere. The [TypeScript section](#typescript) lists the interface for each decoration type. + +### Where should a value live? + +hapi has several places to keep shared values, and a decoration is only one of them: + +| The value is | Keep it in | Example | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------ | +| a service with methods that routes and plugins call | a server decoration | `server.todos` | +| a function whose results are worth caching by arguments | a [server method](/tutorials/en_US/server-methods) | `server.methods.getWeather()` | +| app-wide configuration or state, read and written by any code | [`server.app`](/api#server-app) | `server.app.config` | +| plugin state that other plugins only occasionally read | [`server.expose()`](/api#server-expose-key-value-options), read as `server.plugins[name]` | `server.plugins['todo-store']` | +| a helper that reads the current request | a request decoration | `request.clientIp()` | +| per-request state created on first use | a request decoration with `apply: true` | `request.loadAccount()` | +| per-request data that a hook or handler sets | [`request.app`](/api#request-app), or [`request.plugins`](/api#request-plugins) in a plugin | an id set in `onRequest` | + +A server decoration is the right choice when the value is a service with methods, every route needs it, and you want a short, typed name for it. + +## Request decorations + +A request decoration adds a property to every request. There are two kinds, and the difference is when the value is created. + +### Methods shared by every request + +Without options, the decoration is put on the request prototype once, and every request shares it. Use a regular `function` so `this` is the current request: + +```ts +declare module '@hapi/hapi' { + interface Request { + clientIp: () => string; + } +} + +server.decorate('request', 'clientIp', function () { + const forwarded = this.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',').at(-1)?.trim() ?? this.info.remoteAddress; + } + + return this.info.remoteAddress; +}); + +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + return { ip: request.clientIp() }; + }, +}); +``` + +This reads the last entry of `x-forwarded-for`, which is the address seen by a single proxy you control that appends to the header, such as nginx with `$proxy_add_x_forwarded_for` or an AWS load balancer. Entries before it come from the client and can be forged. With more trusted proxies in front, count back one entry per proxy; with none, ignore the header, since any client can send it. + +### Per-request values with `apply: true` + +With `{ apply: true }`, hapi calls your function once for each new request, with the request as its argument, and assigns the return value to the property. Use it when each request needs its own state, such as a cached lookup: + +```ts +interface Account { + id: string; + name: string; +} + +declare module '@hapi/hapi' { + interface Request { + loadAccount: () => Promise; + } +} + +const accounts = new Map([['2133d32a', { id: '2133d32a', name: 'John Doe' }]]); + +const findAccount = async (id: string) => accounts.get(id) ?? null; + +server.decorate( + 'request', + 'loadAccount', + (request) => { + let pending: Promise | undefined; + + return () => { + const id = request.headers['x-account-id']; + pending ??= typeof id === 'string' ? findAccount(id) : Promise.resolve(null); + return pending; + }; + }, + { apply: true }, +); +``` + +A pre-handler, an `onPostAuth` hook, and the handler can all call `request.loadAccount()`, and the account is looked up once per request. Without `apply`, the decoration is one function shared by all requests, so there is no per-request place to keep `pending`; a variable outside the function would leak the first request's account into all the others. + +| | Without options | With `apply: true` | +| ----------------- | ------------------------------------ | -------------------------------------------------------- | +| Created | once, on the request prototype | once per request, when the request object is built | +| Per-request state | none, use `this` to read the request | yes, held in the closure | +| Cost per request | none | your function runs on every request | +| Real example | vision's `request.render()` | @hapi/cookie's `request.cookieAuth`, yar's `request.yar` | + +`apply` functions run for every request, including requests for routes that never use the value. Keep them cheap: create the closure, and do the work the first time the value is used, as `loadAccount()` does. + +## Toolkit decorations: response helpers + +A toolkit decoration adds a method to `h`. Use a regular `function`: `this` is the toolkit, so `this.response()` builds a response and `this.request` is the current request. This one gives every list route the same pagination convention: + +```ts +import type { ResponseObject } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + paginated: (items: object[], total: number) => ResponseObject; + } +} + +server.decorate('toolkit', 'paginated', function (items: object[], total: number) { + return this.response(items).header('x-total-count', String(total)); +}); + +server.route<{ Query: { page?: string } }>({ + method: 'GET', + path: '/todos', + handler: (request, h) => { + const page = Number(request.query.page ?? 1); + const todos = request.server.todos.list(); + return h.paginated(todos.slice((page - 1) * 20, page * 20), todos.length); + }, +}); +``` + +`h.paginated()` sends one page of items with an `x-total-count` header, so clients read the total the same way on every list route. Toolkit decorations are a good place for response conventions your API repeats, such as this header or an error envelope. How hapi turns the returned value into bytes is covered in [response marshalling](/tutorials/en_US/response-marshalling). + +## Handler decorations: custom handler types + +A handler decoration lets routes describe their handler as an object instead of writing a function, like `handler: { file: 'index.html' }`. Use one when many routes share the same behavior and differ only in configuration. + +The decoration is a function `(route, options)` that hapi calls once per route, when the route is added. It returns the actual handler. This one redirects old URLs: + +```ts +import type { HandlerDecorationMethod } from '@hapi/hapi'; + +interface MovedOptions { + to: string; + permanent?: boolean; +} + +declare module '@hapi/hapi' { + interface HandlerDecorations { + moved?: MovedOptions; + } +} + +const moved: HandlerDecorationMethod = (route, options: MovedOptions) => { + return (request, h) => h.redirect(options.to).permanent(options.permanent ?? false); +}; + +moved.defaults = { auth: false }; + +server.decorate('handler', 'moved', moved); + +server.route({ method: 'GET', path: '/old-docs', handler: { moved: { to: '/docs', permanent: true } } }); +server.route({ method: 'GET', path: '/blog/{slug}', handler: { moved: { to: '/articles' } } }); +``` + +The optional `defaults` property sets route options for every route that uses the handler; here, redirects never require authentication. `defaults` can also be a function of the HTTP method: h2o2 uses one so that `{ proxy }` routes skip payload parsing and stream the body to the upstream server instead. + +Because hapi builds the handler when the route is added, the handler decoration must exist before any route uses it. Register the plugin that defines it first. + +## Response decorations + +`'response'` decorations add a method to every response object, including error responses and those built with `request.generateResponse()`. They are rarely needed. To change responses across the app, use an `onPreResponse` [lifecycle hook](/tutorials/en_US/lifecycle-hooks). + +## Wrapping an existing decoration + +`{ extend: true }` replaces an existing decoration with a wrapped version. Your function receives the current decoration and returns the new one. A common use is adding data to another plugin's decoration without changing its code. This wraps vision's `h.view()` so every template receives the logged-in user: + +```ts +server.decorate( + 'toolkit', + 'view', + (existing) => { + return function (template: string, context: object = {}, options?: object) { + const user = this.request.auth.credentials?.user; + return existing.call(this, template, { user, ...context }, options); + }; + }, + { extend: true }, +); +``` + +The plugin that defines the decoration has to be registered first; extending a decoration that does not exist yet throws. Handler decorations cannot be extended. + +## Rules hapi enforces + +Decorations belong to the whole server, not to the plugin that added them. A decoration added inside a plugin is visible to the root server and to every other plugin. hapi throws as soon as a decoration or a route breaks one of these rules: + +| Mistake | Error | +| -------------------------------------------- | ----------------------------------------------------------------- | +| Two plugins decorate the same name | `Server decoration already defined: todos` | +| Name starts with an underscore | `Property name cannot begin with an underscore: _cache` | +| Name is a built-in property | `Cannot override the built-in request interface decoration: auth` | +| A route uses a handler type nobody decorated | `Unknown handler: moved` | +| `extend: true` on a name that does not exist | `Cannot extend missing toolkit decoration: view` | + +Because names are global, give decorations from reusable plugins a specific name, or let users configure it, as @hapi/cookie does with its `requestDecoratorName` option. [`server.decorations`](/api#server-decorations) lists the names already in use. + +Server decorations are copied to each plugin's own `server` object, so inside a decoration `this` is the server of the plugin that calls it. vision relies on this: `server.render()` looks up the views configured in the caller's plugin, so two plugins can each have their own templates. + +## Bootstrapping with decorations + +Plugins usually decorate inside `register()`, so the decoration exists as soon as `server.register()` resolves. A plugin that uses another plugin's decoration only in handlers can be registered in any order, since handlers run after the server starts. One that uses it inside its own `register()` must be registered after the plugin that adds it. + +- A plugin's `dependencies: ['todo-store']` property only makes hapi check, at [`server.initialize()`](/api#await-server-initialize) or `server.start()`, that the other plugin was registered. It does not change the order plugins are registered in. +- Code that uses another plugin's decoration while the server boots, such as warming a cache or creating tables, belongs in a [`server.dependency()`](/api#server-dependency-dependencies-after) callback. hapi runs it in the `onPreStart` phase, after the plugins it depends on, regardless of registration order: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const seedPlugin: Plugin = { + name: 'todo-seed', + register: (server) => { + server.dependency('todo-store', async (server) => { + server.todos.add('Read the decorators tutorial'); + }); + }, +}; +``` + +The full startup order is in [Dependencies](/tutorials/en_US/plugins#dependencies) in the Plugins tutorial and in [Server hooks](/tutorials/en_US/lifecycle-hooks#server-hooks). + +## TypeScript + +hapi exports an interface for each object you can decorate. Add your decoration to it with a `declare module '@hapi/hapi'` block, in the same file as the `server.decorate()` call, so the type and the code stay together: + +| `type` | Interface to extend | Example | +| ----------- | -------------------- | --------------------------------------------------------------------------------------------- | +| `'server'` | `Server` | `interface Server { todos: TodoStore }` | +| `'request'` | `Request` | `interface Request { loadAccount: () => Promise }` | +| `'toolkit'` | `ResponseToolkit` | `interface ResponseToolkit { paginated: (items: object[], total: number) => ResponseObject }` | +| `'handler'` | `HandlerDecorations` | `interface HandlerDecorations { moved?: MovedOptions }` | + +Handler decoration properties must be optional, or every route would have to set them. hapi's own types use the same mechanism for other app-specific data: `ServerApplicationState` types `server.app`, `RequestApplicationState` types `request.app`, `PluginProperties` types `server.plugins`, and `PluginsStates` types `request.plugins`. + +See the [plugins tutorial](/tutorials/en_US/plugins) for packaging these decorations as a plugin, and the [API reference](/api#server-decorate-type-property-method-options) for the complete `server.decorate()` signature. diff --git a/docs/tutorials/es_ES/decorators.md b/docs/tutorials/es_ES/decorators.md new file mode 100644 index 00000000..8a9d3433 --- /dev/null +++ b/docs/tutorials/es_ES/decorators.md @@ -0,0 +1,335 @@ +--- +title: Decoradores +--- + + + +# Decoradores + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Una decoración añade una propiedad propia a uno de los objetos de hapi. Una vez decorado, cualquier parte de tu aplicación puede usar la nueva propiedad: `server.todos` en un plugin, `request.loadAccount()` en un handler, `h.paginated()` al construir una respuesta. + +La mayoría de los plugins de hapi se basan en decoraciones. Cuando registras `@hapi/vision`, `h.view()` existe porque vision ha decorado el response toolkit. Cuando registras `@hapi/inert`, `handler: { file: 'index.html' }` funciona porque inert ha decorado los handlers de ruta. + +Las decoraciones se añaden con [`server.decorate(type, property, method, [options])`](/api#server-decorate-type-property-method-options). `type` elige el objeto al que se añade: + +| `type` | Se añade a | Ejemplos reales | +| ------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `'server'` | el objeto `server` | `server.views()` y `server.render()` de vision, `server.publish()` de nes, `server.yar` de yar | +| `'request'` | cada objeto `request` | `request.render()` de vision, `request.cookieAuth` de @hapi/cookie, `request.yar` de yar, `request.socket` de nes | +| `'toolkit'` | el response toolkit `h` | `h.view()` de vision, `h.file()` de inert, `h.proxy()` de h2o2 | +| `'handler'` | los tipos de handler que puede usar una ruta | `{ view }` de vision, `{ file }` y `{ directory }` de inert, `{ proxy }` de h2o2 | +| `'response'` | cada objeto response | ninguno de los plugins anteriores; rara vez hace falta | + +Las decoraciones suelen trabajar junto con los [hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks): @hapi/cookie, por ejemplo, añade `request.cookieAuth` con una decoración y lo rellena con un hook `onPreAuth`. + +## Decoraciones de servidor: compartir un servicio + +La decoración más habitual comparte un objeto con toda la aplicación, como un cliente de base de datos, un servicio de correo o cualquier otro servicio. Un plugin lo crea una vez y decora el servidor con él. Los handlers llegan a él a través de `request.server`, y los demás plugins a través de su propio argumento `server`. + +```ts +import Hapi from '@hapi/hapi'; + +import type { Plugin } from '@hapi/hapi'; + +interface Todo { + id: number; + title: string; +} + +class TodoStore { + #todos = new Map(); + + add(title: string) { + const todo = { id: this.#todos.size + 1, title }; + this.#todos.set(todo.id, todo); + return todo; + } + + list() { + return [...this.#todos.values()]; + } +} + +declare module '@hapi/hapi' { + interface Server { + todos: TodoStore; + } +} + +const storePlugin: Plugin = { + name: 'todo-store', + register: (server) => { + server.decorate('server', 'todos', new TodoStore()); + }, +}; + +const routesPlugin: Plugin = { + name: 'todo-routes', + dependencies: ['todo-store'], + register: (server) => { + server.route({ + method: 'GET', + path: '/todos', + handler: (request, h) => request.server.todos.list(), + }); + + server.route<{ Payload: { title: string } }>({ + method: 'POST', + path: '/todos', + handler: (request, h) => request.server.todos.add(request.payload.title), + }); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register([storePlugin, routesPlugin]); +await server.start(); +``` + +El bloque `declare module` informa a TypeScript de la nueva propiedad. hapi exporta sus objetos como interfaces que puedes extender, y a partir de ahí `server.todos` y `request.server.todos` están tipados en todas partes. La [sección de TypeScript](#typescript) indica la interfaz de cada tipo de decoración. + +### ¿Dónde debe vivir un valor? + +hapi tiene varios sitios donde guardar valores compartidos, y una decoración es solo uno de ellos: + +| El valor es | Guárdalo en | Ejemplo | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------ | +| un servicio con métodos que llaman las rutas y los plugins | una decoración de servidor | `server.todos` | +| una función cuyos resultados merece la pena cachear por argumentos | un [método de servidor](/tutorials/es_ES/server-methods) | `server.methods.getWeather()` | +| configuración o estado de toda la aplicación, que lee y escribe cualquier código | [`server.app`](/api#server-app) | `server.app.config` | +| estado de un plugin que otros plugins solo leen de vez en cuando | [`server.expose()`](/api#server-expose-key-value-options), leído como `server.plugins[name]` | `server.plugins['todo-store']` | +| un helper que lee la petición actual | una decoración de petición | `request.clientIp()` | +| estado por petición que se crea en el primer uso | una decoración de petición con `apply: true` | `request.loadAccount()` | +| datos por petición que fija un hook o un handler | [`request.app`](/api#request-app), o [`request.plugins`](/api#request-plugins) en un plugin | un id fijado en `onRequest` | + +Una decoración de servidor es la opción adecuada cuando el valor es un servicio con métodos, todas las rutas lo necesitan y quieres un nombre corto y tipado para él. + +## Decoraciones de petición + +Una decoración de petición añade una propiedad a cada petición. Hay dos tipos, y la diferencia está en cuándo se crea el valor. + +### Métodos compartidos por todas las peticiones + +Sin opciones, la decoración se coloca una sola vez en el prototipo de la petición, y todas las peticiones la comparten. Usa una `function` normal para que `this` sea la petición actual: + +```ts +declare module '@hapi/hapi' { + interface Request { + clientIp: () => string; + } +} + +server.decorate('request', 'clientIp', function () { + const forwarded = this.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',').at(-1)?.trim() ?? this.info.remoteAddress; + } + + return this.info.remoteAddress; +}); + +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + return { ip: request.clientIp() }; + }, +}); +``` + +Esto lee la última entrada de `x-forwarded-for`, que es la dirección que ve un único proxy bajo tu control que añade su entrada a la cabecera, como nginx con `$proxy_add_x_forwarded_for` o un balanceador de carga de AWS. Las entradas anteriores vienen del cliente y se pueden falsificar. Si hay más proxies de confianza delante, retrocede una entrada por proxy; si no hay ninguno, ignora la cabecera, ya que cualquier cliente puede enviarla. + +### Valores por petición con `apply: true` + +Con `{ apply: true }`, hapi llama a tu función una vez por cada petición nueva, con la petición como argumento, y asigna el valor devuelto a la propiedad. Úsalo cuando cada petición necesite su propio estado, como una búsqueda cacheada: + +```ts +interface Account { + id: string; + name: string; +} + +declare module '@hapi/hapi' { + interface Request { + loadAccount: () => Promise; + } +} + +const accounts = new Map([['2133d32a', { id: '2133d32a', name: 'John Doe' }]]); + +const findAccount = async (id: string) => accounts.get(id) ?? null; + +server.decorate( + 'request', + 'loadAccount', + (request) => { + let pending: Promise | undefined; + + return () => { + const id = request.headers['x-account-id']; + pending ??= typeof id === 'string' ? findAccount(id) : Promise.resolve(null); + return pending; + }; + }, + { apply: true }, +); +``` + +Un pre-handler, un hook `onPostAuth` y el handler pueden llamar a `request.loadAccount()`, y la cuenta se busca una sola vez por petición. Sin `apply`, la decoración es una única función compartida por todas las peticiones, así que no hay un sitio por petición donde guardar `pending`; una variable fuera de la función filtraría la cuenta de la primera petición a todas las demás. + +| | Sin opciones | Con `apply: true` | +| ------------------- | ----------------------------------------- | ----------------------------------------------------------- | +| Se crea | una vez, en el prototipo de la petición | una vez por petición, al construir el objeto de la petición | +| Estado por petición | ninguno, usa `this` para leer la petición | sí, guardado en el closure | +| Coste por petición | ninguno | tu función se ejecuta en cada petición | +| Ejemplo real | `request.render()` de vision | `request.cookieAuth` de @hapi/cookie, `request.yar` de yar | + +Las funciones `apply` se ejecutan en cada petición, incluidas las de rutas que nunca usan el valor. Mantenlas ligeras: crea el closure y haz el trabajo la primera vez que se use el valor, como hace `loadAccount()`. + +## Decoraciones del toolkit: helpers de respuesta + +Una decoración del toolkit añade un método a `h`. Usa una `function` normal: `this` es el toolkit, así que `this.response()` construye una respuesta y `this.request` es la petición actual. Esta da a todas las rutas de listado la misma convención de paginación: + +```ts +import type { ResponseObject } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + paginated: (items: object[], total: number) => ResponseObject; + } +} + +server.decorate('toolkit', 'paginated', function (items: object[], total: number) { + return this.response(items).header('x-total-count', String(total)); +}); + +server.route<{ Query: { page?: string } }>({ + method: 'GET', + path: '/todos', + handler: (request, h) => { + const page = Number(request.query.page ?? 1); + const todos = request.server.todos.list(); + return h.paginated(todos.slice((page - 1) * 20, page * 20), todos.length); + }, +}); +``` + +`h.paginated()` envía una página de elementos con una cabecera `x-total-count`, de modo que los clientes leen el total de la misma forma en todas las rutas de listado. Las decoraciones del toolkit son un buen sitio para las convenciones de respuesta que tu API repite, como esta cabecera o un formato común de error. Cómo convierte hapi el valor devuelto en bytes se explica en [serialización de respuestas](/tutorials/es_ES/response-marshalling). + +## Decoraciones de handler: tipos de handler propios + +Una decoración de handler permite que las rutas describan su handler como un objeto en lugar de escribir una función, como `handler: { file: 'index.html' }`. Úsala cuando muchas rutas compartan el mismo comportamiento y solo cambie la configuración. + +La decoración es una función `(route, options)` a la que hapi llama una vez por ruta, cuando se añade la ruta. Devuelve el handler real. Esta redirige URLs antiguas: + +```ts +import type { HandlerDecorationMethod } from '@hapi/hapi'; + +interface MovedOptions { + to: string; + permanent?: boolean; +} + +declare module '@hapi/hapi' { + interface HandlerDecorations { + moved?: MovedOptions; + } +} + +const moved: HandlerDecorationMethod = (route, options: MovedOptions) => { + return (request, h) => h.redirect(options.to).permanent(options.permanent ?? false); +}; + +moved.defaults = { auth: false }; + +server.decorate('handler', 'moved', moved); + +server.route({ method: 'GET', path: '/old-docs', handler: { moved: { to: '/docs', permanent: true } } }); +server.route({ method: 'GET', path: '/blog/{slug}', handler: { moved: { to: '/articles' } } }); +``` + +La propiedad opcional `defaults` fija opciones de ruta para todas las rutas que usan el handler; aquí, las redirecciones nunca requieren autenticación. `defaults` también puede ser una función del método HTTP: h2o2 usa una para que las rutas `{ proxy }` se salten el análisis del payload y, en su lugar, transmitan el cuerpo en streaming al servidor de destino. + +Como hapi construye el handler cuando se añade la ruta, la decoración de handler tiene que existir antes de que ninguna ruta la use. Registra primero el plugin que la define. + +## Decoraciones de respuesta + +Las decoraciones `'response'` añaden un método a cada objeto response, incluidas las respuestas de error y las construidas con `request.generateResponse()`. Rara vez hacen falta. Para cambiar las respuestas en toda la aplicación, usa un [hook del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) `onPreResponse`. + +## Envolver una decoración existente + +`{ extend: true }` sustituye una decoración existente por una versión envuelta. Tu función recibe la decoración actual y devuelve la nueva. Un uso habitual es añadir datos a la decoración de otro plugin sin cambiar su código. Esto envuelve `h.view()` de vision para que cada plantilla reciba el usuario autenticado: + +```ts +server.decorate( + 'toolkit', + 'view', + (existing) => { + return function (template: string, context: object = {}, options?: object) { + const user = this.request.auth.credentials?.user; + return existing.call(this, template, { user, ...context }, options); + }; + }, + { extend: true }, +); +``` + +El plugin que define la decoración tiene que registrarse primero; extender una decoración que todavía no existe lanza un error. Las decoraciones de handler no se pueden extender. + +## Reglas que hapi impone + +Las decoraciones pertenecen a todo el servidor, no al plugin que las añadió. Una decoración añadida dentro de un plugin es visible para el servidor raíz y para todos los demás plugins. hapi lanza un error en cuanto una decoración o una ruta incumple una de estas reglas: + +| Error cometido | Mensaje | +| ----------------------------------------------------- | ----------------------------------------------------------------- | +| Dos plugins decoran el mismo nombre | `Server decoration already defined: todos` | +| El nombre empieza por guion bajo | `Property name cannot begin with an underscore: _cache` | +| El nombre es una propiedad integrada | `Cannot override the built-in request interface decoration: auth` | +| Una ruta usa un tipo de handler que nadie ha decorado | `Unknown handler: moved` | +| `extend: true` sobre un nombre que no existe | `Cannot extend missing toolkit decoration: view` | + +Como los nombres son globales, da a las decoraciones de los plugins reutilizables un nombre específico, o deja que los usuarios lo configuren, como hace @hapi/cookie con su opción `requestDecoratorName`. [`server.decorations`](/api#server-decorations) lista los nombres que ya están en uso. + +Las decoraciones de servidor se copian en el objeto `server` propio de cada plugin, así que dentro de una decoración `this` es el servidor del plugin que la llama. vision se apoya en esto: `server.render()` busca las vistas configuradas en el plugin que la llama, de modo que dos plugins pueden tener cada uno sus propias plantillas. + +## Arranque con decoraciones + +Los plugins suelen decorar dentro de `register()`, así que la decoración existe en cuanto se resuelve `server.register()`. Un plugin que usa la decoración de otro plugin solo en los handlers puede registrarse en cualquier orden, ya que los handlers se ejecutan después de arrancar el servidor. Uno que la usa dentro de su propio `register()` debe registrarse después del plugin que la añade. + +- La propiedad `dependencies: ['todo-store']` de un plugin solo hace que hapi compruebe, en [`server.initialize()`](/api#await-server-initialize) o `server.start()`, que el otro plugin se ha registrado. No cambia el orden en que se registran los plugins. +- El código que usa la decoración de otro plugin mientras arranca el servidor, como precargar una caché o crear tablas, va en un callback de [`server.dependency()`](/api#server-dependency-dependencies-after). hapi lo ejecuta en la fase `onPreStart`, después de los plugins de los que depende, sea cual sea el orden de registro: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const seedPlugin: Plugin = { + name: 'todo-seed', + register: (server) => { + server.dependency('todo-store', async (server) => { + server.todos.add('Read the decorators tutorial'); + }); + }, +}; +``` + +El orden completo de arranque está en [Dependencias](/tutorials/es_ES/plugins#dependencies), en el tutorial de plugins, y en [Hooks del servidor](/tutorials/es_ES/lifecycle-hooks#server-hooks). + +## TypeScript + +hapi exporta una interfaz por cada objeto que puedes decorar. Añade tu decoración con un bloque `declare module '@hapi/hapi'`, en el mismo fichero que la llamada a `server.decorate()`, para que el tipo y el código vayan juntos: + +| `type` | Interfaz que extender | Ejemplo | +| ----------- | --------------------- | --------------------------------------------------------------------------------------------- | +| `'server'` | `Server` | `interface Server { todos: TodoStore }` | +| `'request'` | `Request` | `interface Request { loadAccount: () => Promise }` | +| `'toolkit'` | `ResponseToolkit` | `interface ResponseToolkit { paginated: (items: object[], total: number) => ResponseObject }` | +| `'handler'` | `HandlerDecorations` | `interface HandlerDecorations { moved?: MovedOptions }` | + +Las propiedades de las decoraciones de handler tienen que ser opcionales; si no, todas las rutas tendrían que fijarlas. Los propios tipos de hapi usan el mismo mecanismo para otros datos específicos de la aplicación: `ServerApplicationState` tipa `server.app`, `RequestApplicationState` tipa `request.app`, `PluginProperties` tipa `server.plugins` y `PluginsStates` tipa `request.plugins`. + +Consulta el [tutorial de plugins](/tutorials/es_ES/plugins) para empaquetar estas decoraciones como plugin, y la [referencia de la API](/api#server-decorate-type-property-method-options) para la firma completa de `server.decorate()`. diff --git a/docs/tutorials/ko_KR/decorators.md b/docs/tutorials/ko_KR/decorators.md new file mode 100644 index 00000000..78e89fea --- /dev/null +++ b/docs/tutorials/ko_KR/decorators.md @@ -0,0 +1,335 @@ +--- +title: 데코레이터 +--- + + + +# 데코레이터 + +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ + +## 개요 + +데코레이션은 hapi 객체 중 하나에 여러분이 정의한 속성을 추가합니다. 한 번 데코레이트하면 앱의 모든 곳에서 새 속성을 사용할 수 있습니다. 플러그인에서는 `server.todos`, 핸들러에서는 `request.loadAccount()`, 응답을 만들 때는 `h.paginated()`처럼요. + +대부분의 hapi 플러그인은 데코레이션을 기반으로 만들어집니다. `@hapi/vision`을 등록하면 `h.view()`가 생기는 것은 vision이 응답 툴킷을 데코레이트했기 때문입니다. `@hapi/inert`를 등록하면 `handler: { file: 'index.html' }`이 동작하는 것은 inert가 라우트 핸들러를 데코레이트했기 때문입니다. + +데코레이션은 [`server.decorate(type, property, method, [options])`](/api#server-decorate-type-property-method-options)로 추가합니다. `type`은 무엇에 추가할지 정합니다. + +| `type` | 추가 대상 | 실제 예 | +| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `'server'` | `server` 객체 | vision의 `server.views()`와 `server.render()`, nes의 `server.publish()`, yar의 `server.yar` | +| `'request'` | 모든 `request` 객체 | vision의 `request.render()`, @hapi/cookie의 `request.cookieAuth`, yar의 `request.yar`, nes의 `request.socket` | +| `'toolkit'` | 응답 툴킷 `h` | vision의 `h.view()`, inert의 `h.file()`, h2o2의 `h.proxy()` | +| `'handler'` | 라우트에서 쓸 수 있는 핸들러 유형 | vision의 `{ view }`, inert의 `{ file }`과 `{ directory }`, h2o2의 `{ proxy }` | +| `'response'` | 모든 응답 객체 | 위 hapi 플러그인 중에는 없음. 필요한 경우가 드묾 | + +데코레이션은 [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks)과 함께 쓰이는 경우가 많습니다. 예를 들어 @hapi/cookie는 데코레이션으로 `request.cookieAuth`를 추가하고 `onPreAuth` 훅에서 값을 채웁니다. + +## 서버 데코레이션: 서비스 공유하기 + +가장 흔한 데코레이션은 데이터베이스 클라이언트, 메일 발송기, 서비스처럼 객체 하나를 앱 전체에 공유하는 것입니다. 플러그인이 객체를 한 번 만들고 그 객체로 서버를 데코레이트합니다. 핸들러는 `request.server`를 통해, 다른 플러그인은 각자의 `server` 인자를 통해 이 객체에 접근합니다. + +```ts +import Hapi from '@hapi/hapi'; + +import type { Plugin } from '@hapi/hapi'; + +interface Todo { + id: number; + title: string; +} + +class TodoStore { + #todos = new Map(); + + add(title: string) { + const todo = { id: this.#todos.size + 1, title }; + this.#todos.set(todo.id, todo); + return todo; + } + + list() { + return [...this.#todos.values()]; + } +} + +declare module '@hapi/hapi' { + interface Server { + todos: TodoStore; + } +} + +const storePlugin: Plugin = { + name: 'todo-store', + register: (server) => { + server.decorate('server', 'todos', new TodoStore()); + }, +}; + +const routesPlugin: Plugin = { + name: 'todo-routes', + dependencies: ['todo-store'], + register: (server) => { + server.route({ + method: 'GET', + path: '/todos', + handler: (request, h) => request.server.todos.list(), + }); + + server.route<{ Payload: { title: string } }>({ + method: 'POST', + path: '/todos', + handler: (request, h) => request.server.todos.add(request.payload.title), + }); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register([storePlugin, routesPlugin]); +await server.start(); +``` + +`declare module` 블록은 새 속성을 TypeScript에 알려 줍니다. hapi는 자신의 객체를 확장 가능한 인터페이스로 내보내며, 이렇게 확장하면 어디서든 `server.todos`와 `request.server.todos`에 타입이 붙습니다. 데코레이션 유형별 인터페이스는 [TypeScript 섹션](#typescript)에 정리되어 있습니다. + +### 값은 어디에 두어야 할까? + +hapi에는 공유 값을 둘 곳이 여러 군데 있으며, 데코레이션은 그중 하나일 뿐입니다. + +| 값의 성격 | 둘 곳 | 예 | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------ | +| 라우트와 플러그인이 호출하는 메서드를 가진 서비스 | 서버 데코레이션 | `server.todos` | +| 인자별로 결과를 캐싱할 가치가 있는 함수 | [서버 메서드](/tutorials/ko_KR/server-methods) | `server.methods.getWeather()` | +| 어떤 코드든 읽고 쓰는 앱 전역 설정이나 상태 | [`server.app`](/api#server-app) | `server.app.config` | +| 다른 플러그인이 가끔 읽기만 하는 플러그인 상태 | [`server.expose()`](/api#server-expose-key-value-options), `server.plugins[name]`으로 읽음 | `server.plugins['todo-store']` | +| 현재 요청을 읽는 헬퍼 | 요청 데코레이션 | `request.clientIp()` | +| 처음 사용할 때 만들어지는 요청별 상태 | `apply: true`를 사용한 요청 데코레이션 | `request.loadAccount()` | +| 훅이나 핸들러가 설정하는 요청별 데이터 | [`request.app`](/api#request-app), 플러그인에서는 [`request.plugins`](/api#request-plugins) | `onRequest`에서 설정한 id | + +값이 메서드를 가진 서비스이고, 모든 라우트가 필요로 하며, 짧고 타입이 지정된 이름으로 쓰고 싶다면 서버 데코레이션이 알맞습니다. + +## 요청 데코레이션 + +요청 데코레이션은 모든 요청에 속성을 추가합니다. 두 종류가 있으며, 차이는 값이 언제 만들어지느냐에 있습니다. + +### 모든 요청이 공유하는 메서드 + +옵션 없이 데코레이트하면 데코레이션은 요청 프로토타입에 한 번만 올라가고, 모든 요청이 이를 공유합니다. `this`가 현재 요청이 되도록 일반 `function`을 사용하세요. + +```ts +declare module '@hapi/hapi' { + interface Request { + clientIp: () => string; + } +} + +server.decorate('request', 'clientIp', function () { + const forwarded = this.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',').at(-1)?.trim() ?? this.info.remoteAddress; + } + + return this.info.remoteAddress; +}); + +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + return { ip: request.clientIp() }; + }, +}); +``` + +이 코드는 `x-forwarded-for`의 마지막 항목을 읽습니다. 이는 헤더에 주소를 덧붙이는, 여러분이 관리하는 프록시 하나가 본 주소입니다. `$proxy_add_x_forwarded_for`를 쓰는 nginx나 AWS 로드 밸런서가 그런 예입니다. 그 앞의 항목은 클라이언트가 보낸 것이므로 위조될 수 있습니다. 신뢰하는 프록시가 더 있다면 프록시 하나당 한 항목씩 거슬러 세고, 프록시가 없다면 누구나 보낼 수 있는 헤더이므로 무시하세요. + +### `apply: true`로 요청별 값 만들기 + +`{ apply: true }`를 지정하면 hapi는 새 요청마다 요청을 인자로 함수를 한 번 호출하고, 반환값을 속성에 할당합니다. 캐싱된 조회처럼 요청마다 고유한 상태가 필요할 때 사용하세요. + +```ts +interface Account { + id: string; + name: string; +} + +declare module '@hapi/hapi' { + interface Request { + loadAccount: () => Promise; + } +} + +const accounts = new Map([['2133d32a', { id: '2133d32a', name: 'John Doe' }]]); + +const findAccount = async (id: string) => accounts.get(id) ?? null; + +server.decorate( + 'request', + 'loadAccount', + (request) => { + let pending: Promise | undefined; + + return () => { + const id = request.headers['x-account-id']; + pending ??= typeof id === 'string' ? findAccount(id) : Promise.resolve(null); + return pending; + }; + }, + { apply: true }, +); +``` + +pre-handler, `onPostAuth` 훅, 핸들러 모두 `request.loadAccount()`를 호출할 수 있으며, 계정은 요청당 한 번만 조회됩니다. `apply`가 없으면 데코레이션은 모든 요청이 공유하는 함수 하나이므로 `pending`을 요청별로 둘 곳이 없습니다. 함수 바깥의 변수를 쓰면 첫 요청의 계정이 다른 모든 요청으로 새어 나갑니다. + +| | 옵션 없음 | `apply: true` | +| ----------- | --------------------------- | -------------------------------------------------------- | +| 생성 시점 | 요청 프로토타입에 한 번 | 요청마다 한 번, 요청 객체가 만들어질 때 | +| 요청별 상태 | 없음. 요청은 `this`로 읽음 | 있음. 클로저에 보관 | +| 요청당 비용 | 없음 | 매 요청마다 함수가 실행됨 | +| 실제 예 | vision의 `request.render()` | @hapi/cookie의 `request.cookieAuth`, yar의 `request.yar` | + +`apply` 함수는 값을 전혀 쓰지 않는 라우트의 요청을 포함해 모든 요청에서 실행됩니다. 가볍게 유지하세요. `loadAccount()`처럼 클로저만 만들어 두고, 실제 작업은 값을 처음 사용할 때 하는 것이 좋습니다. + +## 툴킷 데코레이션: 응답 헬퍼 + +툴킷 데코레이션은 `h`에 메서드를 추가합니다. 일반 `function`을 사용하세요. `this`가 툴킷이므로 `this.response()`로 응답을 만들 수 있고 `this.request`는 현재 요청입니다. 다음 예는 모든 목록 라우트에 같은 페이지네이션 규칙을 적용합니다. + +```ts +import type { ResponseObject } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + paginated: (items: object[], total: number) => ResponseObject; + } +} + +server.decorate('toolkit', 'paginated', function (items: object[], total: number) { + return this.response(items).header('x-total-count', String(total)); +}); + +server.route<{ Query: { page?: string } }>({ + method: 'GET', + path: '/todos', + handler: (request, h) => { + const page = Number(request.query.page ?? 1); + const todos = request.server.todos.list(); + return h.paginated(todos.slice((page - 1) * 20, page * 20), todos.length); + }, +}); +``` + +`h.paginated()`는 항목 한 페이지를 `x-total-count` 헤더와 함께 보내므로, 클라이언트는 모든 목록 라우트에서 같은 방식으로 전체 개수를 읽습니다. 이 헤더나 에러 응답 형식처럼 API에서 반복되는 응답 규칙은 툴킷 데코레이션으로 두기 좋습니다. hapi가 반환값을 바이트로 바꾸는 과정은 [응답 마샬링](/tutorials/ko_KR/response-marshalling)에서 다룹니다. + +## 핸들러 데코레이션: 사용자 정의 핸들러 유형 + +핸들러 데코레이션을 사용하면 라우트가 함수를 작성하는 대신 `handler: { file: 'index.html' }`처럼 객체로 핸들러를 기술할 수 있습니다. 여러 라우트가 같은 동작을 공유하고 설정만 다를 때 사용하세요. + +데코레이션은 `(route, options)` 함수이며, hapi는 라우트가 추가될 때 라우트마다 한 번 이 함수를 호출합니다. 이 함수가 실제 핸들러를 반환합니다. 다음 예는 예전 URL을 리다이렉트합니다. + +```ts +import type { HandlerDecorationMethod } from '@hapi/hapi'; + +interface MovedOptions { + to: string; + permanent?: boolean; +} + +declare module '@hapi/hapi' { + interface HandlerDecorations { + moved?: MovedOptions; + } +} + +const moved: HandlerDecorationMethod = (route, options: MovedOptions) => { + return (request, h) => h.redirect(options.to).permanent(options.permanent ?? false); +}; + +moved.defaults = { auth: false }; + +server.decorate('handler', 'moved', moved); + +server.route({ method: 'GET', path: '/old-docs', handler: { moved: { to: '/docs', permanent: true } } }); +server.route({ method: 'GET', path: '/blog/{slug}', handler: { moved: { to: '/articles' } } }); +``` + +선택 속성인 `defaults`는 이 핸들러를 사용하는 모든 라우트의 라우트 옵션을 설정합니다. 여기서는 리다이렉트에 인증이 필요 없게 합니다. `defaults`는 HTTP 메서드를 받는 함수일 수도 있습니다. h2o2는 이 방식으로 `{ proxy }` 라우트가 페이로드 파싱을 건너뛰고 본문을 업스트림 서버로 스트리밍하게 합니다. + +hapi는 라우트를 추가할 때 핸들러를 만들기 때문에, 핸들러 데코레이션은 어떤 라우트가 사용하기 전에 존재해야 합니다. 이를 정의하는 플러그인을 먼저 등록하세요. + +## 응답 데코레이션 + +`'response'` 데코레이션은 에러 응답과 `request.generateResponse()`로 만든 응답을 포함해 모든 응답 객체에 메서드를 추가합니다. 필요한 경우는 드뭅니다. 앱 전체에서 응답을 바꾸려면 `onPreResponse` [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks)을 사용하세요. + +## 기존 데코레이션 감싸기 + +`{ extend: true }`는 기존 데코레이션을 감싼 버전으로 교체합니다. 함수는 현재 데코레이션을 받아 새 데코레이션을 반환합니다. 다른 플러그인의 코드를 바꾸지 않고 그 데코레이션에 데이터를 추가할 때 흔히 씁니다. 다음 예는 vision의 `h.view()`를 감싸서 모든 템플릿이 로그인한 사용자를 받게 합니다. + +```ts +server.decorate( + 'toolkit', + 'view', + (existing) => { + return function (template: string, context: object = {}, options?: object) { + const user = this.request.auth.credentials?.user; + return existing.call(this, template, { user, ...context }, options); + }; + }, + { extend: true }, +); +``` + +데코레이션을 정의하는 플러그인을 먼저 등록해야 합니다. 아직 없는 데코레이션을 확장하면 에러가 발생합니다. 핸들러 데코레이션은 확장할 수 없습니다. + +## hapi가 강제하는 규칙 + +데코레이션은 추가한 플러그인이 아니라 서버 전체에 속합니다. 플러그인 안에서 추가한 데코레이션은 루트 서버와 다른 모든 플러그인에서도 보입니다. 데코레이션이나 라우트가 다음 규칙 중 하나를 어기면 hapi는 즉시 에러를 던집니다. + +| 실수 | 에러 | +| ---------------------------------------------------- | ----------------------------------------------------------------- | +| 두 플러그인이 같은 이름을 데코레이트함 | `Server decoration already defined: todos` | +| 이름이 밑줄로 시작함 | `Property name cannot begin with an underscore: _cache` | +| 이름이 내장 속성임 | `Cannot override the built-in request interface decoration: auth` | +| 라우트가 아무도 데코레이트하지 않은 핸들러 유형을 씀 | `Unknown handler: moved` | +| 존재하지 않는 이름에 `extend: true`를 씀 | `Cannot extend missing toolkit decoration: view` | + +이름은 전역이므로, 재사용할 플러그인의 데코레이션에는 구체적인 이름을 붙이거나, @hapi/cookie의 `requestDecoratorName` 옵션처럼 사용자가 이름을 정할 수 있게 하세요. 이미 사용 중인 이름은 [`server.decorations`](/api#server-decorations)에서 볼 수 있습니다. + +서버 데코레이션은 각 플러그인의 `server` 객체로 복사되므로, 데코레이션 안에서 `this`는 그 데코레이션을 호출한 플러그인의 서버입니다. vision은 이 점을 활용합니다. `server.render()`는 호출한 플러그인에 설정된 뷰를 찾으므로, 두 플러그인이 각자의 템플릿을 가질 수 있습니다. + +## 데코레이션으로 부트스트랩하기 + +플러그인은 보통 `register()` 안에서 데코레이트하므로, `server.register()`가 끝나는 즉시 데코레이션이 존재합니다. 다른 플러그인의 데코레이션을 핸들러에서만 사용하는 플러그인은 등록 순서가 상관없습니다. 핸들러는 서버가 시작된 뒤에 실행되기 때문입니다. 반면 자신의 `register()` 안에서 사용하는 플러그인은 그 데코레이션을 추가하는 플러그인 다음에 등록해야 합니다. + +- 플러그인의 `dependencies: ['todo-store']` 속성은 [`server.initialize()`](/api#await-server-initialize)나 `server.start()` 시점에 다른 플러그인이 등록되었는지 hapi가 확인하게 할 뿐입니다. 플러그인이 등록되는 순서를 바꾸지는 않습니다. +- 캐시 예열이나 테이블 생성처럼 서버가 부팅되는 동안 다른 플러그인의 데코레이션을 사용하는 코드는 [`server.dependency()`](/api#server-dependency-dependencies-after) 콜백에 두어야 합니다. hapi는 등록 순서와 관계없이, 의존하는 플러그인 다음에 `onPreStart` 단계에서 이 콜백을 실행합니다. + +```ts +import type { Plugin } from '@hapi/hapi'; + +const seedPlugin: Plugin = { + name: 'todo-seed', + register: (server) => { + server.dependency('todo-store', async (server) => { + server.todos.add('Read the decorators tutorial'); + }); + }, +}; +``` + +전체 시작 순서는 플러그인 튜토리얼의 [의존성](/tutorials/ko_KR/plugins#dependencies)과 [서버 훅](/tutorials/ko_KR/lifecycle-hooks#server-hooks)에 나와 있습니다. + +## TypeScript + +hapi는 데코레이트할 수 있는 객체마다 인터페이스를 내보냅니다. `server.decorate()` 호출과 같은 파일에서 `declare module '@hapi/hapi'` 블록으로 데코레이션을 인터페이스에 추가하면, 타입과 코드가 함께 유지됩니다. + +| `type` | 확장할 인터페이스 | 예 | +| ----------- | -------------------- | --------------------------------------------------------------------------------------------- | +| `'server'` | `Server` | `interface Server { todos: TodoStore }` | +| `'request'` | `Request` | `interface Request { loadAccount: () => Promise }` | +| `'toolkit'` | `ResponseToolkit` | `interface ResponseToolkit { paginated: (items: object[], total: number) => ResponseObject }` | +| `'handler'` | `HandlerDecorations` | `interface HandlerDecorations { moved?: MovedOptions }` | + +핸들러 데코레이션 속성은 선택 속성이어야 합니다. 그렇지 않으면 모든 라우트가 이를 설정해야 합니다. hapi 자체 타입도 앱별 데이터에 같은 방식을 씁니다. `ServerApplicationState`는 `server.app`, `RequestApplicationState`는 `request.app`, `PluginProperties`는 `server.plugins`, `PluginsStates`는 `request.plugins`의 타입을 지정합니다. + +이 데코레이션들을 플러그인으로 묶는 방법은 [플러그인 튜토리얼](/tutorials/ko_KR/plugins)을, `server.decorate()`의 전체 시그니처는 [API 레퍼런스](/api#server-decorate-type-property-method-options)를 참고하세요. diff --git a/docs/tutorials/pt_BR/decorators.md b/docs/tutorials/pt_BR/decorators.md new file mode 100644 index 00000000..ec716a8e --- /dev/null +++ b/docs/tutorials/pt_BR/decorators.md @@ -0,0 +1,335 @@ +--- +title: Decorators +--- + + + +# Decorators + +_Este tutorial é compatível com hapi v21 e versões mais recentes_ + +## Visão geral + +Uma decoração adiciona uma propriedade sua a um dos objetos do hapi. Depois de decorar, qualquer parte da sua aplicação pode usar a nova propriedade: `server.todos` em um plugin, `request.loadAccount()` em um handler, `h.paginated()` ao montar uma resposta. + +A maioria dos plugins do hapi é construída sobre decorações. Quando você registra o `@hapi/vision`, `h.view()` existe porque o vision decorou o response toolkit. Quando você registra o `@hapi/inert`, `handler: { file: 'index.html' }` funciona porque o inert decorou os handlers de rota. + +Você adiciona decorações com [`server.decorate(type, property, method, [options])`](/api#server-decorate-type-property-method-options). `type` escolhe o objeto ao qual você está adicionando: + +| `type` | Adiciona a | Exemplos reais | +| ------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `'server'` | o objeto `server` | `server.views()` e `server.render()` do vision, `server.publish()` do nes, `server.yar` do yar | +| `'request'` | todo objeto `request` | `request.render()` do vision, `request.cookieAuth` do @hapi/cookie, `request.yar` do yar, `request.socket` do nes | +| `'toolkit'` | o response toolkit `h` | `h.view()` do vision, `h.file()` do inert, `h.proxy()` do h2o2 | +| `'handler'` | os tipos de handler que uma rota pode usar | `{ view }` do vision, `{ file }` e `{ directory }` do inert, `{ proxy }` do h2o2 | +| `'response'` | todo objeto de resposta | nenhum dos plugins do hapi acima; raramente necessário | + +Decorações muitas vezes trabalham junto com [lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks): o @hapi/cookie, por exemplo, adiciona `request.cookieAuth` com uma decoração e o preenche com um hook `onPreAuth`. + +## Decorações de server: compartilhando um serviço + +A decoração mais comum compartilha um objeto com a aplicação inteira, como um cliente de banco de dados, um serviço de e-mail ou um serviço de domínio. Um plugin o cria uma vez e decora o servidor com ele. Os handlers o acessam por `request.server`, e outros plugins pelo seu próprio argumento `server`. + +```ts +import Hapi from '@hapi/hapi'; + +import type { Plugin } from '@hapi/hapi'; + +interface Todo { + id: number; + title: string; +} + +class TodoStore { + #todos = new Map(); + + add(title: string) { + const todo = { id: this.#todos.size + 1, title }; + this.#todos.set(todo.id, todo); + return todo; + } + + list() { + return [...this.#todos.values()]; + } +} + +declare module '@hapi/hapi' { + interface Server { + todos: TodoStore; + } +} + +const storePlugin: Plugin = { + name: 'todo-store', + register: (server) => { + server.decorate('server', 'todos', new TodoStore()); + }, +}; + +const routesPlugin: Plugin = { + name: 'todo-routes', + dependencies: ['todo-store'], + register: (server) => { + server.route({ + method: 'GET', + path: '/todos', + handler: (request, h) => request.server.todos.list(), + }); + + server.route<{ Payload: { title: string } }>({ + method: 'POST', + path: '/todos', + handler: (request, h) => request.server.todos.add(request.payload.title), + }); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register([storePlugin, routesPlugin]); +await server.start(); +``` + +O bloco `declare module` informa o TypeScript sobre a nova propriedade. O hapi exporta seus objetos como interfaces que você pode estender, e depois disso `server.todos` e `request.server.todos` ficam tipados em todo lugar. A [seção de TypeScript](#typescript) lista a interface de cada tipo de decoração. + +### Onde um valor deve ficar? + +O hapi tem vários lugares para guardar valores compartilhados, e uma decoração é só um deles: + +| O valor é | Guarde em | Exemplo | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | ------------------------------ | +| um serviço com métodos que rotas e plugins chamam | uma decoração de server | `server.todos` | +| uma função cujos resultados valem a pena guardar em cache por argumentos | um [método de servidor](/tutorials/pt_BR/server-methods) | `server.methods.getWeather()` | +| configuração ou estado da aplicação, lido e escrito por qualquer código | [`server.app`](/api#server-app) | `server.app.config` | +| estado de plugin que outros plugins só leem de vez em quando | [`server.expose()`](/api#server-expose-key-value-options), lido como `server.plugins[name]` | `server.plugins['todo-store']` | +| um helper que lê a requisição atual | uma decoração de request | `request.clientIp()` | +| estado por requisição criado no primeiro uso | uma decoração de request com `apply: true` | `request.loadAccount()` | +| dados por requisição que um hook ou handler define | [`request.app`](/api#request-app), ou [`request.plugins`](/api#request-plugins) em um plugin | um id definido em `onRequest` | + +Uma decoração de server é a escolha certa quando o valor é um serviço com métodos, todas as rotas precisam dele e você quer um nome curto e tipado para ele. + +## Decorações de request + +Uma decoração de request adiciona uma propriedade a toda requisição. Existem dois tipos, e a diferença está em quando o valor é criado. + +### Métodos compartilhados por todas as requisições + +Sem opções, a decoração é colocada no prototype de request uma única vez, e todas as requisições a compartilham. Use uma `function` comum para que `this` seja a requisição atual: + +```ts +declare module '@hapi/hapi' { + interface Request { + clientIp: () => string; + } +} + +server.decorate('request', 'clientIp', function () { + const forwarded = this.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',').at(-1)?.trim() ?? this.info.remoteAddress; + } + + return this.info.remoteAddress; +}); + +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + return { ip: request.clientIp() }; + }, +}); +``` + +Isso lê a última entrada de `x-forwarded-for`, que é o endereço visto por um único proxy sob seu controle que adiciona entradas ao header, como o nginx com `$proxy_add_x_forwarded_for` ou um load balancer da AWS. As entradas anteriores vêm do cliente e podem ser forjadas. Com mais proxies confiáveis na frente, volte uma entrada por proxy; sem nenhum, ignore o header, já que qualquer cliente pode enviá-lo. + +### Valores por requisição com `apply: true` + +Com `{ apply: true }`, o hapi chama sua função uma vez para cada nova requisição, passando a requisição como argumento, e atribui o valor retornado à propriedade. Use quando cada requisição precisa do seu próprio estado, como uma busca em cache: + +```ts +interface Account { + id: string; + name: string; +} + +declare module '@hapi/hapi' { + interface Request { + loadAccount: () => Promise; + } +} + +const accounts = new Map([['2133d32a', { id: '2133d32a', name: 'John Doe' }]]); + +const findAccount = async (id: string) => accounts.get(id) ?? null; + +server.decorate( + 'request', + 'loadAccount', + (request) => { + let pending: Promise | undefined; + + return () => { + const id = request.headers['x-account-id']; + pending ??= typeof id === 'string' ? findAccount(id) : Promise.resolve(null); + return pending; + }; + }, + { apply: true }, +); +``` + +Um pre-handler, um hook `onPostAuth` e o handler podem todos chamar `request.loadAccount()`, e a conta é buscada uma vez por requisição. Sem `apply`, a decoração é uma única função compartilhada por todas as requisições, então não existe um lugar por requisição para guardar `pending`; uma variável fora da função vazaria a conta da primeira requisição para todas as outras. + +| | Sem opções | Com `apply: true` | +| --------------------- | ---------------------------------------- | --------------------------------------------------------------- | +| Criado | uma vez, no prototype de request | uma vez por requisição, quando o objeto de requisição é montado | +| Estado por requisição | nenhum, use `this` para ler a requisição | sim, guardado na closure | +| Custo por requisição | nenhum | sua função roda em toda requisição | +| Exemplo real | `request.render()` do vision | `request.cookieAuth` do @hapi/cookie, `request.yar` do yar | + +Funções com `apply` rodam em toda requisição, inclusive em requisições para rotas que nunca usam o valor. Mantenha-as baratas: crie a closure e faça o trabalho na primeira vez que o valor for usado, como `loadAccount()` faz. + +## Decorações de toolkit: helpers de resposta + +Uma decoração de toolkit adiciona um método a `h`. Use uma `function` comum: `this` é o toolkit, então `this.response()` monta uma resposta e `this.request` é a requisição atual. Esta dá a todas as rotas de listagem a mesma convenção de paginação: + +```ts +import type { ResponseObject } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + paginated: (items: object[], total: number) => ResponseObject; + } +} + +server.decorate('toolkit', 'paginated', function (items: object[], total: number) { + return this.response(items).header('x-total-count', String(total)); +}); + +server.route<{ Query: { page?: string } }>({ + method: 'GET', + path: '/todos', + handler: (request, h) => { + const page = Number(request.query.page ?? 1); + const todos = request.server.todos.list(); + return h.paginated(todos.slice((page - 1) * 20, page * 20), todos.length); + }, +}); +``` + +`h.paginated()` envia uma página de itens com um header `x-total-count`, então os clientes leem o total da mesma forma em todas as rotas de listagem. Decorações de toolkit são um bom lugar para convenções de resposta que sua API repete, como esse header ou um envelope de erro. Como o hapi transforma o valor retornado em bytes é assunto de [Marshalling de respostas](/tutorials/pt_BR/response-marshalling). + +## Decorações de handler: tipos de handler customizados + +Uma decoração de handler permite que as rotas descrevam seu handler como um objeto em vez de escrever uma função, como `handler: { file: 'index.html' }`. Use quando muitas rotas compartilham o mesmo comportamento e só mudam a configuração. + +A decoração é uma função `(route, options)` que o hapi chama uma vez por rota, quando a rota é adicionada. Ela retorna o handler de fato. Esta redireciona URLs antigas: + +```ts +import type { HandlerDecorationMethod } from '@hapi/hapi'; + +interface MovedOptions { + to: string; + permanent?: boolean; +} + +declare module '@hapi/hapi' { + interface HandlerDecorations { + moved?: MovedOptions; + } +} + +const moved: HandlerDecorationMethod = (route, options: MovedOptions) => { + return (request, h) => h.redirect(options.to).permanent(options.permanent ?? false); +}; + +moved.defaults = { auth: false }; + +server.decorate('handler', 'moved', moved); + +server.route({ method: 'GET', path: '/old-docs', handler: { moved: { to: '/docs', permanent: true } } }); +server.route({ method: 'GET', path: '/blog/{slug}', handler: { moved: { to: '/articles' } } }); +``` + +A propriedade opcional `defaults` define opções de rota para todas as rotas que usam o handler; aqui, redirecionamentos nunca exigem autenticação. `defaults` também pode ser uma função do método HTTP: o h2o2 usa uma para que as rotas `{ proxy }` pulem o parsing do payload e repassem o corpo em stream para o servidor upstream. + +Como o hapi monta o handler quando a rota é adicionada, a decoração de handler precisa existir antes de qualquer rota usá-la. Registre primeiro o plugin que a define. + +## Decorações de response + +Decorações `'response'` adicionam um método a todo objeto de resposta, inclusive respostas de erro e as montadas com `request.generateResponse()`. Raramente são necessárias. Para alterar respostas na aplicação inteira, use um [lifecycle hook](/tutorials/pt_BR/lifecycle-hooks) `onPreResponse`. + +## Envolvendo uma decoração existente + +`{ extend: true }` substitui uma decoração existente por uma versão que a envolve. Sua função recebe a decoração atual e retorna a nova. Um uso comum é adicionar dados à decoração de outro plugin sem mudar o código dele. Este exemplo envolve o `h.view()` do vision para que todo template receba o usuário logado: + +```ts +server.decorate( + 'toolkit', + 'view', + (existing) => { + return function (template: string, context: object = {}, options?: object) { + const user = this.request.auth.credentials?.user; + return existing.call(this, template, { user, ...context }, options); + }; + }, + { extend: true }, +); +``` + +O plugin que define a decoração precisa ser registrado primeiro; estender uma decoração que ainda não existe lança um erro. Decorações de handler não podem ser estendidas. + +## Regras que o hapi impõe + +As decorações pertencem ao servidor inteiro, não ao plugin que as adicionou. Uma decoração adicionada dentro de um plugin fica visível para o servidor raiz e para todos os outros plugins. O hapi lança um erro assim que uma decoração ou uma rota quebra uma destas regras: + +| Erro cometido | Mensagem | +| --------------------------------------------------- | ----------------------------------------------------------------- | +| Dois plugins decoram o mesmo nome | `Server decoration already defined: todos` | +| O nome começa com underscore | `Property name cannot begin with an underscore: _cache` | +| O nome é uma propriedade nativa | `Cannot override the built-in request interface decoration: auth` | +| Uma rota usa um tipo de handler que ninguém decorou | `Unknown handler: moved` | +| `extend: true` em um nome que não existe | `Cannot extend missing toolkit decoration: view` | + +Como os nomes são globais, dê às decorações de plugins reutilizáveis um nome específico, ou deixe o usuário configurá-lo, como o @hapi/cookie faz com sua opção `requestDecoratorName`. [`server.decorations`](/api#server-decorations) lista os nomes já em uso. + +As decorações de server são copiadas para o objeto `server` de cada plugin, então dentro de uma decoração `this` é o servidor do plugin que a chama. O vision depende disso: `server.render()` busca as views configuradas no plugin de quem chama, então dois plugins podem ter, cada um, seus próprios templates. + +## Inicialização com decorações + +Os plugins normalmente decoram dentro de `register()`, então a decoração existe assim que `server.register()` resolve. Um plugin que usa a decoração de outro plugin só nos handlers pode ser registrado em qualquer ordem, já que os handlers rodam depois que o servidor inicia. Um que a usa dentro do seu próprio `register()` precisa ser registrado depois do plugin que a adiciona. + +- A propriedade `dependencies: ['todo-store']` de um plugin só faz o hapi verificar, em [`server.initialize()`](/api#await-server-initialize) ou `server.start()`, que o outro plugin foi registrado. Ela não muda a ordem em que os plugins são registrados. +- Código que usa a decoração de outro plugin enquanto o servidor inicializa, como aquecer um cache ou criar tabelas, deve ficar em um callback de [`server.dependency()`](/api#server-dependency-dependencies-after). O hapi o executa na fase `onPreStart`, depois dos plugins dos quais ele depende, independentemente da ordem de registro: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const seedPlugin: Plugin = { + name: 'todo-seed', + register: (server) => { + server.dependency('todo-store', async (server) => { + server.todos.add('Read the decorators tutorial'); + }); + }, +}; +``` + +A ordem completa de inicialização está em [Dependências](/tutorials/pt_BR/plugins#dependencies), no tutorial Plugins, e em [Server hooks](/tutorials/pt_BR/lifecycle-hooks#server-hooks). + +## TypeScript + +O hapi exporta uma interface para cada objeto que você pode decorar. Adicione sua decoração a ela com um bloco `declare module '@hapi/hapi'`, no mesmo arquivo da chamada a `server.decorate()`, para que o tipo e o código fiquem juntos: + +| `type` | Interface a estender | Exemplo | +| ----------- | -------------------- | --------------------------------------------------------------------------------------------- | +| `'server'` | `Server` | `interface Server { todos: TodoStore }` | +| `'request'` | `Request` | `interface Request { loadAccount: () => Promise }` | +| `'toolkit'` | `ResponseToolkit` | `interface ResponseToolkit { paginated: (items: object[], total: number) => ResponseObject }` | +| `'handler'` | `HandlerDecorations` | `interface HandlerDecorations { moved?: MovedOptions }` | + +As propriedades de decorações de handler precisam ser opcionais, senão toda rota teria que defini-las. Os próprios tipos do hapi usam o mesmo mecanismo para outros dados específicos da aplicação: `ServerApplicationState` tipa `server.app`, `RequestApplicationState` tipa `request.app`, `PluginProperties` tipa `server.plugins` e `PluginsStates` tipa `request.plugins`. + +Veja o tutorial [Plugins](/tutorials/pt_BR/plugins) para empacotar essas decorações como um plugin, e a [referência da API](/api#server-decorate-type-property-method-options) para a assinatura completa de `server.decorate()`. diff --git a/docs/tutorials/tr_TR/decorators.md b/docs/tutorials/tr_TR/decorators.md new file mode 100644 index 00000000..0df3ea0b --- /dev/null +++ b/docs/tutorials/tr_TR/decorators.md @@ -0,0 +1,335 @@ +--- +title: Decorator'lar +--- + + + +# Decorator'lar + +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ + +## Genel Bakış + +Bir decoration, hapi'nin nesnelerinden birine kendi özelliğini ekler. Decorate ettiğin anda uygulamanın her parçası yeni özelliği kullanabilir: bir plugin'de `server.todos`, bir handler'da `request.loadAccount()`, bir yanıt oluştururken `h.paginated()`. + +hapi plugin'lerinin çoğu decoration'lar üzerine kuruludur. `@hapi/vision` kaydettiğinde `h.view()` vardır, çünkü vision response toolkit'i decorate etmiştir. `@hapi/inert` kaydettiğinde `handler: { file: 'index.html' }` çalışır, çünkü inert route handler'larını decorate etmiştir. + +Decoration'ları [`server.decorate(type, property, method, [options])`](/api#server-decorate-type-property-method-options) ile eklersin. `type`, ekleme yaptığın nesneyi seçer: + +| `type` | Nereye ekler | Gerçek örnekler | +| ------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `'server'` | `server` nesnesine | vision'ın `server.views()` ve `server.render()` metotları, nes'in `server.publish()`, yar'ın `server.yar` | +| `'request'` | her `request` nesnesine | vision'ın `request.render()`, @hapi/cookie'nin `request.cookieAuth`, yar'ın `request.yar`, nes'in `request.socket` | +| `'toolkit'` | response toolkit `h` nesnesine | vision'ın `h.view()`, inert'in `h.file()`, h2o2'nun `h.proxy()` | +| `'handler'` | bir route'un kullanabileceği handler tiplerine | vision'ın `{ view }`, inert'in `{ file }` ve `{ directory }`, h2o2'nun `{ proxy }` | +| `'response'` | her response nesnesine | yukarıdaki hapi plugin'lerinin hiçbiri; nadiren gerekir | + +Decoration'lar çoğu zaman [yaşam döngüsü hook'larıyla](/tutorials/tr_TR/lifecycle-hooks) birlikte çalışır: örneğin @hapi/cookie, `request.cookieAuth` özelliğini bir decoration ile ekler ve bir `onPreAuth` hook'uyla doldurur. + +## Sunucu decoration'ları: bir servisi paylaşmak + +En yaygın decoration, bir veritabanı client'ı, bir e-posta gönderici ya da bir servis gibi tek bir nesneyi tüm uygulamayla paylaşır. Bir plugin onu bir kez oluşturur ve sunucuyu onunla decorate eder. Handler'lar ona `request.server` üzerinden, diğer plugin'ler de kendi `server` argümanları üzerinden erişir. + +```ts +import Hapi from '@hapi/hapi'; + +import type { Plugin } from '@hapi/hapi'; + +interface Todo { + id: number; + title: string; +} + +class TodoStore { + #todos = new Map(); + + add(title: string) { + const todo = { id: this.#todos.size + 1, title }; + this.#todos.set(todo.id, todo); + return todo; + } + + list() { + return [...this.#todos.values()]; + } +} + +declare module '@hapi/hapi' { + interface Server { + todos: TodoStore; + } +} + +const storePlugin: Plugin = { + name: 'todo-store', + register: (server) => { + server.decorate('server', 'todos', new TodoStore()); + }, +}; + +const routesPlugin: Plugin = { + name: 'todo-routes', + dependencies: ['todo-store'], + register: (server) => { + server.route({ + method: 'GET', + path: '/todos', + handler: (request, h) => request.server.todos.list(), + }); + + server.route<{ Payload: { title: string } }>({ + method: 'POST', + path: '/todos', + handler: (request, h) => request.server.todos.add(request.payload.title), + }); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register([storePlugin, routesPlugin]); +await server.start(); +``` + +`declare module` bloğu TypeScript'e yeni özelliği bildirir. hapi nesnelerini genişletebileceğin arayüzler olarak dışa aktarır ve bundan sonra `server.todos` ile `request.server.todos` her yerde tiplenmiş olur. [TypeScript bölümü](#typescript) her decoration tipi için ilgili arayüzü listeler. + +### Bir değer nerede tutulmalı? + +hapi'de paylaşılan değerleri tutmak için birkaç yer vardır ve decoration bunlardan yalnızca biridir: + +| Değer | Nerede tutulmalı | Örnek | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------- | +| route'ların ve plugin'lerin çağırdığı metotlara sahip bir servis | bir sunucu decoration'ı | `server.todos` | +| sonuçlarını argümanlara göre önbelleğe almaya değer bir fonksiyon | bir [server method](/tutorials/tr_TR/server-methods) | `server.methods.getWeather()` | +| herhangi bir kodun okuyup yazdığı, uygulama genelinde yapılandırma veya durum | [`server.app`](/api#server-app) | `server.app.config` | +| diğer plugin'lerin yalnızca ara sıra okuduğu plugin durumu | [`server.expose()`](/api#server-expose-key-value-options), `server.plugins[name]` olarak okunur | `server.plugins['todo-store']` | +| mevcut isteği okuyan bir yardımcı | bir request decoration'ı | `request.clientIp()` | +| ilk kullanımda oluşturulan istek başına durum | `apply: true` ile bir request decoration'ı | `request.loadAccount()` | +| bir hook'un ya da handler'ın ayarladığı istek başına veri | [`request.app`](/api#request-app), plugin içinde ise [`request.plugins`](/api#request-plugins) | `onRequest` içinde ayarlanan bir id | + +Değer metotlara sahip bir servis olduğunda, her route ona ihtiyaç duyduğunda ve ona kısa, tiplenmiş bir ad vermek istediğinde doğru seçim bir sunucu decoration'ıdır. + +## Request decoration'ları + +Bir request decoration'ı her isteğe bir özellik ekler. İki türü vardır ve aralarındaki fark, değerin ne zaman oluşturulduğudur. + +### Tüm isteklerin paylaştığı metotlar + +Seçenek verilmezse decoration request prototype'ına bir kez konur ve her istek onu paylaşır. `this` mevcut istek olsun diye normal bir `function` kullan: + +```ts +declare module '@hapi/hapi' { + interface Request { + clientIp: () => string; + } +} + +server.decorate('request', 'clientIp', function () { + const forwarded = this.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',').at(-1)?.trim() ?? this.info.remoteAddress; + } + + return this.info.remoteAddress; +}); + +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + return { ip: request.clientIp() }; + }, +}); +``` + +Bu kod `x-forwarded-for` header'ının son girdisini okur. Bu girdi, header'a ekleme yapan ve senin denetlediğin tek bir proxy'nin gördüğü adrestir; örneğin `$proxy_add_x_forwarded_for` ile nginx ya da bir AWS load balancer. Ondan önceki girdiler istemciden gelir ve sahte olabilir. Önünde daha fazla güvenilir proxy varsa her proxy için bir girdi geriye say; hiç yoksa header'ı yok say, çünkü herhangi bir istemci onu gönderebilir. + +### `apply: true` ile istek başına değerler + +`{ apply: true }` ile hapi, her yeni istek için fonksiyonunu isteği argüman olarak vererek bir kez çağırır ve dönüş değerini özelliğe atar. Her isteğin önbelleğe alınmış bir arama sonucu gibi kendi durumuna ihtiyaç duyduğu durumlarda bunu kullan: + +```ts +interface Account { + id: string; + name: string; +} + +declare module '@hapi/hapi' { + interface Request { + loadAccount: () => Promise; + } +} + +const accounts = new Map([['2133d32a', { id: '2133d32a', name: 'John Doe' }]]); + +const findAccount = async (id: string) => accounts.get(id) ?? null; + +server.decorate( + 'request', + 'loadAccount', + (request) => { + let pending: Promise | undefined; + + return () => { + const id = request.headers['x-account-id']; + pending ??= typeof id === 'string' ? findAccount(id) : Promise.resolve(null); + return pending; + }; + }, + { apply: true }, +); +``` + +Bir pre-handler, bir `onPostAuth` hook'u ve handler'ın hepsi `request.loadAccount()` çağırabilir; hesap istek başına bir kez aranır. `apply` olmadan decoration tüm isteklerin paylaştığı tek bir fonksiyondur, yani `pending` değerini tutacak istek başına bir yer yoktur; fonksiyonun dışındaki bir değişken ilk isteğin hesabını diğer tüm isteklere sızdırırdı. + +| | Seçeneksiz | `apply: true` ile | +| -------------------- | ------------------------------------- | ----------------------------------------------------------- | +| Oluşturulma | bir kez, request prototype'ında | istek başına bir kez, request nesnesi oluşturulurken | +| İstek başına durum | yok, isteği okumak için `this` kullan | var, closure içinde tutulur | +| İstek başına maliyet | yok | fonksiyonun her istekte çalışır | +| Gerçek örnek | vision'ın `request.render()` | @hapi/cookie'nin `request.cookieAuth`, yar'ın `request.yar` | + +`apply` fonksiyonları, değeri hiç kullanmayan route'lara gelenler de dahil her istekte çalışır. Bunları ucuz tut: closure'ı oluştur ve işi, `loadAccount()` örneğinde olduğu gibi, değer ilk kez kullanıldığında yap. + +## Toolkit decoration'ları: yanıt yardımcıları + +Bir toolkit decoration'ı `h` nesnesine bir metot ekler. Normal bir `function` kullan: `this` toolkit'in kendisidir, yani `this.response()` bir yanıt oluşturur ve `this.request` mevcut istektir. Bu örnek, her liste route'una aynı sayfalama kuralını verir: + +```ts +import type { ResponseObject } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + paginated: (items: object[], total: number) => ResponseObject; + } +} + +server.decorate('toolkit', 'paginated', function (items: object[], total: number) { + return this.response(items).header('x-total-count', String(total)); +}); + +server.route<{ Query: { page?: string } }>({ + method: 'GET', + path: '/todos', + handler: (request, h) => { + const page = Number(request.query.page ?? 1); + const todos = request.server.todos.list(); + return h.paginated(todos.slice((page - 1) * 20, page * 20), todos.length); + }, +}); +``` + +`h.paginated()` bir sayfa öğeyi `x-total-count` header'ıyla gönderir; böylece istemciler toplam sayıyı her liste route'unda aynı şekilde okur. Toolkit decoration'ları, bu header ya da bir hata zarfı gibi API'nin tekrarladığı yanıt kuralları için iyi bir yerdir. hapi'nin döndürülen değeri nasıl baytlara dönüştürdüğü [Yanıt Hazırlama](/tutorials/tr_TR/response-marshalling) eğitiminde anlatılıyor. + +## Handler decoration'ları: özel handler tipleri + +Bir handler decoration'ı, route'ların bir fonksiyon yazmak yerine handler'larını `handler: { file: 'index.html' }` gibi bir nesneyle tanımlamasını sağlar. Birçok route aynı davranışı paylaşıp yalnızca yapılandırmada ayrıştığında bunu kullan. + +Decoration, hapi'nin route eklenirken her route için bir kez çağırdığı `(route, options)` fonksiyonudur. Asıl handler'ı döndürür. Bu örnek eski URL'leri yönlendirir: + +```ts +import type { HandlerDecorationMethod } from '@hapi/hapi'; + +interface MovedOptions { + to: string; + permanent?: boolean; +} + +declare module '@hapi/hapi' { + interface HandlerDecorations { + moved?: MovedOptions; + } +} + +const moved: HandlerDecorationMethod = (route, options: MovedOptions) => { + return (request, h) => h.redirect(options.to).permanent(options.permanent ?? false); +}; + +moved.defaults = { auth: false }; + +server.decorate('handler', 'moved', moved); + +server.route({ method: 'GET', path: '/old-docs', handler: { moved: { to: '/docs', permanent: true } } }); +server.route({ method: 'GET', path: '/blog/{slug}', handler: { moved: { to: '/articles' } } }); +``` + +İsteğe bağlı `defaults` özelliği, handler'ı kullanan her route için route seçeneklerini ayarlar; burada yönlendirmeler hiçbir zaman kimlik doğrulama gerektirmez. `defaults`, HTTP metoduna bağlı bir fonksiyon da olabilir: h2o2 bunu, `{ proxy }` route'larının payload ayrıştırmayı atlayıp gövdeyi upstream sunucuya stream etmesi için kullanır. + +hapi handler'ı route eklenirken oluşturduğu için handler decoration'ı, herhangi bir route onu kullanmadan önce var olmalıdır. Onu tanımlayan plugin'i önce kaydet. + +## Response decoration'ları + +`'response'` decoration'ları, hata yanıtları ve `request.generateResponse()` ile oluşturulanlar dahil her response nesnesine bir metot ekler. Nadiren gerekirler. Uygulama genelinde yanıtları değiştirmek için bir `onPreResponse` [yaşam döngüsü hook'u](/tutorials/tr_TR/lifecycle-hooks) kullan. + +## Mevcut bir decoration'ı sarmalamak + +`{ extend: true }`, mevcut bir decoration'ı sarmalanmış bir sürümüyle değiştirir. Fonksiyonun mevcut decoration'ı alır ve yenisini döndürür. Yaygın bir kullanım, başka bir plugin'in decoration'ına onun kodunu değiştirmeden veri eklemektir. Bu örnek vision'ın `h.view()` metodunu sarmalar, böylece her şablon oturum açmış kullanıcıyı alır: + +```ts +server.decorate( + 'toolkit', + 'view', + (existing) => { + return function (template: string, context: object = {}, options?: object) { + const user = this.request.auth.credentials?.user; + return existing.call(this, template, { user, ...context }, options); + }; + }, + { extend: true }, +); +``` + +Decoration'ı tanımlayan plugin önce kaydedilmelidir; henüz var olmayan bir decoration'ı genişletmek hata fırlatır. Handler decoration'ları genişletilemez. + +## hapi'nin uyguladığı kurallar + +Decoration'lar onları ekleyen plugin'e değil, sunucunun tamamına aittir. Bir plugin içinde eklenen decoration, kök sunucu ve diğer tüm plugin'ler tarafından görülür. Bir decoration ya da route bu kurallardan birini bozduğu anda hapi hata fırlatır: + +| Hata | Hata mesajı | +| ----------------------------------------------------------------- | ----------------------------------------------------------------- | +| İki plugin aynı adı decorate ediyor | `Server decoration already defined: todos` | +| Ad alt çizgiyle başlıyor | `Property name cannot begin with an underscore: _cache` | +| Ad yerleşik bir özellik | `Cannot override the built-in request interface decoration: auth` | +| Bir route, kimsenin decorate etmediği bir handler tipi kullanıyor | `Unknown handler: moved` | +| Var olmayan bir ad üzerinde `extend: true` | `Cannot extend missing toolkit decoration: view` | + +Adlar global olduğu için yeniden kullanılabilir plugin'lerin decoration'larına belirgin bir ad ver ya da @hapi/cookie'nin `requestDecoratorName` seçeneğiyle yaptığı gibi kullanıcıların adı yapılandırmasına izin ver. [`server.decorations`](/api#server-decorations) kullanımda olan adları listeler. + +Sunucu decoration'ları her plugin'in kendi `server` nesnesine kopyalanır, bu yüzden bir decoration içinde `this`, onu çağıran plugin'in sunucusudur. vision buna dayanır: `server.render()`, çağıranın plugin'inde yapılandırılmış view'ları arar; böylece iki plugin'in her biri kendi şablonlarına sahip olabilir. + +## Decoration'larla başlatma + +Plugin'ler genellikle `register()` içinde decorate eder, böylece decoration `server.register()` çözülür çözülmez var olur. Başka bir plugin'in decoration'ını yalnızca handler'larda kullanan bir plugin herhangi bir sırayla kaydedilebilir, çünkü handler'lar sunucu başladıktan sonra çalışır. Onu kendi `register()` fonksiyonu içinde kullanan bir plugin ise onu ekleyen plugin'den sonra kaydedilmelidir. + +- Bir plugin'in `dependencies: ['todo-store']` özelliği yalnızca hapi'nin [`server.initialize()`](/api#await-server-initialize) ya da `server.start()` sırasında diğer plugin'in kaydedildiğini denetlemesini sağlar. Plugin'lerin kaydedilme sırasını değiştirmez. +- Sunucu açılırken başka bir plugin'in decoration'ını kullanan kod (örneğin bir önbelleği ısıtmak ya da tablolar oluşturmak) bir [`server.dependency()`](/api#server-dependency-dependencies-after) callback'ine aittir. hapi bunu, kayıt sırasından bağımsız olarak bağlı olduğu plugin'lerden sonra, `onPreStart` aşamasında çalıştırır: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const seedPlugin: Plugin = { + name: 'todo-seed', + register: (server) => { + server.dependency('todo-store', async (server) => { + server.todos.add('Read the decorators tutorial'); + }); + }, +}; +``` + +Başlatma sırasının tamamı Plugin'ler eğitimindeki [Bağımlılıklar](/tutorials/tr_TR/plugins#dependencies) bölümünde ve [Sunucu hook'ları](/tutorials/tr_TR/lifecycle-hooks#server-hooks) bölümünde yer alır. + +## TypeScript + +hapi, decorate edebileceğin her nesne için bir arayüz dışa aktarır. Decoration'ını bu arayüze, `server.decorate()` çağrısıyla aynı dosyada bir `declare module '@hapi/hapi'` bloğuyla ekle; böylece tip ve kod bir arada kalır: + +| `type` | Genişletilecek arayüz | Örnek | +| ----------- | --------------------- | --------------------------------------------------------------------------------------------- | +| `'server'` | `Server` | `interface Server { todos: TodoStore }` | +| `'request'` | `Request` | `interface Request { loadAccount: () => Promise }` | +| `'toolkit'` | `ResponseToolkit` | `interface ResponseToolkit { paginated: (items: object[], total: number) => ResponseObject }` | +| `'handler'` | `HandlerDecorations` | `interface HandlerDecorations { moved?: MovedOptions }` | + +Handler decoration özellikleri isteğe bağlı olmalıdır, yoksa her route onları ayarlamak zorunda kalırdı. hapi'nin kendi tipleri, uygulamaya özgü diğer veriler için de aynı mekanizmayı kullanır: `ServerApplicationState` `server.app` tipini, `RequestApplicationState` `request.app` tipini, `PluginProperties` `server.plugins` tipini ve `PluginsStates` `request.plugins` tipini belirler. + +Bu decoration'ları bir plugin olarak paketlemek için [Plugin'ler](/tutorials/tr_TR/plugins) eğitimine, `server.decorate()` imzasının tamamı için [API referansına](/api#server-decorate-type-property-method-options) bak. diff --git a/docs/tutorials/zh_CN/decorators.md b/docs/tutorials/zh_CN/decorators.md new file mode 100644 index 00000000..afaa7ad0 --- /dev/null +++ b/docs/tutorials/zh_CN/decorators.md @@ -0,0 +1,335 @@ +--- +title: 装饰器 +--- + + + +# 装饰器 + +_本教程适用于 hapi v21 及更新版本_ + +## 概述 + +装饰(decoration)为 hapi 的某个对象添加你自己的属性。装饰之后,应用的任何部分都可以使用这个新属性:在插件中使用 `server.todos`,在 handler 中使用 `request.loadAccount()`,在构建响应时使用 `h.paginated()`。 + +大多数 hapi 插件都建立在装饰之上。注册 `@hapi/vision` 后,`h.view()` 之所以存在,是因为 vision 装饰了响应工具包。注册 `@hapi/inert` 后,`handler: { file: 'index.html' }` 之所以可用,是因为 inert 装饰了路由 handler。 + +使用 [`server.decorate(type, property, method, [options])`](/api#server-decorate-type-property-method-options) 添加装饰。`type` 决定添加到哪个对象上: + +| `type` | 添加到 | 实际示例 | +| ------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `'server'` | `server` 对象 | vision 的 `server.views()` 和 `server.render()`,nes 的 `server.publish()`,yar 的 `server.yar` | +| `'request'` | 每个 `request` 对象 | vision 的 `request.render()`,@hapi/cookie 的 `request.cookieAuth`,yar 的 `request.yar`,nes 的 `request.socket` | +| `'toolkit'` | 响应工具包 `h` | vision 的 `h.view()`,inert 的 `h.file()`,h2o2 的 `h.proxy()` | +| `'handler'` | 路由可以使用的 handler 类型 | vision 的 `{ view }`,inert 的 `{ file }` 和 `{ directory }`,h2o2 的 `{ proxy }` | +| `'response'` | 每个响应对象 | 上述 hapi 插件都没有使用;很少需要 | + +装饰常常与[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)配合使用:例如 @hapi/cookie 通过装饰添加 `request.cookieAuth`,再通过 `onPreAuth` 钩子为其填充内容。 + +## 服务器装饰:共享服务 + +最常见的装饰是与整个应用共享一个对象,例如数据库客户端、邮件发送器或某个服务。插件创建它一次,并用它装饰服务器。handler 通过 `request.server` 访问它,其他插件则通过自己的 `server` 参数访问。 + +```ts +import Hapi from '@hapi/hapi'; + +import type { Plugin } from '@hapi/hapi'; + +interface Todo { + id: number; + title: string; +} + +class TodoStore { + #todos = new Map(); + + add(title: string) { + const todo = { id: this.#todos.size + 1, title }; + this.#todos.set(todo.id, todo); + return todo; + } + + list() { + return [...this.#todos.values()]; + } +} + +declare module '@hapi/hapi' { + interface Server { + todos: TodoStore; + } +} + +const storePlugin: Plugin = { + name: 'todo-store', + register: (server) => { + server.decorate('server', 'todos', new TodoStore()); + }, +}; + +const routesPlugin: Plugin = { + name: 'todo-routes', + dependencies: ['todo-store'], + register: (server) => { + server.route({ + method: 'GET', + path: '/todos', + handler: (request, h) => request.server.todos.list(), + }); + + server.route<{ Payload: { title: string } }>({ + method: 'POST', + path: '/todos', + handler: (request, h) => request.server.todos.add(request.payload.title), + }); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register([storePlugin, routesPlugin]); +await server.start(); +``` + +`declare module` 代码块把这个新属性告诉 TypeScript。hapi 将其对象导出为可扩展的接口,扩展之后,`server.todos` 和 `request.server.todos` 在任何地方都有类型。[TypeScript 一节](#typescript)列出了每种装饰类型对应的接口。 + +### 值应该放在哪里? + +hapi 有多个存放共享值的地方,装饰只是其中之一: + +| 值的性质 | 存放位置 | 示例 | +| ------------------------------------ | ------------------------------------------------------------------------------------------- | ------------------------------ | +| 带有方法、供路由和插件调用的服务 | 服务器装饰 | `server.todos` | +| 结果值得按参数缓存的函数 | [服务器方法](/tutorials/zh_CN/server-methods) | `server.methods.getWeather()` | +| 应用级的配置或状态,任何代码都可读写 | [`server.app`](/api#server-app) | `server.app.config` | +| 其他插件只是偶尔读取的插件状态 | [`server.expose()`](/api#server-expose-key-value-options),通过 `server.plugins[name]` 读取 | `server.plugins['todo-store']` | +| 读取当前请求的辅助函数 | 请求装饰 | `request.clientIp()` | +| 首次使用时才创建的每请求状态 | 带 `apply: true` 的请求装饰 | `request.loadAccount()` | +| 由钩子或 handler 设置的每请求数据 | [`request.app`](/api#request-app),在插件中则用 [`request.plugins`](/api#request-plugins) | 在 `onRequest` 中设置的 id | + +当值是一个带有方法的服务、每个路由都需要它,并且你想给它一个简短且带类型的名称时,服务器装饰就是正确的选择。 + +## 请求装饰 + +请求装饰为每个请求添加一个属性。它分为两种,区别在于值在什么时候创建。 + +### 所有请求共享的方法 + +不带选项时,装饰只会被放到请求原型上一次,所有请求共享它。请使用普通的 `function`,这样 `this` 就是当前请求: + +```ts +declare module '@hapi/hapi' { + interface Request { + clientIp: () => string; + } +} + +server.decorate('request', 'clientIp', function () { + const forwarded = this.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',').at(-1)?.trim() ?? this.info.remoteAddress; + } + + return this.info.remoteAddress; +}); + +server.route({ + method: 'GET', + path: '/whoami', + handler: (request, h) => { + return { ip: request.clientIp() }; + }, +}); +``` + +这里读取的是 `x-forwarded-for` 的最后一项,也就是由你掌控的、会向该请求头追加地址的单个代理所看到的地址,例如使用 `$proxy_add_x_forwarded_for` 的 nginx,或 AWS 负载均衡器。它之前的项来自客户端,可能是伪造的。如果前面有多层可信代理,每多一层代理就往前数一项;如果没有代理,就忽略这个请求头,因为任何客户端都可以发送它。 + +### 使用 `apply: true` 的每请求值 + +使用 `{ apply: true }` 时,hapi 会为每个新请求调用一次你的函数,以请求作为参数,并把返回值赋给该属性。当每个请求都需要自己的状态(例如一次带缓存的查询)时使用它: + +```ts +interface Account { + id: string; + name: string; +} + +declare module '@hapi/hapi' { + interface Request { + loadAccount: () => Promise; + } +} + +const accounts = new Map([['2133d32a', { id: '2133d32a', name: 'John Doe' }]]); + +const findAccount = async (id: string) => accounts.get(id) ?? null; + +server.decorate( + 'request', + 'loadAccount', + (request) => { + let pending: Promise | undefined; + + return () => { + const id = request.headers['x-account-id']; + pending ??= typeof id === 'string' ? findAccount(id) : Promise.resolve(null); + return pending; + }; + }, + { apply: true }, +); +``` + +pre-handler、`onPostAuth` 钩子和 handler 都可以调用 `request.loadAccount()`,而每个请求只会查询一次账户。如果不使用 `apply`,装饰就是所有请求共享的同一个函数,没有属于单个请求的地方来保存 `pending`;把变量放在函数外部,会把第一个请求的账户泄漏给其他所有请求。 + +| | 不带选项 | 带 `apply: true` | +| ---------- | ---------------------------- | ---------------------------------------------------------- | +| 创建时机 | 一次,放在请求原型上 | 每个请求一次,在构建请求对象时 | +| 每请求状态 | 无,通过 `this` 读取请求 | 有,保存在闭包中 | +| 每请求开销 | 无 | 你的函数在每个请求上都会运行 | +| 实际示例 | vision 的 `request.render()` | @hapi/cookie 的 `request.cookieAuth`,yar 的 `request.yar` | + +`apply` 函数在每个请求上都会运行,包括那些从不使用该值的路由的请求。让它保持轻量:只创建闭包,在第一次使用该值时再做实际工作,就像 `loadAccount()` 那样。 + +## 工具包装饰:响应辅助方法 + +工具包装饰为 `h` 添加一个方法。请使用普通的 `function`:`this` 是工具包,因此 `this.response()` 构建响应,`this.request` 是当前请求。下面这个装饰让每个列表路由都遵循相同的分页约定: + +```ts +import type { ResponseObject } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ResponseToolkit { + paginated: (items: object[], total: number) => ResponseObject; + } +} + +server.decorate('toolkit', 'paginated', function (items: object[], total: number) { + return this.response(items).header('x-total-count', String(total)); +}); + +server.route<{ Query: { page?: string } }>({ + method: 'GET', + path: '/todos', + handler: (request, h) => { + const page = Number(request.query.page ?? 1); + const todos = request.server.todos.list(); + return h.paginated(todos.slice((page - 1) * 20, page * 20), todos.length); + }, +}); +``` + +`h.paginated()` 发送一页数据,并带上 `x-total-count` 响应头,这样客户端在每个列表路由上都以相同的方式读取总数。API 中反复出现的响应约定(例如这个响应头或统一的错误包装格式)很适合放在工具包装饰里。hapi 如何把返回值转换成字节,参见[响应编组](/tutorials/zh_CN/response-marshalling)。 + +## Handler 装饰:自定义 handler 类型 + +handler 装饰让路由可以用对象描述其 handler,而不必编写函数,例如 `handler: { file: 'index.html' }`。当许多路由行为相同、只是配置不同时,就可以使用它。 + +这种装饰是一个 `(route, options)` 函数,hapi 会在添加路由时为每个路由调用一次。它返回真正的 handler。下面这个装饰用于重定向旧 URL: + +```ts +import type { HandlerDecorationMethod } from '@hapi/hapi'; + +interface MovedOptions { + to: string; + permanent?: boolean; +} + +declare module '@hapi/hapi' { + interface HandlerDecorations { + moved?: MovedOptions; + } +} + +const moved: HandlerDecorationMethod = (route, options: MovedOptions) => { + return (request, h) => h.redirect(options.to).permanent(options.permanent ?? false); +}; + +moved.defaults = { auth: false }; + +server.decorate('handler', 'moved', moved); + +server.route({ method: 'GET', path: '/old-docs', handler: { moved: { to: '/docs', permanent: true } } }); +server.route({ method: 'GET', path: '/blog/{slug}', handler: { moved: { to: '/articles' } } }); +``` + +可选的 `defaults` 属性为每个使用该 handler 的路由设置路由选项;这里的设置让重定向永远不需要身份认证。`defaults` 也可以是一个以 HTTP 方法为参数的函数:h2o2 就用了这种方式,让 `{ proxy }` 路由跳过 payload 解析,直接把 payload 以流的形式转发给上游服务器。 + +由于 hapi 在添加路由时构建 handler,handler 装饰必须在任何路由使用它之前就已存在。请先注册定义它的插件。 + +## 响应装饰 + +`'response'` 装饰为每个响应对象添加一个方法,包括错误响应和通过 `request.generateResponse()` 构建的响应。很少需要用到它。如需在整个应用范围内修改响应,请使用 `onPreResponse` [生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)。 + +## 包装已有的装饰 + +`{ extend: true }` 用包装后的版本替换已有的装饰。你的函数接收当前的装饰,并返回新的装饰。常见用法是在不修改其他插件代码的情况下,为其装饰添加数据。下面的代码包装了 vision 的 `h.view()`,让每个模板都能拿到已登录的用户: + +```ts +server.decorate( + 'toolkit', + 'view', + (existing) => { + return function (template: string, context: object = {}, options?: object) { + const user = this.request.auth.credentials?.user; + return existing.call(this, template, { user, ...context }, options); + }; + }, + { extend: true }, +); +``` + +必须先注册定义该装饰的插件;扩展一个尚不存在的装饰会抛出错误。handler 装饰不能被扩展。 + +## hapi 强制执行的规则 + +装饰属于整个服务器,而不属于添加它的插件。在插件内添加的装饰,对根服务器和其他所有插件都可见。一旦装饰或路由违反以下任一规则,hapi 就会立即抛出错误: + +| 错误用法 | 错误信息 | +| ----------------------------------------- | ----------------------------------------------------------------- | +| 两个插件装饰了同一个名称 | `Server decoration already defined: todos` | +| 名称以下划线开头 | `Property name cannot begin with an underscore: _cache` | +| 名称是内置属性 | `Cannot override the built-in request interface decoration: auth` | +| 路由使用了没有任何人装饰过的 handler 类型 | `Unknown handler: moved` | +| 对不存在的名称使用 `extend: true` | `Cannot extend missing toolkit decoration: view` | + +由于名称是全局的,可复用插件中的装饰应当使用具体的名称,或者允许用户配置名称,就像 @hapi/cookie 的 `requestDecoratorName` 选项那样。[`server.decorations`](/api#server-decorations) 列出了已在使用的名称。 + +服务器装饰会被复制到每个插件自己的 `server` 对象上,因此在装饰内部,`this` 是调用它的那个插件的服务器。vision 正是依赖这一点:`server.render()` 查找的是调用方插件中配置的视图,所以两个插件可以各自拥有自己的模板。 + +## 用装饰进行启动初始化 + +插件通常在 `register()` 内部进行装饰,因此 `server.register()` 一完成,装饰就已存在。如果一个插件只在 handler 中使用另一个插件的装饰,那么两者的注册顺序无关紧要,因为 handler 在服务器启动后才运行。如果它在自己的 `register()` 内部就使用该装饰,就必须在添加该装饰的插件之后注册。 + +- 插件的 `dependencies: ['todo-store']` 属性只是让 hapi 在 [`server.initialize()`](/api#await-server-initialize) 或 `server.start()` 时检查另一个插件是否已注册。它不会改变插件的注册顺序。 +- 在服务器启动过程中使用其他插件装饰的代码(例如预热缓存或创建数据表),应放在 [`server.dependency()`](/api#server-dependency-dependencies-after) 回调中。hapi 会在 `onPreStart` 阶段、在其依赖的插件之后运行它,与注册顺序无关: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const seedPlugin: Plugin = { + name: 'todo-seed', + register: (server) => { + server.dependency('todo-store', async (server) => { + server.todos.add('Read the decorators tutorial'); + }); + }, +}; +``` + +完整的启动顺序请参见插件教程中的[依赖](/tutorials/zh_CN/plugins#dependencies)一节,以及[服务器钩子](/tutorials/zh_CN/lifecycle-hooks#server-hooks)。 + +## TypeScript + +hapi 为每个可装饰的对象都导出了一个接口。在调用 `server.decorate()` 的同一个文件中,用 `declare module '@hapi/hapi'` 代码块把你的装饰添加进去,让类型和代码放在一起: + +| `type` | 要扩展的接口 | 示例 | +| ----------- | -------------------- | --------------------------------------------------------------------------------------------- | +| `'server'` | `Server` | `interface Server { todos: TodoStore }` | +| `'request'` | `Request` | `interface Request { loadAccount: () => Promise }` | +| `'toolkit'` | `ResponseToolkit` | `interface ResponseToolkit { paginated: (items: object[], total: number) => ResponseObject }` | +| `'handler'` | `HandlerDecorations` | `interface HandlerDecorations { moved?: MovedOptions }` | + +handler 装饰的属性必须是可选的,否则每个路由都必须设置它们。hapi 自己的类型也用同样的机制处理其他应用相关的数据:`ServerApplicationState` 为 `server.app` 提供类型,`RequestApplicationState` 为 `request.app` 提供类型,`PluginProperties` 为 `server.plugins` 提供类型,`PluginsStates` 为 `request.plugins` 提供类型。 + +如何把这些装饰打包成插件,请参见[插件教程](/tutorials/zh_CN/plugins);`server.decorate()` 的完整签名请参见 [API 参考](/api#server-decorate-type-property-method-options)。 From 8ec4f3da6af34fee15443d88242c9691a2d388f4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:55:11 -0400 Subject: [PATCH 19/22] docs: add lifecycle hooks tutorial --- .vitepress/config.ts | 1 + docs/tutorials/en_US/lifecycle-hooks.md | 378 ++++++++++++++++++++++++ docs/tutorials/es_ES/lifecycle-hooks.md | 378 ++++++++++++++++++++++++ docs/tutorials/ko_KR/lifecycle-hooks.md | 378 ++++++++++++++++++++++++ docs/tutorials/pt_BR/lifecycle-hooks.md | 378 ++++++++++++++++++++++++ docs/tutorials/tr_TR/lifecycle-hooks.md | 378 ++++++++++++++++++++++++ docs/tutorials/zh_CN/lifecycle-hooks.md | 378 ++++++++++++++++++++++++ 7 files changed, 2269 insertions(+) create mode 100644 docs/tutorials/en_US/lifecycle-hooks.md create mode 100644 docs/tutorials/es_ES/lifecycle-hooks.md create mode 100644 docs/tutorials/ko_KR/lifecycle-hooks.md create mode 100644 docs/tutorials/pt_BR/lifecycle-hooks.md create mode 100644 docs/tutorials/tr_TR/lifecycle-hooks.md create mode 100644 docs/tutorials/zh_CN/lifecycle-hooks.md diff --git a/.vitepress/config.ts b/.vitepress/config.ts index e57e67fa..eee4cb58 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -58,6 +58,7 @@ const tutorialItems = [ { slug: 'caching', text: 'Caching' }, { slug: 'cookies', text: 'Cookies' }, { slug: 'decorators', text: 'Decorators' }, + { slug: 'lifecycle-hooks', text: 'Lifecycle Hooks' }, { slug: 'logging', text: 'Logging' }, { slug: 'plugins', text: 'Plugins' }, { slug: 'routing', text: 'Routing' }, diff --git a/docs/tutorials/en_US/lifecycle-hooks.md b/docs/tutorials/en_US/lifecycle-hooks.md new file mode 100644 index 00000000..009e0fae --- /dev/null +++ b/docs/tutorials/en_US/lifecycle-hooks.md @@ -0,0 +1,378 @@ +--- +title: Lifecycle Hooks +--- + + + +# Lifecycle Hooks + +_This tutorial is compatible with hapi v21 and newer_ + +## Overview + +hapi runs every request through the same fixed sequence of steps: route lookup, authentication, validation, the handler, and so on. A lifecycle hook, which the API reference calls an extension point, lets you run your own function at a named point in that sequence. The server itself has four more hooks for startup and shutdown. + +Hooks are how most cross-cutting features are built. Several official plugins are mostly a few hooks: + +| Plugin | Hook | What it does there | +| ---------------------- | --------------- | ----------------------------------------------------- | +| [yar](/module/yar) | `onPreAuth` | loads the session, so authentication can use it | +| [yar](/module/yar) | `onPreResponse` | saves the session into a cookie | +| [crumb](/module/crumb) | `onPostAuth` | rejects requests with a missing or wrong CSRF token | +| [crumb](/module/crumb) | `onPreResponse` | adds the CSRF token to the view context of HTML pages | +| [nes](/module/nes) | `onPreStart` | starts WebSocket heartbeats | +| [nes](/module/nes) | `onPreStop` | closes open WebSocket connections | + +You add a hook with [`server.ext()`](/api#server-ext-events); the API reference has the full option list. + +## Server hooks: startup and shutdown + +Server hooks run once, when the server starts or stops. Each receives the `server` and returns nothing, or a promise. A thrown error stops startup or shutdown. + +```mermaid +flowchart TD + ST["server.start()"] --> I["server.initialize()
unless already called"] + I --> C1[caches start] + C1 --> PS[onPreStart] + PS -->|"initialize() ends here"| L[server starts listening] + L --> PO[onPostStart] + S["server.stop()"] --> PR[onPreStop] + PR --> U["server stops listening,
open requests finish"] + U --> C2[caches stop] + C2 --> PT[onPostStop] + + class PS,PO,PR,PT highlight +``` + +[`server.start()`](/api#await-server-start) runs `server.initialize()` first if you have not called it. `initialize()` stops before the listening step, so it runs `onPreStart` but never opens a port. That makes it the right call in tests, where you send requests with `server.inject()`. + +| Hook | Runs | Use it for | +| ------------- | --------------------------------- | -------------------------------------------------------------------------- | +| `onPreStart` | before the server listens | connecting to databases and checking config, so a failure stops startup | +| `onPostStart` | after the server listens | work that needs the bound port, such as registering with service discovery | +| `onPreStop` | before the server stops listening | telling load balancers and long-lived connections to go away | +| `onPostStop` | after open requests have finished | closing the database pool and other resources requests were using | + +A database pool is the common case. Connecting in `onPreStart` makes a missing database fail `server.start()` instead of the first request. Closing in `onPostStop` waits until in-flight requests have finished their queries: + +```ts +import Hapi from '@hapi/hapi'; +import { Pool } from 'pg'; + +import type { Plugin } from '@hapi/hapi'; + +const database: Plugin<{ connectionString: string }> = { + name: 'database', + register(server, options) { + const pool = new Pool({ connectionString: options.connectionString }); + + server.ext('onPreStart', async () => { + await pool.query('SELECT 1'); + }); + + server.ext('onPostStop', async () => { + await pool.end(); + }); + + server.expose('pool', pool); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register({ + plugin: database, + options: { connectionString: 'postgres://localhost/app' }, +}); + +await server.start(); + +process.on('SIGTERM', async () => { + await server.stop({ timeout: 10_000 }); + process.exit(0); +}); +``` + +`server.stop({ timeout })` gives open requests that long to finish before their connections are closed. To share a service like this pool with handlers, use a [server decoration](/tutorials/en_US/decorators#server). + +Two rules for server hooks: + +- Add `onPreStart` before calling `initialize()` or `start()`. Adding one afterwards throws `Cannot add onPreStart (after) extension after the server was initialized`. +- When one plugin's startup work needs another plugin to be ready, put it in a [`server.dependency()`](/api#server-dependency-dependencies-after) callback. hapi runs the callback during `onPreStart`, after the plugins it names, whatever order they were registered in. See [Dependencies](/tutorials/en_US/plugins#dependencies) in the Plugins tutorial. + +## Request hooks + +Request hooks run for each request. The eight hooks sit between the steps of the [request lifecycle](/api#request-lifecycle): + +```mermaid +flowchart TD + A[onRequest] -->|route lookup| B[onPreAuth] + B -->|authentication| C[onCredentials] + C -->|authorization| D[onPostAuth] + D -->|validation| E[onPreHandler] + E -->|handler| F[onPostHandler] + F -->|response validation| G[onPreResponse] + G -->|response sent| H[onPostResponse] + + class A,G,H highlight +``` + +`onRequest`, `onPreResponse`, and `onPostResponse` run for every request, including 404s and requests rejected by authentication or validation. The exception is a request that is aborted or closed with `h.close`, which skips `onPreResponse`. `onCredentials` runs whenever the route has authentication configured, even when no credentials were found. The others run when the request gets that far. + +| Hook | What you can rely on | Typical use | +| ---------------- | ------------------------------------------------------------------------- | ----------------------------------------------- | +| `onRequest` | the raw path and headers; no route yet; can change the URL | request ids, URL rewrites, maintenance mode | +| `onPreAuth` | `request.route` and parsed cookies | loading a session that authentication will read | +| `onCredentials` | `request.auth.credentials`, before scopes are checked | adding roles or scopes from your database | +| `onPostAuth` | authentication done, payload parsed but not validated | CSRF checks, loading the tenant or account | +| `onPreHandler` | params, query, and payload validated | checks that need validated input | +| `onPostHandler` | the handler's response object; a failing handler skips to `onPreResponse` | changing successful responses | +| `onPreResponse` | the final `request.response`, errors included | error pages, response headers, saving sessions | +| `onPostResponse` | the response has been sent | metrics, access logs, cleanup | + +### What a hook returns + +A request hook is a [lifecycle method](/api#lifecycle-methods): it receives `(request, h)` and must return something. What it returns decides what happens next: + +| Return | `onRequest` | `onPreAuth` to `onPreHandler` | `onPostHandler`, `onPreResponse` | `onPostResponse` | +| ------------------------------ | ------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------- | +| `h.continue` | the next step runs | the next step runs | the current response is kept | ignored | +| `h.response(value).takeover()` | skips to `onPreResponse` | skips to the route's response validation, then `onPreResponse` | replaces the response; later hooks of the same type are skipped | ignored | +| an error, thrown or returned | skips to `onPreResponse` | skips to `onPreResponse` | replaces the response; later hooks of the same type are skipped | thrown, or returned boom: logged | +| any other value | 500 error | 500 error | replaces the response; later hooks of the same type still run | ignored | +| nothing (`undefined`) | 500 error | 500 error | 500 error | ignored | + +[`h.continue`](/api#h-continue) is how a hook says "nothing to do here". A takeover is how a hook before the handler answers the request itself, and even a takeover from `onRequest` passes through `onPreResponse`. The [lifecycle workflow](/api#lifecycle-workflow) in the API reference has the full rules. + +## Patterns + +### Request ids and timing + +A request id ties a client's error report to your logs. This plugin accepts an id from the client or creates one, returns it in a header, and records how long each request took: + +```ts +import { randomUUID } from 'node:crypto'; +import Boom from '@hapi/boom'; + +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginsStates { + 'request-tracing': { id: string }; + } +} + +const requestTracing: Plugin = { + name: 'request-tracing', + register(server) { + server.ext('onRequest', (request, h) => { + const incoming = request.headers['x-request-id']; + request.plugins['request-tracing'] = { id: typeof incoming === 'string' ? incoming : randomUUID() }; + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const { response } = request; + const { id } = request.plugins['request-tracing']; + if (Boom.isBoom(response)) { + response.output.headers['x-request-id'] = id; + } else { + response.header('x-request-id', id); + } + + return h.continue; + }); + + server.ext('onPostResponse', (request, h) => { + const { response } = request; + const statusCode = Boom.isBoom(response) ? response.output.statusCode : response.statusCode; + const duration = request.info.completed - request.info.received; + + request.log(['metrics'], { + id: request.plugins['request-tracing'].id, + path: request.path, + statusCode, + duration, + }); + return h.continue; + }); + }, +}; +``` + +- A plugin keeps its per-request data under its own name in [`request.plugins`](/api#request-plugins), and the `declare module` block types that entry through hapi's `PluginsStates` interface. [`request.app`](/api#request-app) is for your application's own code. +- `request.response` is a boom error when the request failed, so `onPreResponse` sets the header on `response.output.headers` instead of calling `.header()`. +- `onPostResponse` runs after the response is sent, so the client never waits for it, and its return value is ignored. hapi runs the `onPostResponse` hooks one after another, so a slow hook still delays the hooks after it. Hand slow work such as sending metrics over the network to a queue, and return. +- hapi already records [`request.info.received`](/api#request-info) and `request.info.completed`, so there is no need to store a start time yourself. + +### Rewriting URLs + +`onRequest` is the only hook that runs before route lookup, so it is the only place a rewrite changes which route handles the request. [`request.setUrl()`](/api#request-seturl-url-striptrailingslash) does the rewrite: + +```ts +server.ext('onRequest', (request, h) => { + if (request.path.startsWith('/api/v1/')) { + request.setUrl(request.url.pathname.replace('/api/v1/', '/api/') + request.url.search); + } + + return h.continue; +}); +``` + +Old clients keep calling `/api/v1/users?page=2`, and the `/api/users` route answers them with the query string intact. + +### Maintenance mode + +A takeover response from `onRequest` answers every request before any route runs: + +```ts +let maintenance = false; + +server.ext('onRequest', (request, h) => { + if (!maintenance || request.path === '/health') { + return h.continue; + } + + return h.response({ message: 'Down for maintenance' }).code(503).header('retry-after', '600').takeover(); +}); +``` + +The health check stays reachable, so your load balancer can tell a planned outage from a crash. + +### Loading roles: onCredentials or onPostAuth + +Both hooks run after authentication. The difference is the authorization step between them, which checks the route's [`auth.access.scope`](/api#route-options-auth-access-scope) (see [Scopes](/tutorials/en_US/auth#scopes)). + +Use `onCredentials` when you add something authorization should see. A token often identifies the user but does not carry their roles, so you load the roles from your database and add them as scopes: + +```ts +server.ext('onCredentials', async (request, h) => { + if (!request.auth.isAuthenticated) { + return h.continue; + } + + const { user } = request.auth.credentials; + if (user) { + request.auth.credentials.scope = await loadRoles(user.id); + } + + return h.continue; +}); + +server.route<{ Params: { id: string } }>({ + method: 'DELETE', + path: '/projects/{id}', + options: { auth: { access: { scope: 'admin' } } }, + handler: (request, h) => deleteProject(request.params.id), +}); +``` + +`onCredentials` runs whenever the route has authentication configured, including `'try'` and `'optional'` routes where no credentials were found and `request.auth.credentials` is `null`, so check `request.auth.isAuthenticated` first. `user.id` relies on the `UserCredentials` declaration shown in the [Authentication tutorial](/tutorials/en_US/auth#schemes). + +Use `onPostAuth` for work that does not change what the user is allowed to do, or that must also run on routes without authentication, such as loading the tenant named in the `Host` header. `onCredentials` never runs on a route with `auth: false`. + +### Consistent error responses + +`onPreResponse` sees every response on its way out, so it is where you change how errors look. A hook that replaces an error with its own response has to keep the error's headers, such as `WWW-Authenticate` on a 401: + +```ts +import Boom from '@hapi/boom'; + +server.ext( + 'onPreResponse', + (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ error: payload.message }).code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; + }, + { before: ['request-tracing'] }, +); +``` + +The new response replaces the error, and `onPreResponse` hooks that run after this one see the new response. With `before: ['request-tracing']`, the [request id hook](#request-id) runs after this one and adds its header to the new response. Without it, the request id hook would set its header on the error first, and only the header copy above would keep it. Ordering matters most for hooks that replace the response. The [Response Marshalling tutorial](/tutorials/en_US/response-marshalling#errors) has a full error format and HTML error pages. + +## Ordering and scoping + +By default, hooks of the same type run in the order they were added, counting server hooks, plugin hooks, and route hooks together. Three options change which requests a hook sees and when it runs. + +**`before` and `after`** take plugin names, and order your plugin's hook relative to theirs. A plugin that reads the session that yar loads would use: + +```ts +server.ext('onPreAuth', loadPreferences, { after: ['@hapi/yar'] }); +``` + +**`sandbox: 'plugin'`** limits a hook to the routes its plugin added. It works for every request hook except `onRequest`, which runs before hapi knows the route: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const adminPanel: Plugin = { + name: 'admin-panel', + register(server) { + server.ext('onPreHandler', auditAdminAccess, { sandbox: 'plugin' }); + + server.route({ method: 'GET', path: '/admin', handler: (request, h) => 'admin' }); + }, +}; +``` + +**Route-level hooks** go in the route's [`options.ext`](/api#route-options-ext), and run only for that route: + +```ts +server.route({ + method: 'POST', + path: '/uploads', + options: { + ext: { + onPreHandler: { method: checkStorageQuota }, + }, + }, + handler: (request, h) => saveUpload(request.payload), +}); +``` + +`server.ext()` also takes an array, which keeps a plugin's hooks together in one place: + +```ts +server.ext([ + { type: 'onRequest', method: assignRequestId }, + { type: 'onPreResponse', method: addRequestIdHeader }, + { type: 'onPostResponse', method: recordMetrics }, +]); +``` + +## Typing hooks + +Hooks written inline in `server.ext()` need no annotations: TypeScript infers `request` and `h` from the hook name, and infers `server` for server hooks. A hook defined on its own is a `Lifecycle.Method`: + +```ts +import Boom from '@hapi/boom'; + +import type { Lifecycle } from '@hapi/hapi'; + +const checkStorageQuota: Lifecycle.Method = async (request, h) => { + if (!request.auth.isAuthenticated) { + throw Boom.unauthorized(); + } + + const quota = await getQuota(request.auth.credentials.user?.id); + if (quota.used >= quota.limit) { + throw Boom.forbidden('Storage quota exceeded'); + } + + return h.continue; +}; +``` + +To type data your hooks store on the request, extend `PluginsStates` as in the [request id example](#request-id), or `RequestApplicationState` for `request.app` in application code. `user?.id` relies on the `UserCredentials` declaration shown in the [Authentication tutorial](/tutorials/en_US/auth#schemes). diff --git a/docs/tutorials/es_ES/lifecycle-hooks.md b/docs/tutorials/es_ES/lifecycle-hooks.md new file mode 100644 index 00000000..97f0b9ff --- /dev/null +++ b/docs/tutorials/es_ES/lifecycle-hooks.md @@ -0,0 +1,378 @@ +--- +title: Hooks del ciclo de vida +--- + + + +# Hooks del ciclo de vida + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +hapi hace pasar cada petición por la misma secuencia fija de pasos: búsqueda de la ruta, autenticación, validación, el handler, etc. Un hook del ciclo de vida, que la referencia de la API llama punto de extensión (extension point), te permite ejecutar tu propia función en un punto con nombre de esa secuencia. El propio servidor tiene otros cuatro hooks para el arranque y la parada. + +La mayoría de las funcionalidades transversales se construyen con hooks. Varios plugins oficiales son, en esencia, unos pocos hooks: + +| Plugin | Hook | Qué hace en él | +| ---------------------- | --------------- | ------------------------------------------------------------ | +| [yar](/module/yar) | `onPreAuth` | carga la sesión, para que la autenticación pueda usarla | +| [yar](/module/yar) | `onPreResponse` | guarda la sesión en una cookie | +| [crumb](/module/crumb) | `onPostAuth` | rechaza peticiones sin token CSRF o con un token incorrecto | +| [crumb](/module/crumb) | `onPreResponse` | añade el token CSRF al contexto de vista de las páginas HTML | +| [nes](/module/nes) | `onPreStart` | arranca los heartbeats de WebSocket | +| [nes](/module/nes) | `onPreStop` | cierra las conexiones WebSocket abiertas | + +Los hooks se añaden con [`server.ext()`](/api#server-ext-events); la referencia de la API tiene la lista completa de opciones. + +## Hooks del servidor: arranque y parada + +Los hooks del servidor se ejecutan una vez, cuando el servidor arranca o se detiene. Cada uno recibe el `server` y no devuelve nada, o devuelve una promesa. Un error lanzado detiene el arranque o la parada. + +```mermaid +flowchart TD + ST["server.start()"] --> I["server.initialize()
si no se ha llamado ya"] + I --> C1[arrancan las cachés] + C1 --> PS[onPreStart] + PS -->|"initialize() termina aquí"| L[el servidor empieza a escuchar] + L --> PO[onPostStart] + S["server.stop()"] --> PR[onPreStop] + PR --> U["el servidor deja de escuchar,
terminan las peticiones abiertas"] + U --> C2[se detienen las cachés] + C2 --> PT[onPostStop] + + class PS,PO,PR,PT highlight +``` + +[`server.start()`](/api#await-server-start) ejecuta primero `server.initialize()` si no lo has llamado tú. `initialize()` se detiene antes del paso de escucha, así que ejecuta `onPreStart` pero nunca abre un puerto. Por eso es la llamada adecuada en los tests, donde envías peticiones con `server.inject()`. + +| Hook | Se ejecuta | Úsalo para | +| ------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `onPreStart` | antes de que el servidor escuche | conectar con bases de datos y comprobar la configuración, para que un fallo detenga el arranque | +| `onPostStart` | después de que el servidor escuche | trabajo que necesita el puerto asignado, como registrarse en un servicio de descubrimiento | +| `onPreStop` | antes de que el servidor deje de escuchar | avisar a los balanceadores de carga y a las conexiones de larga duración de que se vayan | +| `onPostStop` | cuando han terminado las peticiones abiertas | cerrar el pool de la base de datos y otros recursos que usaban las peticiones | + +Un pool de base de datos es el caso habitual. Conectar en `onPreStart` hace que una base de datos ausente haga fallar `server.start()` en lugar de la primera petición. Cerrar en `onPostStop` espera a que las peticiones en curso terminen sus consultas: + +```ts +import Hapi from '@hapi/hapi'; +import { Pool } from 'pg'; + +import type { Plugin } from '@hapi/hapi'; + +const database: Plugin<{ connectionString: string }> = { + name: 'database', + register(server, options) { + const pool = new Pool({ connectionString: options.connectionString }); + + server.ext('onPreStart', async () => { + await pool.query('SELECT 1'); + }); + + server.ext('onPostStop', async () => { + await pool.end(); + }); + + server.expose('pool', pool); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register({ + plugin: database, + options: { connectionString: 'postgres://localhost/app' }, +}); + +await server.start(); + +process.on('SIGTERM', async () => { + await server.stop({ timeout: 10_000 }); + process.exit(0); +}); +``` + +`server.stop({ timeout })` da ese tiempo a las peticiones abiertas para terminar antes de cerrar sus conexiones. Para compartir un servicio como este pool con los handlers, usa una [decoración de servidor](/tutorials/es_ES/decorators#server). + +Dos reglas para los hooks del servidor: + +- Añade `onPreStart` antes de llamar a `initialize()` o `start()`. Añadirlo después lanza `Cannot add onPreStart (after) extension after the server was initialized`. +- Cuando el trabajo de arranque de un plugin necesita que otro plugin esté listo, ponlo en un callback de [`server.dependency()`](/api#server-dependency-dependencies-after). hapi ejecuta el callback durante `onPreStart`, después de los plugins que nombra, sea cual sea el orden en que se registraron. Consulta [Dependencias](/tutorials/es_ES/plugins#dependencies) en el tutorial de plugins. + +## Hooks de petición + +Los hooks de petición se ejecutan en cada petición. Los ocho hooks se sitúan entre los pasos del [ciclo de vida de la petición](/api#request-lifecycle): + +```mermaid +flowchart TD + A[onRequest] -->|búsqueda de ruta| B[onPreAuth] + B -->|autenticación| C[onCredentials] + C -->|autorización| D[onPostAuth] + D -->|validación| E[onPreHandler] + E -->|handler| F[onPostHandler] + F -->|validación de la respuesta| G[onPreResponse] + G -->|respuesta enviada| H[onPostResponse] + + class A,G,H highlight +``` + +`onRequest`, `onPreResponse` y `onPostResponse` se ejecutan en todas las peticiones, incluidos los 404 y las peticiones rechazadas por la autenticación o la validación. La excepción es una petición abortada o cerrada con `h.close`, que se salta `onPreResponse`. `onCredentials` se ejecuta siempre que la ruta tiene autenticación configurada, aunque no se hayan encontrado credenciales. Los demás se ejecutan cuando la petición llega hasta ese punto. + +| Hook | Con qué puedes contar | Uso típico | +| ---------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `onRequest` | la ruta y las cabeceras en bruto; todavía sin ruta asignada; puede cambiar la URL | ids de petición, reescritura de URLs, modo mantenimiento | +| `onPreAuth` | `request.route` y las cookies parseadas | cargar una sesión que leerá la autenticación | +| `onCredentials` | `request.auth.credentials`, antes de comprobar los scopes | añadir roles o scopes desde tu base de datos | +| `onPostAuth` | autenticación hecha, payload parseado pero sin validar | comprobaciones CSRF, cargar el tenant o la cuenta | +| `onPreHandler` | params, query y payload validados | comprobaciones que necesitan la entrada validada | +| `onPostHandler` | el objeto response del handler; si el handler falla, se salta a `onPreResponse` | modificar las respuestas correctas | +| `onPreResponse` | el `request.response` final, errores incluidos | páginas de error, cabeceras de respuesta, guardar sesiones | +| `onPostResponse` | la respuesta ya se ha enviado | métricas, logs de acceso, limpieza | + +### Qué devuelve un hook + +Un hook de petición es un [método del ciclo de vida](/api#lifecycle-methods): recibe `(request, h)` y tiene que devolver algo. Lo que devuelve decide qué ocurre después: + +| Devuelve | `onRequest` | De `onPreAuth` a `onPreHandler` | `onPostHandler`, `onPreResponse` | `onPostResponse` | +| ------------------------------ | ---------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------- | +| `h.continue` | se ejecuta el siguiente paso | se ejecuta el siguiente paso | se mantiene la respuesta actual | se ignora | +| `h.response(value).takeover()` | salta a `onPreResponse` | salta a la validación de respuesta de la ruta y después a `onPreResponse` | sustituye la respuesta; se saltan los hooks posteriores del mismo tipo | se ignora | +| un error, lanzado o devuelto | salta a `onPreResponse` | salta a `onPreResponse` | sustituye la respuesta; se saltan los hooks posteriores del mismo tipo | lanzado, o boom devuelto: se registra en el log | +| cualquier otro valor | error 500 | error 500 | sustituye la respuesta; los hooks posteriores del mismo tipo se siguen ejecutando | se ignora | +| nada (`undefined`) | error 500 | error 500 | error 500 | se ignora | + +[`h.continue`](/api#h-continue) es la forma en que un hook dice "aquí no hay nada que hacer". Un takeover es la forma en que un hook anterior al handler responde él mismo a la petición, e incluso un takeover desde `onRequest` pasa por `onPreResponse`. El [flujo del ciclo de vida](/api#lifecycle-workflow) de la referencia de la API tiene las reglas completas. + +## Patrones + +### Ids de petición y tiempos + +Un id de petición relaciona el informe de error de un cliente con tus logs. Este plugin acepta un id del cliente o crea uno, lo devuelve en una cabecera y registra cuánto tardó cada petición: + +```ts +import { randomUUID } from 'node:crypto'; +import Boom from '@hapi/boom'; + +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginsStates { + 'request-tracing': { id: string }; + } +} + +const requestTracing: Plugin = { + name: 'request-tracing', + register(server) { + server.ext('onRequest', (request, h) => { + const incoming = request.headers['x-request-id']; + request.plugins['request-tracing'] = { id: typeof incoming === 'string' ? incoming : randomUUID() }; + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const { response } = request; + const { id } = request.plugins['request-tracing']; + if (Boom.isBoom(response)) { + response.output.headers['x-request-id'] = id; + } else { + response.header('x-request-id', id); + } + + return h.continue; + }); + + server.ext('onPostResponse', (request, h) => { + const { response } = request; + const statusCode = Boom.isBoom(response) ? response.output.statusCode : response.statusCode; + const duration = request.info.completed - request.info.received; + + request.log(['metrics'], { + id: request.plugins['request-tracing'].id, + path: request.path, + statusCode, + duration, + }); + return h.continue; + }); + }, +}; +``` + +- Un plugin guarda sus datos por petición bajo su propio nombre en [`request.plugins`](/api#request-plugins), y el bloque `declare module` tipa esa entrada mediante la interfaz `PluginsStates` de hapi. [`request.app`](/api#request-app) es para el código propio de tu aplicación. +- `request.response` es un error boom cuando la petición ha fallado, así que `onPreResponse` fija la cabecera en `response.output.headers` en lugar de llamar a `.header()`. +- `onPostResponse` se ejecuta después de enviar la respuesta, así que el cliente nunca lo espera, y su valor de retorno se ignora. hapi ejecuta los hooks `onPostResponse` uno tras otro, así que un hook lento sigue retrasando a los que vienen detrás. Pasa el trabajo lento, como enviar métricas por la red, a una cola, y devuelve. +- hapi ya registra [`request.info.received`](/api#request-info) y `request.info.completed`, así que no hace falta que guardes tú una hora de inicio. + +### Reescribir URLs + +`onRequest` es el único hook que se ejecuta antes de la búsqueda de la ruta, así que es el único sitio donde una reescritura cambia qué ruta atiende la petición. [`request.setUrl()`](/api#request-seturl-url-striptrailingslash) hace la reescritura: + +```ts +server.ext('onRequest', (request, h) => { + if (request.path.startsWith('/api/v1/')) { + request.setUrl(request.url.pathname.replace('/api/v1/', '/api/') + request.url.search); + } + + return h.continue; +}); +``` + +Los clientes antiguos siguen llamando a `/api/v1/users?page=2`, y la ruta `/api/users` les responde con la query string intacta. + +### Modo mantenimiento + +Una respuesta takeover desde `onRequest` responde a todas las peticiones antes de que se ejecute ninguna ruta: + +```ts +let maintenance = false; + +server.ext('onRequest', (request, h) => { + if (!maintenance || request.path === '/health') { + return h.continue; + } + + return h.response({ message: 'Down for maintenance' }).code(503).header('retry-after', '600').takeover(); +}); +``` + +El health check sigue accesible, para que tu balanceador de carga distinga una parada planificada de una caída. + +### Cargar roles: onCredentials u onPostAuth + +Los dos hooks se ejecutan después de la autenticación. La diferencia es el paso de autorización que hay entre ellos, que comprueba el [`auth.access.scope`](/api#route-options-auth-access-scope) de la ruta (consulta [Scopes](/tutorials/es_ES/auth#scopes)). + +Usa `onCredentials` cuando añadas algo que la autorización deba ver. Es habitual que un token identifique al usuario pero no lleve sus roles, así que cargas los roles desde tu base de datos y los añades como scopes: + +```ts +server.ext('onCredentials', async (request, h) => { + if (!request.auth.isAuthenticated) { + return h.continue; + } + + const { user } = request.auth.credentials; + if (user) { + request.auth.credentials.scope = await loadRoles(user.id); + } + + return h.continue; +}); + +server.route<{ Params: { id: string } }>({ + method: 'DELETE', + path: '/projects/{id}', + options: { auth: { access: { scope: 'admin' } } }, + handler: (request, h) => deleteProject(request.params.id), +}); +``` + +`onCredentials` se ejecuta siempre que la ruta tiene autenticación configurada, incluidas las rutas `'try'` y `'optional'` en las que no se encontraron credenciales y `request.auth.credentials` es `null`, así que comprueba primero `request.auth.isAuthenticated`. `user.id` depende de la declaración `UserCredentials` que aparece en el [tutorial de autenticación](/tutorials/es_ES/auth#schemes). + +Usa `onPostAuth` para el trabajo que no cambia lo que el usuario tiene permitido hacer, o que también debe ejecutarse en rutas sin autenticación, como cargar el tenant indicado en la cabecera `Host`. `onCredentials` nunca se ejecuta en una ruta con `auth: false`. + +### Respuestas de error coherentes + +`onPreResponse` ve cada respuesta antes de que salga, así que es el sitio donde cambiar el aspecto de los errores. Un hook que sustituye un error por su propia respuesta tiene que conservar las cabeceras del error, como `WWW-Authenticate` en un 401: + +```ts +import Boom from '@hapi/boom'; + +server.ext( + 'onPreResponse', + (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ error: payload.message }).code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; + }, + { before: ['request-tracing'] }, +); +``` + +La nueva respuesta sustituye al error, y los hooks `onPreResponse` que se ejecutan después de este ven la nueva respuesta. Con `before: ['request-tracing']`, el [hook del id de petición](#request-id) se ejecuta después de este y añade su cabecera a la nueva respuesta. Sin esa opción, el hook del id de petición fijaría primero su cabecera en el error, y solo la copia de cabeceras de arriba la conservaría. El orden importa sobre todo en los hooks que sustituyen la respuesta. El [tutorial de serialización de respuestas](/tutorials/es_ES/response-marshalling#errors) tiene un formato de error completo y páginas de error HTML. + +## Orden y alcance + +Por defecto, los hooks del mismo tipo se ejecutan en el orden en que se añadieron, contando juntos los hooks del servidor, los de los plugins y los de las rutas. Tres opciones cambian qué peticiones ve un hook y cuándo se ejecuta. + +**`before` y `after`** reciben nombres de plugins y ordenan el hook de tu plugin respecto a los suyos. Un plugin que lee la sesión que carga yar usaría: + +```ts +server.ext('onPreAuth', loadPreferences, { after: ['@hapi/yar'] }); +``` + +**`sandbox: 'plugin'`** limita un hook a las rutas que añadió su plugin. Funciona con todos los hooks de petición excepto `onRequest`, que se ejecuta antes de que hapi conozca la ruta: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const adminPanel: Plugin = { + name: 'admin-panel', + register(server) { + server.ext('onPreHandler', auditAdminAccess, { sandbox: 'plugin' }); + + server.route({ method: 'GET', path: '/admin', handler: (request, h) => 'admin' }); + }, +}; +``` + +**Los hooks a nivel de ruta** van en el [`options.ext`](/api#route-options-ext) de la ruta y solo se ejecutan para esa ruta: + +```ts +server.route({ + method: 'POST', + path: '/uploads', + options: { + ext: { + onPreHandler: { method: checkStorageQuota }, + }, + }, + handler: (request, h) => saveUpload(request.payload), +}); +``` + +`server.ext()` también acepta un array, lo que mantiene juntos los hooks de un plugin en un solo sitio: + +```ts +server.ext([ + { type: 'onRequest', method: assignRequestId }, + { type: 'onPreResponse', method: addRequestIdHeader }, + { type: 'onPostResponse', method: recordMetrics }, +]); +``` + +## Tipar los hooks + +Los hooks escritos directamente en `server.ext()` no necesitan anotaciones: TypeScript infiere `request` y `h` a partir del nombre del hook, e infiere `server` en los hooks del servidor. Un hook definido por separado es un `Lifecycle.Method`: + +```ts +import Boom from '@hapi/boom'; + +import type { Lifecycle } from '@hapi/hapi'; + +const checkStorageQuota: Lifecycle.Method = async (request, h) => { + if (!request.auth.isAuthenticated) { + throw Boom.unauthorized(); + } + + const quota = await getQuota(request.auth.credentials.user?.id); + if (quota.used >= quota.limit) { + throw Boom.forbidden('Storage quota exceeded'); + } + + return h.continue; +}; +``` + +Para tipar los datos que tus hooks guardan en la petición, extiende `PluginsStates` como en el [ejemplo del id de petición](#request-id), o `RequestApplicationState` para `request.app` en el código de la aplicación. `user?.id` depende de la declaración `UserCredentials` que aparece en el [tutorial de autenticación](/tutorials/es_ES/auth#schemes). diff --git a/docs/tutorials/ko_KR/lifecycle-hooks.md b/docs/tutorials/ko_KR/lifecycle-hooks.md new file mode 100644 index 00000000..09916bb9 --- /dev/null +++ b/docs/tutorials/ko_KR/lifecycle-hooks.md @@ -0,0 +1,378 @@ +--- +title: 라이프사이클 훅 +--- + + + +# 라이프사이클 훅 + +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ + +## 개요 + +hapi는 모든 요청을 라우트 조회, 인증, 검증, 핸들러 등 정해진 같은 순서의 단계로 처리합니다. 라이프사이클 훅(API 레퍼런스에서는 extension point라고 부릅니다)을 사용하면 이 순서의 이름 붙은 지점에서 여러분의 함수를 실행할 수 있습니다. 서버 자체에도 시작과 종료를 위한 훅이 네 개 더 있습니다. + +여러 기능에 걸친 공통 기능은 대부분 훅으로 만들어집니다. 몇몇 공식 플러그인은 사실상 훅 몇 개로 이루어져 있습니다. + +| 플러그인 | 훅 | 그 훅에서 하는 일 | +| ---------------------- | --------------- | -------------------------------------------- | +| [yar](/module/yar) | `onPreAuth` | 인증에서 쓸 수 있도록 세션을 불러옴 | +| [yar](/module/yar) | `onPreResponse` | 세션을 쿠키에 저장 | +| [crumb](/module/crumb) | `onPostAuth` | CSRF 토큰이 없거나 틀린 요청을 거부 | +| [crumb](/module/crumb) | `onPreResponse` | HTML 페이지의 뷰 컨텍스트에 CSRF 토큰을 추가 | +| [nes](/module/nes) | `onPreStart` | WebSocket heartbeat 시작 | +| [nes](/module/nes) | `onPreStop` | 열려 있는 WebSocket 연결을 닫음 | + +훅은 [`server.ext()`](/api#server-ext-events)로 추가하며, 전체 옵션 목록은 API 레퍼런스에 있습니다. + +## 서버 훅: 시작과 종료 + +서버 훅은 서버가 시작하거나 멈출 때 한 번 실행됩니다. 각 훅은 `server`를 받고, 아무것도 반환하지 않거나 promise를 반환합니다. 에러를 던지면 시작이나 종료가 중단됩니다. + +```mermaid +flowchart TD + ST["server.start()"] --> I["server.initialize()
이미 호출하지 않았다면"] + I --> C1[캐시 시작] + C1 --> PS[onPreStart] + PS -->|"initialize()는 여기서 끝남"| L[서버가 수신 시작] + L --> PO[onPostStart] + S["server.stop()"] --> PR[onPreStop] + PR --> U["서버가 수신 중단,
열린 요청이 마무리됨"] + U --> C2[캐시 중지] + C2 --> PT[onPostStop] + + class PS,PO,PR,PT highlight +``` + +[`server.start()`](/api#await-server-start)는 `server.initialize()`를 아직 호출하지 않았다면 먼저 실행합니다. `initialize()`는 수신 단계 전에 멈추므로 `onPreStart`는 실행하지만 포트는 열지 않습니다. 그래서 `server.inject()`로 요청을 보내는 테스트에서는 이 메서드를 호출하는 것이 맞습니다. + +| 훅 | 실행 시점 | 용도 | +| ------------- | ------------------------ | ------------------------------------------------------ | +| `onPreStart` | 서버가 수신하기 전 | 데이터베이스 연결과 설정 검사. 실패하면 시작이 중단됨 | +| `onPostStart` | 서버가 수신을 시작한 뒤 | 서비스 디스커버리 등록처럼 바인딩된 포트가 필요한 작업 | +| `onPreStop` | 서버가 수신을 멈추기 전 | 로드 밸런서와 장시간 연결에 연결을 끊으라고 알림 | +| `onPostStop` | 열린 요청이 모두 끝난 뒤 | 데이터베이스 풀 등 요청이 쓰던 리소스 정리 | + +데이터베이스 풀이 흔한 예입니다. `onPreStart`에서 연결하면 데이터베이스가 없을 때 첫 요청이 아니라 `server.start()`가 실패합니다. `onPostStop`에서 닫으면 처리 중인 요청이 쿼리를 마칠 때까지 기다립니다. + +```ts +import Hapi from '@hapi/hapi'; +import { Pool } from 'pg'; + +import type { Plugin } from '@hapi/hapi'; + +const database: Plugin<{ connectionString: string }> = { + name: 'database', + register(server, options) { + const pool = new Pool({ connectionString: options.connectionString }); + + server.ext('onPreStart', async () => { + await pool.query('SELECT 1'); + }); + + server.ext('onPostStop', async () => { + await pool.end(); + }); + + server.expose('pool', pool); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register({ + plugin: database, + options: { connectionString: 'postgres://localhost/app' }, +}); + +await server.start(); + +process.on('SIGTERM', async () => { + await server.stop({ timeout: 10_000 }); + process.exit(0); +}); +``` + +`server.stop({ timeout })`은 열린 요청에 연결이 닫히기 전까지 그만큼의 시간을 줍니다. 이 풀 같은 서비스를 핸들러와 공유하려면 [서버 데코레이션](/tutorials/ko_KR/decorators#server)을 사용하세요. + +서버 훅의 두 가지 규칙: + +- `onPreStart`는 `initialize()`나 `start()`를 호출하기 전에 추가하세요. 그 뒤에 추가하면 `Cannot add onPreStart (after) extension after the server was initialized` 에러가 발생합니다. +- 한 플러그인의 시작 작업이 다른 플러그인의 준비를 필요로 한다면 [`server.dependency()`](/api#server-dependency-dependencies-after) 콜백에 넣으세요. hapi는 등록 순서와 관계없이, 지정한 플러그인 다음에 `onPreStart` 동안 이 콜백을 실행합니다. 플러그인 튜토리얼의 [의존성](/tutorials/ko_KR/plugins#dependencies)을 참고하세요. + +## 요청 훅 + +요청 훅은 요청마다 실행됩니다. 여덟 개의 훅이 [요청 라이프사이클](/api#request-lifecycle)의 단계 사이에 위치합니다. + +```mermaid +flowchart TD + A[onRequest] -->|라우트 조회| B[onPreAuth] + B -->|인증| C[onCredentials] + C -->|인가| D[onPostAuth] + D -->|검증| E[onPreHandler] + E -->|핸들러| F[onPostHandler] + F -->|응답 검증| G[onPreResponse] + G -->|응답 전송| H[onPostResponse] + + class A,G,H highlight +``` + +`onRequest`, `onPreResponse`, `onPostResponse`는 404나 인증 또는 검증에서 거부된 요청을 포함해 모든 요청에서 실행됩니다. 예외는 중단(abort)되었거나 `h.close`로 닫힌 요청으로, 이 경우 `onPreResponse`를 건너뜁니다. `onCredentials`는 라우트에 인증이 설정되어 있으면 자격 증명을 찾지 못했더라도 항상 실행됩니다. 나머지 훅은 요청이 그 단계까지 도달했을 때 실행됩니다. + +| 훅 | 믿고 쓸 수 있는 것 | 일반적인 용도 | +| ---------------- | -------------------------------------------------------------- | ------------------------------------- | +| `onRequest` | 원본 경로와 헤더. 아직 라우트 없음. URL을 바꿀 수 있음 | 요청 id, URL 재작성, 점검 모드 | +| `onPreAuth` | `request.route`와 파싱된 쿠키 | 인증에서 읽을 세션 불러오기 | +| `onCredentials` | 스코프 검사 전의 `request.auth.credentials` | 데이터베이스에서 역할이나 스코프 추가 | +| `onPostAuth` | 인증 완료, 페이로드는 파싱되었지만 검증 전 | CSRF 검사, 테넌트나 계정 불러오기 | +| `onPreHandler` | 파라미터, 쿼리, 페이로드 검증 완료 | 검증된 입력이 필요한 검사 | +| `onPostHandler` | 핸들러의 응답 객체. 핸들러가 실패하면 `onPreResponse`로 건너뜀 | 성공한 응답 변경 | +| `onPreResponse` | 에러를 포함한 최종 `request.response` | 에러 페이지, 응답 헤더, 세션 저장 | +| `onPostResponse` | 응답 전송 완료 | 메트릭, 접근 로그, 정리 작업 | + +### 훅이 반환하는 값 + +요청 훅은 [라이프사이클 메서드](/api#lifecycle-methods)입니다. `(request, h)`를 받고 반드시 무언가를 반환해야 합니다. 무엇을 반환하느냐에 따라 다음 동작이 정해집니다. + +| 반환값 | `onRequest` | `onPreAuth` ~ `onPreHandler` | `onPostHandler`, `onPreResponse` | `onPostResponse` | +| ------------------------------ | ------------------------ | ------------------------------------------------ | -------------------------------------------- | ------------------------------------ | +| `h.continue` | 다음 단계 실행 | 다음 단계 실행 | 현재 응답 유지 | 무시됨 | +| `h.response(value).takeover()` | `onPreResponse`로 건너뜀 | 라우트의 응답 검증으로 건너뛴 뒤 `onPreResponse` | 응답을 교체. 같은 유형의 이후 훅은 건너뜀 | 무시됨 | +| 던지거나 반환한 에러 | `onPreResponse`로 건너뜀 | `onPreResponse`로 건너뜀 | 응답을 교체. 같은 유형의 이후 훅은 건너뜀 | 던진 에러나 반환한 boom: 로그로 남음 | +| 그 밖의 값 | 500 에러 | 500 에러 | 응답을 교체. 같은 유형의 이후 훅은 계속 실행 | 무시됨 | +| 아무것도 없음 (`undefined`) | 500 에러 | 500 에러 | 500 에러 | 무시됨 | + +[`h.continue`](/api#h-continue)는 훅이 "여기서는 할 일이 없음"을 알리는 방법입니다. takeover는 핸들러 이전의 훅이 직접 요청에 응답하는 방법이며, `onRequest`에서 한 takeover도 `onPreResponse`를 거칩니다. 전체 규칙은 API 레퍼런스의 [라이프사이클 워크플로](/api#lifecycle-workflow)에 있습니다. + +## 패턴 + +### 요청 id와 소요 시간 + +요청 id는 클라이언트의 에러 보고와 서버 로그를 연결해 줍니다. 다음 플러그인은 클라이언트가 보낸 id를 받거나 새로 만들고, 헤더로 돌려주며, 각 요청에 걸린 시간을 기록합니다. + +```ts +import { randomUUID } from 'node:crypto'; +import Boom from '@hapi/boom'; + +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginsStates { + 'request-tracing': { id: string }; + } +} + +const requestTracing: Plugin = { + name: 'request-tracing', + register(server) { + server.ext('onRequest', (request, h) => { + const incoming = request.headers['x-request-id']; + request.plugins['request-tracing'] = { id: typeof incoming === 'string' ? incoming : randomUUID() }; + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const { response } = request; + const { id } = request.plugins['request-tracing']; + if (Boom.isBoom(response)) { + response.output.headers['x-request-id'] = id; + } else { + response.header('x-request-id', id); + } + + return h.continue; + }); + + server.ext('onPostResponse', (request, h) => { + const { response } = request; + const statusCode = Boom.isBoom(response) ? response.output.statusCode : response.statusCode; + const duration = request.info.completed - request.info.received; + + request.log(['metrics'], { + id: request.plugins['request-tracing'].id, + path: request.path, + statusCode, + duration, + }); + return h.continue; + }); + }, +}; +``` + +- 플러그인은 요청별 데이터를 [`request.plugins`](/api#request-plugins)에 자신의 이름으로 보관하며, `declare module` 블록은 hapi의 `PluginsStates` 인터페이스를 통해 이 항목의 타입을 지정합니다. [`request.app`](/api#request-app)은 애플리케이션 자체 코드용입니다. +- 요청이 실패하면 `request.response`는 boom 에러이므로, `onPreResponse`는 `.header()`를 호출하는 대신 `response.output.headers`에 헤더를 설정합니다. +- `onPostResponse`는 응답을 보낸 뒤에 실행되므로 클라이언트는 이를 기다리지 않으며, 반환값은 무시됩니다. 다만 hapi는 `onPostResponse` 훅을 하나씩 차례로 실행하므로, 느린 훅은 그 뒤의 훅을 지연시킵니다. 네트워크로 메트릭을 보내는 것처럼 느린 작업은 큐에 넘기고 바로 반환하세요. +- hapi가 이미 [`request.info.received`](/api#request-info)와 `request.info.completed`를 기록하므로 시작 시각을 직접 저장할 필요가 없습니다. + +### URL 재작성 + +`onRequest`는 라우트 조회 전에 실행되는 유일한 훅이므로, 어떤 라우트가 요청을 처리할지 바꾸는 재작성은 여기서만 할 수 있습니다. 재작성은 [`request.setUrl()`](/api#request-seturl-url-striptrailingslash)로 합니다. + +```ts +server.ext('onRequest', (request, h) => { + if (request.path.startsWith('/api/v1/')) { + request.setUrl(request.url.pathname.replace('/api/v1/', '/api/') + request.url.search); + } + + return h.continue; +}); +``` + +예전 클라이언트는 계속 `/api/v1/users?page=2`를 호출하고, `/api/users` 라우트가 쿼리 문자열을 그대로 유지한 채 응답합니다. + +### 점검 모드 + +`onRequest`에서 takeover 응답을 반환하면 어떤 라우트도 실행되기 전에 모든 요청에 응답합니다. + +```ts +let maintenance = false; + +server.ext('onRequest', (request, h) => { + if (!maintenance || request.path === '/health') { + return h.continue; + } + + return h.response({ message: 'Down for maintenance' }).code(503).header('retry-after', '600').takeover(); +}); +``` + +헬스 체크는 계속 접근할 수 있으므로, 로드 밸런서가 계획된 중단과 장애를 구분할 수 있습니다. + +### 역할 불러오기: onCredentials 또는 onPostAuth + +두 훅 모두 인증 뒤에 실행됩니다. 차이는 둘 사이의 인가 단계로, 이 단계에서 라우트의 [`auth.access.scope`](/api#route-options-auth-access-scope)를 검사합니다([스코프](/tutorials/ko_KR/auth#scopes) 참고). + +인가 단계에서 보여야 하는 것을 추가할 때는 `onCredentials`를 사용하세요. 토큰은 사용자를 식별하지만 역할 정보는 담고 있지 않은 경우가 많으므로, 데이터베이스에서 역할을 불러와 스코프로 추가합니다. + +```ts +server.ext('onCredentials', async (request, h) => { + if (!request.auth.isAuthenticated) { + return h.continue; + } + + const { user } = request.auth.credentials; + if (user) { + request.auth.credentials.scope = await loadRoles(user.id); + } + + return h.continue; +}); + +server.route<{ Params: { id: string } }>({ + method: 'DELETE', + path: '/projects/{id}', + options: { auth: { access: { scope: 'admin' } } }, + handler: (request, h) => deleteProject(request.params.id), +}); +``` + +`onCredentials`는 라우트에 인증이 설정되어 있으면 항상 실행됩니다. 자격 증명을 찾지 못해 `request.auth.credentials`가 `null`인 `'try'`나 `'optional'` 라우트도 마찬가지이므로, 먼저 `request.auth.isAuthenticated`를 확인하세요. `user.id`는 [인증 튜토리얼](/tutorials/ko_KR/auth#schemes)에 나온 `UserCredentials` 선언을 전제로 합니다. + +사용자가 할 수 있는 일을 바꾸지 않는 작업이나, `Host` 헤더에 지정된 테넌트를 불러오는 것처럼 인증이 없는 라우트에서도 실행되어야 하는 작업에는 `onPostAuth`를 사용하세요. `onCredentials`는 `auth: false`인 라우트에서는 실행되지 않습니다. + +### 일관된 에러 응답 + +`onPreResponse`는 나가는 모든 응답을 보므로, 에러의 모양을 바꾸는 곳입니다. 에러를 자체 응답으로 교체하는 훅은 401의 `WWW-Authenticate`처럼 에러에 있던 헤더를 유지해야 합니다. + +```ts +import Boom from '@hapi/boom'; + +server.ext( + 'onPreResponse', + (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ error: payload.message }).code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; + }, + { before: ['request-tracing'] }, +); +``` + +새 응답이 에러를 대체하며, 이 훅 다음에 실행되는 `onPreResponse` 훅은 새 응답을 보게 됩니다. `before: ['request-tracing']`을 지정하면 [요청 id 훅](#request-id)이 이 훅 다음에 실행되어 새 응답에 헤더를 추가합니다. 이 옵션이 없으면 요청 id 훅이 먼저 에러에 헤더를 설정하고, 위의 헤더 복사 코드만이 그 헤더를 유지하게 됩니다. 순서는 응답을 교체하는 훅에서 가장 중요합니다. 전체 에러 형식과 HTML 에러 페이지는 [응답 마샬링 튜토리얼](/tutorials/ko_KR/response-marshalling#errors)에 있습니다. + +## 순서와 범위 + +기본적으로 같은 유형의 훅은 추가된 순서대로 실행되며, 서버 훅, 플러그인 훅, 라우트 훅을 모두 합쳐 셉니다. 세 가지 옵션으로 훅이 어떤 요청을 보고 언제 실행될지 바꿀 수 있습니다. + +**`before`와 `after`** 는 플러그인 이름을 받아, 여러분 플러그인의 훅을 그 플러그인들의 훅 기준으로 정렬합니다. yar가 불러온 세션을 읽는 플러그인이라면 다음과 같이 씁니다. + +```ts +server.ext('onPreAuth', loadPreferences, { after: ['@hapi/yar'] }); +``` + +**`sandbox: 'plugin'`** 은 훅을 해당 플러그인이 추가한 라우트로 제한합니다. hapi가 라우트를 알기 전에 실행되는 `onRequest`를 제외한 모든 요청 훅에서 동작합니다. + +```ts +import type { Plugin } from '@hapi/hapi'; + +const adminPanel: Plugin = { + name: 'admin-panel', + register(server) { + server.ext('onPreHandler', auditAdminAccess, { sandbox: 'plugin' }); + + server.route({ method: 'GET', path: '/admin', handler: (request, h) => 'admin' }); + }, +}; +``` + +**라우트 수준 훅** 은 라우트의 [`options.ext`](/api#route-options-ext)에 두며, 그 라우트에서만 실행됩니다. + +```ts +server.route({ + method: 'POST', + path: '/uploads', + options: { + ext: { + onPreHandler: { method: checkStorageQuota }, + }, + }, + handler: (request, h) => saveUpload(request.payload), +}); +``` + +`server.ext()`는 배열도 받으므로, 플러그인의 훅을 한곳에 모아 둘 수 있습니다. + +```ts +server.ext([ + { type: 'onRequest', method: assignRequestId }, + { type: 'onPreResponse', method: addRequestIdHeader }, + { type: 'onPostResponse', method: recordMetrics }, +]); +``` + +## 훅에 타입 지정하기 + +`server.ext()` 안에 인라인으로 작성한 훅에는 타입 표기가 필요 없습니다. TypeScript가 훅 이름으로 `request`와 `h`를 추론하고, 서버 훅에서는 `server`를 추론합니다. 따로 정의한 훅은 `Lifecycle.Method` 타입입니다. + +```ts +import Boom from '@hapi/boom'; + +import type { Lifecycle } from '@hapi/hapi'; + +const checkStorageQuota: Lifecycle.Method = async (request, h) => { + if (!request.auth.isAuthenticated) { + throw Boom.unauthorized(); + } + + const quota = await getQuota(request.auth.credentials.user?.id); + if (quota.used >= quota.limit) { + throw Boom.forbidden('Storage quota exceeded'); + } + + return h.continue; +}; +``` + +훅이 요청에 저장하는 데이터의 타입을 지정하려면 [요청 id 예제](#request-id)처럼 `PluginsStates`를 확장하거나, 애플리케이션 코드의 `request.app`이라면 `RequestApplicationState`를 확장하세요. `user?.id`는 [인증 튜토리얼](/tutorials/ko_KR/auth#schemes)에 나온 `UserCredentials` 선언을 전제로 합니다. diff --git a/docs/tutorials/pt_BR/lifecycle-hooks.md b/docs/tutorials/pt_BR/lifecycle-hooks.md new file mode 100644 index 00000000..78127677 --- /dev/null +++ b/docs/tutorials/pt_BR/lifecycle-hooks.md @@ -0,0 +1,378 @@ +--- +title: Lifecycle hooks +--- + + + +# Lifecycle hooks + +_Este tutorial é compatível com hapi v21 e versões mais recentes_ + +## Visão geral + +O hapi passa toda requisição pela mesma sequência fixa de etapas: busca da rota, autenticação, validação, o handler e assim por diante. Um lifecycle hook, que a referência da API chama de extension point, permite rodar uma função sua em um ponto nomeado dessa sequência. O próprio servidor tem mais quatro hooks para inicialização e desligamento. + +A maioria dos recursos transversais é construída com hooks. Vários plugins oficiais são, basicamente, alguns hooks: + +| Plugin | Hook | O que ele faz ali | +| ---------------------- | --------------- | ---------------------------------------------------------- | +| [yar](/module/yar) | `onPreAuth` | carrega a sessão, para que a autenticação possa usá-la | +| [yar](/module/yar) | `onPreResponse` | salva a sessão em um cookie | +| [crumb](/module/crumb) | `onPostAuth` | rejeita requisições com token CSRF ausente ou errado | +| [crumb](/module/crumb) | `onPreResponse` | adiciona o token CSRF ao contexto de view das páginas HTML | +| [nes](/module/nes) | `onPreStart` | inicia os heartbeats do WebSocket | +| [nes](/module/nes) | `onPreStop` | fecha as conexões WebSocket abertas | + +Você adiciona um hook com [`server.ext()`](/api#server-ext-events); a referência da API tem a lista completa de opções. + +## Server hooks: inicialização e desligamento + +Os server hooks rodam uma vez, quando o servidor inicia ou para. Cada um recebe o `server` e não retorna nada, ou retorna uma promise. Um erro lançado interrompe a inicialização ou o desligamento. + +```mermaid +flowchart TD + ST["server.start()"] --> I["server.initialize()
se ainda não foi chamado"] + I --> C1[os caches iniciam] + C1 --> PS[onPreStart] + PS -->|"initialize() termina aqui"| L[o servidor começa a escutar] + L --> PO[onPostStart] + S["server.stop()"] --> PR[onPreStop] + PR --> U["o servidor para de escutar,
as requisições abertas terminam"] + U --> C2[os caches param] + C2 --> PT[onPostStop] + + class PS,PO,PR,PT highlight +``` + +[`server.start()`](/api#await-server-start) roda `server.initialize()` primeiro, se você ainda não o chamou. `initialize()` para antes da etapa de escuta, então roda `onPreStart` mas nunca abre uma porta. Por isso é a chamada certa em testes, nos quais você envia requisições com `server.inject()`. + +| Hook | Roda | Use para | +| ------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | +| `onPreStart` | antes de o servidor escutar | conectar a bancos de dados e checar a configuração, para que uma falha impeça a inicialização | +| `onPostStart` | depois de o servidor escutar | trabalho que precisa da porta já vinculada, como se registrar em um service discovery | +| `onPreStop` | antes de o servidor parar de escutar | avisar load balancers e conexões de longa duração para irem embora | +| `onPostStop` | depois que as requisições abertas terminam | fechar o pool do banco e outros recursos que as requisições estavam usando | + +Um pool de banco de dados é o caso comum. Conectar em `onPreStart` faz um banco indisponível derrubar o `server.start()` em vez da primeira requisição. Fechar em `onPostStop` espera até que as requisições em andamento terminem suas consultas: + +```ts +import Hapi from '@hapi/hapi'; +import { Pool } from 'pg'; + +import type { Plugin } from '@hapi/hapi'; + +const database: Plugin<{ connectionString: string }> = { + name: 'database', + register(server, options) { + const pool = new Pool({ connectionString: options.connectionString }); + + server.ext('onPreStart', async () => { + await pool.query('SELECT 1'); + }); + + server.ext('onPostStop', async () => { + await pool.end(); + }); + + server.expose('pool', pool); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register({ + plugin: database, + options: { connectionString: 'postgres://localhost/app' }, +}); + +await server.start(); + +process.on('SIGTERM', async () => { + await server.stop({ timeout: 10_000 }); + process.exit(0); +}); +``` + +`server.stop({ timeout })` dá às requisições abertas esse tempo para terminarem antes que suas conexões sejam fechadas. Para compartilhar um serviço como esse pool com os handlers, use uma [decoração de server](/tutorials/pt_BR/decorators#server). + +Duas regras para server hooks: + +- Adicione `onPreStart` antes de chamar `initialize()` ou `start()`. Adicionar um depois lança `Cannot add onPreStart (after) extension after the server was initialized`. +- Quando o trabalho de inicialização de um plugin precisa que outro plugin esteja pronto, coloque-o em um callback de [`server.dependency()`](/api#server-dependency-dependencies-after). O hapi roda o callback durante `onPreStart`, depois dos plugins que ele nomeia, qualquer que seja a ordem em que foram registrados. Veja [Dependências](/tutorials/pt_BR/plugins#dependencies) no tutorial Plugins. + +## Request hooks + +Os request hooks rodam para cada requisição. Os oito hooks ficam entre as etapas do [ciclo de vida da requisição](/api#request-lifecycle): + +```mermaid +flowchart TD + A[onRequest] -->|busca da rota| B[onPreAuth] + B -->|autenticação| C[onCredentials] + C -->|autorização| D[onPostAuth] + D -->|validação| E[onPreHandler] + E -->|handler| F[onPostHandler] + F -->|validação da resposta| G[onPreResponse] + G -->|resposta enviada| H[onPostResponse] + + class A,G,H highlight +``` + +`onRequest`, `onPreResponse` e `onPostResponse` rodam em toda requisição, inclusive em 404s e em requisições rejeitadas pela autenticação ou pela validação. A exceção é uma requisição abortada ou encerrada com `h.close`, que pula `onPreResponse`. `onCredentials` roda sempre que a rota tem autenticação configurada, mesmo quando nenhuma credencial foi encontrada. Os demais rodam quando a requisição chega até eles. + +| Hook | Com o que você pode contar | Uso típico | +| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `onRequest` | o caminho e os headers brutos; ainda sem rota; pode mudar a URL | ids de requisição, reescrita de URL, modo de manutenção | +| `onPreAuth` | `request.route` e os cookies já processados | carregar uma sessão que a autenticação vai ler | +| `onCredentials` | `request.auth.credentials`, antes de os scopes serem verificados | adicionar papéis ou scopes a partir do seu banco | +| `onPostAuth` | autenticação concluída, payload processado mas não validado | checagens de CSRF, carregar o tenant ou a conta | +| `onPreHandler` | params, query e payload validados | checagens que precisam de entrada validada | +| `onPostHandler` | o objeto de resposta do handler; um handler que falha pula para `onPreResponse` | alterar respostas bem-sucedidas | +| `onPreResponse` | o `request.response` final, erros incluídos | páginas de erro, headers de resposta, salvar sessões | +| `onPostResponse` | a resposta já foi enviada | métricas, logs de acesso, limpeza | + +### O que um hook retorna + +Um request hook é um [lifecycle method](/api#lifecycle-methods): recebe `(request, h)` e precisa retornar alguma coisa. O que ele retorna decide o que acontece em seguida: + +| Retorno | `onRequest` | `onPreAuth` até `onPreHandler` | `onPostHandler`, `onPreResponse` | `onPostResponse` | +| ------------------------------ | ------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------- | +| `h.continue` | a próxima etapa roda | a próxima etapa roda | a resposta atual é mantida | ignorado | +| `h.response(value).takeover()` | pula para `onPreResponse` | pula para a validação de resposta da rota, depois `onPreResponse` | substitui a resposta; os hooks seguintes do mesmo tipo são pulados | ignorado | +| um erro, lançado ou retornado | pula para `onPreResponse` | pula para `onPreResponse` | substitui a resposta; os hooks seguintes do mesmo tipo são pulados | lançado, ou boom retornado: registrado em log | +| qualquer outro valor | erro 500 | erro 500 | substitui a resposta; os hooks seguintes do mesmo tipo ainda rodam | ignorado | +| nada (`undefined`) | erro 500 | erro 500 | erro 500 | ignorado | + +[`h.continue`](/api#h-continue) é como um hook diz "nada a fazer aqui". Um takeover é como um hook anterior ao handler responde a requisição ele mesmo, e até um takeover vindo de `onRequest` passa por `onPreResponse`. O [lifecycle workflow](/api#lifecycle-workflow) na referência da API tem as regras completas. + +## Padrões + +### Ids de requisição e tempo de resposta + +Um id de requisição liga o relato de erro de um cliente aos seus logs. Este plugin aceita um id enviado pelo cliente ou cria um, devolve-o em um header e registra quanto tempo cada requisição levou: + +```ts +import { randomUUID } from 'node:crypto'; +import Boom from '@hapi/boom'; + +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginsStates { + 'request-tracing': { id: string }; + } +} + +const requestTracing: Plugin = { + name: 'request-tracing', + register(server) { + server.ext('onRequest', (request, h) => { + const incoming = request.headers['x-request-id']; + request.plugins['request-tracing'] = { id: typeof incoming === 'string' ? incoming : randomUUID() }; + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const { response } = request; + const { id } = request.plugins['request-tracing']; + if (Boom.isBoom(response)) { + response.output.headers['x-request-id'] = id; + } else { + response.header('x-request-id', id); + } + + return h.continue; + }); + + server.ext('onPostResponse', (request, h) => { + const { response } = request; + const statusCode = Boom.isBoom(response) ? response.output.statusCode : response.statusCode; + const duration = request.info.completed - request.info.received; + + request.log(['metrics'], { + id: request.plugins['request-tracing'].id, + path: request.path, + statusCode, + duration, + }); + return h.continue; + }); + }, +}; +``` + +- Um plugin guarda seus dados por requisição sob o próprio nome em [`request.plugins`](/api#request-plugins), e o bloco `declare module` tipa essa entrada pela interface `PluginsStates` do hapi. [`request.app`](/api#request-app) é para o código da sua própria aplicação. +- `request.response` é um erro boom quando a requisição falhou, então `onPreResponse` define o header em `response.output.headers` em vez de chamar `.header()`. +- `onPostResponse` roda depois que a resposta é enviada, então o cliente nunca espera por ele, e seu valor de retorno é ignorado. O hapi roda os hooks `onPostResponse` um depois do outro, então um hook lento ainda atrasa os hooks seguintes. Passe trabalho lento, como enviar métricas pela rede, para uma fila e retorne. +- O hapi já registra [`request.info.received`](/api#request-info) e `request.info.completed`, então não é preciso guardar o horário de início você mesmo. + +### Reescrevendo URLs + +`onRequest` é o único hook que roda antes da busca da rota, então é o único lugar em que uma reescrita muda qual rota atende a requisição. [`request.setUrl()`](/api#request-seturl-url-striptrailingslash) faz a reescrita: + +```ts +server.ext('onRequest', (request, h) => { + if (request.path.startsWith('/api/v1/')) { + request.setUrl(request.url.pathname.replace('/api/v1/', '/api/') + request.url.search); + } + + return h.continue; +}); +``` + +Clientes antigos continuam chamando `/api/v1/users?page=2`, e a rota `/api/users` os atende com a query string intacta. + +### Modo de manutenção + +Uma resposta de takeover vinda de `onRequest` responde a toda requisição antes de qualquer rota rodar: + +```ts +let maintenance = false; + +server.ext('onRequest', (request, h) => { + if (!maintenance || request.path === '/health') { + return h.continue; + } + + return h.response({ message: 'Down for maintenance' }).code(503).header('retry-after', '600').takeover(); +}); +``` + +O health check continua acessível, então seu load balancer consegue distinguir uma parada planejada de uma queda. + +### Carregando papéis: onCredentials ou onPostAuth + +Os dois hooks rodam depois da autenticação. A diferença é a etapa de autorização entre eles, que verifica o [`auth.access.scope`](/api#route-options-auth-access-scope) da rota (veja [Scopes](/tutorials/pt_BR/auth#scopes)). + +Use `onCredentials` quando você adiciona algo que a autorização precisa ver. Um token muitas vezes identifica o usuário mas não traz seus papéis, então você carrega os papéis do seu banco e os adiciona como scopes: + +```ts +server.ext('onCredentials', async (request, h) => { + if (!request.auth.isAuthenticated) { + return h.continue; + } + + const { user } = request.auth.credentials; + if (user) { + request.auth.credentials.scope = await loadRoles(user.id); + } + + return h.continue; +}); + +server.route<{ Params: { id: string } }>({ + method: 'DELETE', + path: '/projects/{id}', + options: { auth: { access: { scope: 'admin' } } }, + handler: (request, h) => deleteProject(request.params.id), +}); +``` + +`onCredentials` roda sempre que a rota tem autenticação configurada, inclusive em rotas `'try'` e `'optional'` em que nenhuma credencial foi encontrada e `request.auth.credentials` é `null`, então verifique `request.auth.isAuthenticated` primeiro. `user.id` depende da declaração de `UserCredentials` mostrada no tutorial [Autenticação](/tutorials/pt_BR/auth#schemes). + +Use `onPostAuth` para trabalho que não muda o que o usuário pode fazer, ou que também precisa rodar em rotas sem autenticação, como carregar o tenant indicado no header `Host`. `onCredentials` nunca roda em uma rota com `auth: false`. + +### Respostas de erro consistentes + +`onPreResponse` vê toda resposta na saída, então é ali que você muda a aparência dos erros. Um hook que substitui um erro por uma resposta própria precisa manter os headers do erro, como `WWW-Authenticate` em um 401: + +```ts +import Boom from '@hapi/boom'; + +server.ext( + 'onPreResponse', + (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ error: payload.message }).code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; + }, + { before: ['request-tracing'] }, +); +``` + +A nova resposta substitui o erro, e os hooks `onPreResponse` que rodam depois deste veem a nova resposta. Com `before: ['request-tracing']`, o [hook de id de requisição](#request-id) roda depois deste e adiciona seu header à nova resposta. Sem isso, o hook de id de requisição definiria seu header primeiro no erro, e só a cópia de headers acima o manteria. A ordem importa mais para hooks que substituem a resposta. O tutorial [Marshalling de respostas](/tutorials/pt_BR/response-marshalling#errors) tem um formato de erro completo e páginas de erro em HTML. + +## Ordem e escopo + +Por padrão, hooks do mesmo tipo rodam na ordem em que foram adicionados, contando juntos os hooks do servidor, dos plugins e das rotas. Três opções mudam quais requisições um hook vê e quando ele roda. + +**`before` e `after`** recebem nomes de plugins e ordenam o hook do seu plugin em relação aos deles. Um plugin que lê a sessão que o yar carrega usaria: + +```ts +server.ext('onPreAuth', loadPreferences, { after: ['@hapi/yar'] }); +``` + +**`sandbox: 'plugin'`** limita um hook às rotas que o plugin dele adicionou. Funciona para todos os request hooks exceto `onRequest`, que roda antes de o hapi saber qual é a rota: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const adminPanel: Plugin = { + name: 'admin-panel', + register(server) { + server.ext('onPreHandler', auditAdminAccess, { sandbox: 'plugin' }); + + server.route({ method: 'GET', path: '/admin', handler: (request, h) => 'admin' }); + }, +}; +``` + +**Hooks de rota** ficam em [`options.ext`](/api#route-options-ext) da rota e só rodam para aquela rota: + +```ts +server.route({ + method: 'POST', + path: '/uploads', + options: { + ext: { + onPreHandler: { method: checkStorageQuota }, + }, + }, + handler: (request, h) => saveUpload(request.payload), +}); +``` + +`server.ext()` também aceita um array, o que mantém os hooks de um plugin juntos em um só lugar: + +```ts +server.ext([ + { type: 'onRequest', method: assignRequestId }, + { type: 'onPreResponse', method: addRequestIdHeader }, + { type: 'onPostResponse', method: recordMetrics }, +]); +``` + +## Tipando hooks + +Hooks escritos inline em `server.ext()` não precisam de anotações: o TypeScript infere `request` e `h` a partir do nome do hook, e infere `server` nos server hooks. Um hook definido separadamente é um `Lifecycle.Method`: + +```ts +import Boom from '@hapi/boom'; + +import type { Lifecycle } from '@hapi/hapi'; + +const checkStorageQuota: Lifecycle.Method = async (request, h) => { + if (!request.auth.isAuthenticated) { + throw Boom.unauthorized(); + } + + const quota = await getQuota(request.auth.credentials.user?.id); + if (quota.used >= quota.limit) { + throw Boom.forbidden('Storage quota exceeded'); + } + + return h.continue; +}; +``` + +Para tipar dados que seus hooks guardam na requisição, estenda `PluginsStates` como no [exemplo de id de requisição](#request-id), ou `RequestApplicationState` para `request.app` no código da aplicação. `user?.id` depende da declaração de `UserCredentials` mostrada no tutorial [Autenticação](/tutorials/pt_BR/auth#schemes). diff --git a/docs/tutorials/tr_TR/lifecycle-hooks.md b/docs/tutorials/tr_TR/lifecycle-hooks.md new file mode 100644 index 00000000..d5d3282b --- /dev/null +++ b/docs/tutorials/tr_TR/lifecycle-hooks.md @@ -0,0 +1,378 @@ +--- +title: Yaşam Döngüsü Hook'ları +--- + + + +# Yaşam Döngüsü Hook'ları + +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ + +## Genel Bakış + +hapi her isteği aynı sabit adım dizisinden geçirir: route arama, kimlik doğrulama, doğrulama, handler ve diğerleri. API referansının extension point (genişletme noktası) dediği bir yaşam döngüsü hook'u, bu dizinin adlandırılmış bir noktasında kendi fonksiyonunu çalıştırmanı sağlar. Sunucunun kendisinde de başlatma ve kapatma için dört hook daha vardır. + +Uygulama geneline yayılan özelliklerin çoğu hook'larla yapılır. Birçok resmi plugin büyük ölçüde birkaç hook'tan ibarettir: + +| Plugin | Hook | Orada ne yapar | +| ---------------------- | --------------- | --------------------------------------------------------- | +| [yar](/module/yar) | `onPreAuth` | oturumu yükler, böylece kimlik doğrulama onu kullanabilir | +| [yar](/module/yar) | `onPreResponse` | oturumu bir cookie'ye kaydeder | +| [crumb](/module/crumb) | `onPostAuth` | CSRF token'ı eksik ya da yanlış olan istekleri reddeder | +| [crumb](/module/crumb) | `onPreResponse` | HTML sayfalarının view context'ine CSRF token'ını ekler | +| [nes](/module/nes) | `onPreStart` | WebSocket heartbeat'lerini başlatır | +| [nes](/module/nes) | `onPreStop` | açık WebSocket bağlantılarını kapatır | + +Bir hook'u [`server.ext()`](/api#server-ext-events) ile eklersin; seçeneklerin tam listesi API referansında. + +## Sunucu hook'ları: başlatma ve kapatma + +Sunucu hook'ları, sunucu başlarken ya da dururken bir kez çalışır. Her biri `server` nesnesini alır ve hiçbir şey ya da bir promise döndürür. Fırlatılan bir hata başlatmayı veya kapatmayı durdurur. + +```mermaid +flowchart TD + ST["server.start()"] --> I["server.initialize()
daha önce çağrılmadıysa"] + I --> C1[önbellekler başlar] + C1 --> PS[onPreStart] + PS -->|"initialize() burada biter"| L[sunucu dinlemeye başlar] + L --> PO[onPostStart] + S["server.stop()"] --> PR[onPreStop] + PR --> U["sunucu dinlemeyi bırakır,
açık istekler tamamlanır"] + U --> C2[önbellekler durur] + C2 --> PT[onPostStop] + + class PS,PO,PR,PT highlight +``` + +[`server.start()`](/api#await-server-start), sen çağırmadıysan önce `server.initialize()` çalıştırır. `initialize()` dinleme adımından önce durur; yani `onPreStart` çalıştırır ama hiçbir zaman port açmaz. Bu da onu, istekleri `server.inject()` ile gönderdiğin testlerde doğru çağrı yapar. + +| Hook | Ne zaman çalışır | Ne için kullanılır | +| ------------- | ---------------------------------- | -------------------------------------------------------------------------------------------- | +| `onPreStart` | sunucu dinlemeye başlamadan önce | veritabanlarına bağlanmak ve yapılandırmayı denetlemek, böylece bir hata başlatmayı durdurur | +| `onPostStart` | sunucu dinlemeye başladıktan sonra | bağlanan portu gerektiren işler, örneğin service discovery'ye kayıt olmak | +| `onPreStop` | sunucu dinlemeyi bırakmadan önce | load balancer'lara ve uzun ömürlü bağlantılara ayrılmalarını söylemek | +| `onPostStop` | açık istekler tamamlandıktan sonra | veritabanı havuzunu ve isteklerin kullandığı diğer kaynakları kapatmak | + +Yaygın örnek bir veritabanı havuzudur. `onPreStart` içinde bağlanmak, veritabanı yoksa ilk isteğin değil `server.start()` çağrısının başarısız olmasını sağlar. `onPostStop` içinde kapatmak, devam eden isteklerin sorgularını bitirmesini bekler: + +```ts +import Hapi from '@hapi/hapi'; +import { Pool } from 'pg'; + +import type { Plugin } from '@hapi/hapi'; + +const database: Plugin<{ connectionString: string }> = { + name: 'database', + register(server, options) { + const pool = new Pool({ connectionString: options.connectionString }); + + server.ext('onPreStart', async () => { + await pool.query('SELECT 1'); + }); + + server.ext('onPostStop', async () => { + await pool.end(); + }); + + server.expose('pool', pool); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register({ + plugin: database, + options: { connectionString: 'postgres://localhost/app' }, +}); + +await server.start(); + +process.on('SIGTERM', async () => { + await server.stop({ timeout: 10_000 }); + process.exit(0); +}); +``` + +`server.stop({ timeout })`, açık isteklere bağlantıları kapatılmadan önce tamamlanmaları için bu kadar süre tanır. Bu havuz gibi bir servisi handler'larla paylaşmak için bir [sunucu decoration'ı](/tutorials/tr_TR/decorators#server) kullan. + +Sunucu hook'ları için iki kural: + +- `onPreStart` hook'unu `initialize()` ya da `start()` çağırmadan önce ekle. Sonradan eklemek `Cannot add onPreStart (after) extension after the server was initialized` hatasını fırlatır. +- Bir plugin'in başlatma işi başka bir plugin'in hazır olmasını gerektiriyorsa, bunu bir [`server.dependency()`](/api#server-dependency-dependencies-after) callback'ine koy. hapi bu callback'i `onPreStart` sırasında, kayıt sırası ne olursa olsun adı verilen plugin'lerden sonra çalıştırır. Plugin'ler eğitimindeki [Bağımlılıklar](/tutorials/tr_TR/plugins#dependencies) bölümüne bak. + +## İstek hook'ları + +İstek hook'ları her istek için çalışır. Sekiz hook, [istek yaşam döngüsünün](/api#request-lifecycle) adımları arasında yer alır: + +```mermaid +flowchart TD + A[onRequest] -->|route arama| B[onPreAuth] + B -->|kimlik doğrulama| C[onCredentials] + C -->|yetkilendirme| D[onPostAuth] + D -->|doğrulama| E[onPreHandler] + E -->|handler| F[onPostHandler] + F -->|yanıt doğrulama| G[onPreResponse] + G -->|yanıt gönderildi| H[onPostResponse] + + class A,G,H highlight +``` + +`onRequest`, `onPreResponse` ve `onPostResponse`; 404'ler ile kimlik doğrulama veya doğrulama tarafından reddedilen istekler dahil her istekte çalışır. İstisna, iptal edilen ya da `h.close` ile kapatılan bir istektir; bu istek `onPreResponse` adımını atlar. `onCredentials`, route'ta kimlik doğrulama yapılandırılmışsa, hiç kimlik bilgisi bulunmasa bile çalışır. Diğerleri istek o noktaya ulaştığında çalışır. + +| Hook | Neye güvenebilirsin | Tipik kullanım | +| ---------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `onRequest` | ham yol ve header'lar; henüz route yok; URL'yi değiştirebilir | istek id'leri, URL yeniden yazma, bakım modu | +| `onPreAuth` | `request.route` ve ayrıştırılmış cookie'ler | kimlik doğrulamanın okuyacağı bir oturumu yüklemek | +| `onCredentials` | scope'lar denetlenmeden önce `request.auth.credentials` | veritabanından roller ya da scope'lar eklemek | +| `onPostAuth` | kimlik doğrulama tamamlanmış, payload ayrıştırılmış ama doğrulanmamış | CSRF denetimleri, tenant'ı ya da hesabı yüklemek | +| `onPreHandler` | params, query ve payload doğrulanmış | doğrulanmış girdi gerektiren denetimler | +| `onPostHandler` | handler'ın response nesnesi; başarısız bir handler doğrudan `onPreResponse` adımına atlar | başarılı yanıtları değiştirmek | +| `onPreResponse` | hatalar dahil son `request.response` | hata sayfaları, yanıt header'ları, oturumları kaydetmek | +| `onPostResponse` | yanıt gönderilmiştir | metrikler, erişim logları, temizlik | + +### Bir hook ne döndürür + +Bir istek hook'u bir [yaşam döngüsü metodudur](/api#lifecycle-methods): `(request, h)` alır ve bir şey döndürmek zorundadır. Döndürdüğü şey sonra ne olacağına karar verir: + +| Dönüş değeri | `onRequest` | `onPreAuth` ile `onPreHandler` arası | `onPostHandler`, `onPreResponse` | `onPostResponse` | +| ------------------------------------ | ----------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------ | +| `h.continue` | sonraki adım çalışır | sonraki adım çalışır | mevcut yanıt korunur | yok sayılır | +| `h.response(value).takeover()` | `onPreResponse` adımına atlar | route'un yanıt doğrulamasına, ardından `onPreResponse` adımına atlar | yanıtı değiştirir; aynı tipteki sonraki hook'lar atlanır | yok sayılır | +| fırlatılan ya da döndürülen bir hata | `onPreResponse` adımına atlar | `onPreResponse` adımına atlar | yanıtı değiştirir; aynı tipteki sonraki hook'lar atlanır | fırlatılan ya da döndürülen boom: loglanır | +| başka herhangi bir değer | 500 hatası | 500 hatası | yanıtı değiştirir; aynı tipteki sonraki hook'lar yine çalışır | yok sayılır | +| hiçbir şey (`undefined`) | 500 hatası | 500 hatası | 500 hatası | yok sayılır | + +[`h.continue`](/api#h-continue), bir hook'un "burada yapacak bir şey yok" deme yoludur. Takeover ise handler'dan önceki bir hook'un isteği kendisinin yanıtlama yoludur; `onRequest` içinden yapılan bir takeover bile `onPreResponse` adımından geçer. Kuralların tamamı API referansındaki [yaşam döngüsü akışı](/api#lifecycle-workflow) bölümünde. + +## Kalıplar + +### İstek id'leri ve süre ölçümü + +Bir istek id'si, istemcinin hata raporunu loglarınla ilişkilendirir. Bu plugin istemciden bir id kabul eder ya da bir tane oluşturur, onu bir header'da geri döndürür ve her isteğin ne kadar sürdüğünü kaydeder: + +```ts +import { randomUUID } from 'node:crypto'; +import Boom from '@hapi/boom'; + +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginsStates { + 'request-tracing': { id: string }; + } +} + +const requestTracing: Plugin = { + name: 'request-tracing', + register(server) { + server.ext('onRequest', (request, h) => { + const incoming = request.headers['x-request-id']; + request.plugins['request-tracing'] = { id: typeof incoming === 'string' ? incoming : randomUUID() }; + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const { response } = request; + const { id } = request.plugins['request-tracing']; + if (Boom.isBoom(response)) { + response.output.headers['x-request-id'] = id; + } else { + response.header('x-request-id', id); + } + + return h.continue; + }); + + server.ext('onPostResponse', (request, h) => { + const { response } = request; + const statusCode = Boom.isBoom(response) ? response.output.statusCode : response.statusCode; + const duration = request.info.completed - request.info.received; + + request.log(['metrics'], { + id: request.plugins['request-tracing'].id, + path: request.path, + statusCode, + duration, + }); + return h.continue; + }); + }, +}; +``` + +- Bir plugin, istek başına verisini [`request.plugins`](/api#request-plugins) içinde kendi adı altında tutar; `declare module` bloğu bu girdinin tipini hapi'nin `PluginsStates` arayüzü üzerinden belirler. [`request.app`](/api#request-app) ise uygulamanın kendi kodu içindir. +- İstek başarısız olduğunda `request.response` bir boom hatasıdır, bu yüzden `onPreResponse`, `.header()` çağırmak yerine header'ı `response.output.headers` üzerine ayarlar. +- `onPostResponse` yanıt gönderildikten sonra çalışır; istemci onu hiç beklemez ve dönüş değeri yok sayılır. hapi `onPostResponse` hook'larını art arda çalıştırır, yani yavaş bir hook kendisinden sonraki hook'ları yine de geciktirir. Metrikleri ağ üzerinden göndermek gibi yavaş işleri bir kuyruğa devret ve dön. +- hapi [`request.info.received`](/api#request-info) ve `request.info.completed` değerlerini zaten kaydeder, bu yüzden başlangıç zamanını kendin saklamana gerek yoktur. + +### URL'leri yeniden yazmak + +`onRequest`, route aramasından önce çalışan tek hook'tur; dolayısıyla bir yeniden yazmanın isteği hangi route'un işleyeceğini değiştirebildiği tek yer burasıdır. Yeniden yazmayı [`request.setUrl()`](/api#request-seturl-url-striptrailingslash) yapar: + +```ts +server.ext('onRequest', (request, h) => { + if (request.path.startsWith('/api/v1/')) { + request.setUrl(request.url.pathname.replace('/api/v1/', '/api/') + request.url.search); + } + + return h.continue; +}); +``` + +Eski istemciler `/api/v1/users?page=2` çağırmaya devam eder ve `/api/users` route'u onlara query string'i bozmadan yanıt verir. + +### Bakım modu + +`onRequest` içinden verilen bir takeover yanıtı, herhangi bir route çalışmadan her isteği yanıtlar: + +```ts +let maintenance = false; + +server.ext('onRequest', (request, h) => { + if (!maintenance || request.path === '/health') { + return h.continue; + } + + return h.response({ message: 'Down for maintenance' }).code(503).header('retry-after', '600').takeover(); +}); +``` + +Sağlık denetimi erişilebilir kalır, böylece load balancer'ın planlı bir kesintiyi çökmeden ayırt edebilir. + +### Rolleri yüklemek: onCredentials mı onPostAuth mı + +İki hook da kimlik doğrulamadan sonra çalışır. Fark, aralarındaki yetkilendirme adımıdır; bu adım route'un [`auth.access.scope`](/api#route-options-auth-access-scope) değerini denetler (bkz. [Scope'lar](/tutorials/tr_TR/auth#scopes)). + +Yetkilendirmenin görmesi gereken bir şey eklediğinde `onCredentials` kullan. Bir token çoğu zaman kullanıcıyı tanımlar ama rollerini taşımaz; bu yüzden rolleri veritabanından yükler ve scope olarak eklersin: + +```ts +server.ext('onCredentials', async (request, h) => { + if (!request.auth.isAuthenticated) { + return h.continue; + } + + const { user } = request.auth.credentials; + if (user) { + request.auth.credentials.scope = await loadRoles(user.id); + } + + return h.continue; +}); + +server.route<{ Params: { id: string } }>({ + method: 'DELETE', + path: '/projects/{id}', + options: { auth: { access: { scope: 'admin' } } }, + handler: (request, h) => deleteProject(request.params.id), +}); +``` + +`onCredentials`, route'ta kimlik doğrulama yapılandırılmışsa her zaman çalışır; buna kimlik bilgisi bulunmayan ve `request.auth.credentials` değerinin `null` olduğu `'try'` ve `'optional'` route'ları da dahildir. Bu yüzden önce `request.auth.isAuthenticated` değerini denetle. `user.id`, [Kimlik Doğrulama](/tutorials/tr_TR/auth#schemes) eğitiminde gösterilen `UserCredentials` bildirimine dayanır. + +Kullanıcının neye izni olduğunu değiştirmeyen ya da kimlik doğrulaması olmayan route'larda da çalışması gereken işler için `onPostAuth` kullan; örneğin `Host` header'ında adı geçen tenant'ı yüklemek. `onCredentials`, `auth: false` olan bir route'ta hiçbir zaman çalışmaz. + +### Tutarlı hata yanıtları + +`onPreResponse` dışarı çıkan her yanıtı görür, bu yüzden hataların nasıl göründüğünü değiştirdiğin yer burasıdır. Bir hatayı kendi yanıtıyla değiştiren bir hook, hatanın header'larını korumak zorundadır; örneğin bir 401'deki `WWW-Authenticate`: + +```ts +import Boom from '@hapi/boom'; + +server.ext( + 'onPreResponse', + (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ error: payload.message }).code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; + }, + { before: ['request-tracing'] }, +); +``` + +Yeni yanıt hatanın yerini alır ve bundan sonra çalışan `onPreResponse` hook'ları yeni yanıtı görür. `before: ['request-tracing']` ile [istek id hook'u](#request-id) bundan sonra çalışır ve header'ını yeni yanıta ekler. Bu olmadan istek id hook'u header'ını önce hataya koyardı ve onu yalnızca yukarıdaki header kopyalama korurdu. Sıralama en çok yanıtı değiştiren hook'lar için önemlidir. [Yanıt Hazırlama](/tutorials/tr_TR/response-marshalling#errors) eğitiminde eksiksiz bir hata biçimi ve HTML hata sayfaları var. + +## Sıralama ve kapsam + +Varsayılan olarak aynı tipteki hook'lar, sunucu hook'ları, plugin hook'ları ve route hook'ları birlikte sayılarak eklendikleri sırayla çalışır. Üç seçenek, bir hook'un hangi istekleri göreceğini ve ne zaman çalışacağını değiştirir. + +**`before` ve `after`** plugin adları alır ve plugin'inin hook'unu onlarınkine göre sıralar. yar'ın yüklediği oturumu okuyan bir plugin şunu kullanır: + +```ts +server.ext('onPreAuth', loadPreferences, { after: ['@hapi/yar'] }); +``` + +**`sandbox: 'plugin'`**, bir hook'u yalnızca kendi plugin'inin eklediği route'larla sınırlar. hapi route'u bilmeden önce çalışan `onRequest` dışındaki tüm istek hook'larında çalışır: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const adminPanel: Plugin = { + name: 'admin-panel', + register(server) { + server.ext('onPreHandler', auditAdminAccess, { sandbox: 'plugin' }); + + server.route({ method: 'GET', path: '/admin', handler: (request, h) => 'admin' }); + }, +}; +``` + +**Route düzeyindeki hook'lar** route'un [`options.ext`](/api#route-options-ext) değerine yazılır ve yalnızca o route için çalışır: + +```ts +server.route({ + method: 'POST', + path: '/uploads', + options: { + ext: { + onPreHandler: { method: checkStorageQuota }, + }, + }, + handler: (request, h) => saveUpload(request.payload), +}); +``` + +`server.ext()` bir dizi de alır; bu, bir plugin'in hook'larını tek bir yerde bir arada tutar: + +```ts +server.ext([ + { type: 'onRequest', method: assignRequestId }, + { type: 'onPreResponse', method: addRequestIdHeader }, + { type: 'onPostResponse', method: recordMetrics }, +]); +``` + +## Hook'ları tiplemek + +`server.ext()` içinde satır içi yazılan hook'lar tip açıklaması gerektirmez: TypeScript `request` ve `h` tiplerini hook adından, sunucu hook'ları için de `server` tipini çıkarır. Ayrı tanımlanan bir hook bir `Lifecycle.Method`'dur: + +```ts +import Boom from '@hapi/boom'; + +import type { Lifecycle } from '@hapi/hapi'; + +const checkStorageQuota: Lifecycle.Method = async (request, h) => { + if (!request.auth.isAuthenticated) { + throw Boom.unauthorized(); + } + + const quota = await getQuota(request.auth.credentials.user?.id); + if (quota.used >= quota.limit) { + throw Boom.forbidden('Storage quota exceeded'); + } + + return h.continue; +}; +``` + +Hook'larının isteğe kaydettiği verileri tiplemek için [istek id örneğinde](#request-id) olduğu gibi `PluginsStates` arayüzünü ya da uygulama kodunda `request.app` için `RequestApplicationState` arayüzünü genişlet. `user?.id`, [Kimlik Doğrulama](/tutorials/tr_TR/auth#schemes) eğitiminde gösterilen `UserCredentials` bildirimine dayanır. diff --git a/docs/tutorials/zh_CN/lifecycle-hooks.md b/docs/tutorials/zh_CN/lifecycle-hooks.md new file mode 100644 index 00000000..afc760be --- /dev/null +++ b/docs/tutorials/zh_CN/lifecycle-hooks.md @@ -0,0 +1,378 @@ +--- +title: 生命周期钩子 +--- + + + +# 生命周期钩子 + +_本教程适用于 hapi v21 及更新版本_ + +## 概述 + +hapi 让每个请求都经过同样一组固定的步骤:路由查找、身份认证、验证、handler 等等。生命周期钩子(API 参考中称为扩展点)让你可以在这个序列中某个具名的位置运行自己的函数。服务器本身还有另外四个钩子,用于启动和关闭。 + +大多数横切功能都是通过钩子实现的。好几个官方插件主要就是由几个钩子组成的: + +| 插件 | 钩子 | 在钩子里做什么 | +| ---------------------- | --------------- | -------------------------------------------- | +| [yar](/module/yar) | `onPreAuth` | 加载会话,供身份认证使用 | +| [yar](/module/yar) | `onPreResponse` | 把会话保存到 cookie 中 | +| [crumb](/module/crumb) | `onPostAuth` | 拒绝缺少 CSRF token 或 token 错误的请求 | +| [crumb](/module/crumb) | `onPreResponse` | 把 CSRF token 添加到 HTML 页面的视图上下文中 | +| [nes](/module/nes) | `onPreStart` | 启动 WebSocket 心跳 | +| [nes](/module/nes) | `onPreStop` | 关闭打开的 WebSocket 连接 | + +使用 [`server.ext()`](/api#server-ext-events) 添加钩子;完整的选项列表见 API 参考。 + +## 服务器钩子:启动与关闭 + +服务器钩子在服务器启动或停止时运行一次。每个钩子接收 `server`,不返回任何值,或返回一个 promise。抛出错误会中止启动或关闭过程。 + +```mermaid +flowchart TD + ST["server.start()"] --> I["server.initialize()
除非已经调用过"] + I --> C1[启动缓存] + C1 --> PS[onPreStart] + PS -->|"initialize() 到此结束"| L[服务器开始监听] + L --> PO[onPostStart] + S["server.stop()"] --> PR[onPreStop] + PR --> U["服务器停止监听,
处理完进行中的请求"] + U --> C2[停止缓存] + C2 --> PT[onPostStop] + + class PS,PO,PR,PT highlight +``` + +如果你还没有调用 `server.initialize()`,[`server.start()`](/api#await-server-start) 会先调用它。`initialize()` 在监听步骤之前结束,因此它会运行 `onPreStart`,但从不打开端口。这使它成为测试中的正确选择,在测试中你通过 `server.inject()` 发送请求。 + +| 钩子 | 运行时机 | 用途 | +| ------------- | ------------------------ | ---------------------------------------------- | +| `onPreStart` | 服务器开始监听之前 | 连接数据库、检查配置,这样一旦失败就会中止启动 | +| `onPostStart` | 服务器开始监听之后 | 需要已绑定端口的工作,例如向服务发现注册 | +| `onPreStop` | 服务器停止监听之前 | 通知负载均衡器和长连接断开 | +| `onPostStop` | 进行中的请求全部完成之后 | 关闭数据库连接池以及请求正在使用的其他资源 | + +数据库连接池是最常见的场景。在 `onPreStart` 中连接,数据库不可用时 `server.start()` 就会失败,而不是等到第一个请求才失败。在 `onPostStop` 中关闭,会等到进行中的请求完成查询之后: + +```ts +import Hapi from '@hapi/hapi'; +import { Pool } from 'pg'; + +import type { Plugin } from '@hapi/hapi'; + +const database: Plugin<{ connectionString: string }> = { + name: 'database', + register(server, options) { + const pool = new Pool({ connectionString: options.connectionString }); + + server.ext('onPreStart', async () => { + await pool.query('SELECT 1'); + }); + + server.ext('onPostStop', async () => { + await pool.end(); + }); + + server.expose('pool', pool); + }, +}; + +const server = Hapi.server({ port: 4000 }); + +await server.register({ + plugin: database, + options: { connectionString: 'postgres://localhost/app' }, +}); + +await server.start(); + +process.on('SIGTERM', async () => { + await server.stop({ timeout: 10_000 }); + process.exit(0); +}); +``` + +`server.stop({ timeout })` 给进行中的请求留出这么长的时间来完成,之后再关闭它们的连接。要与 handler 共享这样的服务(比如这个连接池),请使用[服务器装饰](/tutorials/zh_CN/decorators#server)。 + +服务器钩子的两条规则: + +- 在调用 `initialize()` 或 `start()` 之前添加 `onPreStart`。之后再添加会抛出 `Cannot add onPreStart (after) extension after the server was initialized`。 +- 当某个插件的启动工作需要另一个插件先准备好时,把它放进 [`server.dependency()`](/api#server-dependency-dependencies-after) 回调中。hapi 会在 `onPreStart` 期间、在回调所列插件之后运行它,与插件的注册顺序无关。参见插件教程中的[依赖](/tutorials/zh_CN/plugins#dependencies)一节。 + +## 请求钩子 + +请求钩子在每个请求上运行。这八个钩子位于[请求生命周期](/api#request-lifecycle)的各个步骤之间: + +```mermaid +flowchart TD + A[onRequest] -->|路由查找| B[onPreAuth] + B -->|身份认证| C[onCredentials] + C -->|授权| D[onPostAuth] + D -->|验证| E[onPreHandler] + E -->|handler| F[onPostHandler] + F -->|响应验证| G[onPreResponse] + G -->|发送响应| H[onPostResponse] + + class A,G,H highlight +``` + +`onRequest`、`onPreResponse` 和 `onPostResponse` 在每个请求上都会运行,包括 404 以及被身份认证或验证拒绝的请求。例外是被中止或通过 `h.close` 关闭的请求,它们会跳过 `onPreResponse`。只要路由配置了身份认证,`onCredentials` 就会运行,即使没有找到凭证。其余钩子只有在请求走到那一步时才会运行。 + +| 钩子 | 此时可以依赖的内容 | 典型用途 | +| ---------------- | ------------------------------------------------------------ | --------------------------- | +| `onRequest` | 原始路径和请求头;尚未确定路由;可以修改 URL | 请求 id、URL 重写、维护模式 | +| `onPreAuth` | `request.route` 和已解析的 cookie | 加载身份认证将要读取的会话 | +| `onCredentials` | `request.auth.credentials`,此时尚未检查 scope | 从数据库添加角色或 scope | +| `onPostAuth` | 身份认证已完成,payload 已解析但尚未验证 | CSRF 检查,加载租户或账户 | +| `onPreHandler` | params、query 和 payload 已通过验证 | 需要已验证输入的检查 | +| `onPostHandler` | handler 的响应对象;handler 失败时会直接跳到 `onPreResponse` | 修改成功的响应 | +| `onPreResponse` | 最终的 `request.response`,包括错误 | 错误页面、响应头、保存会话 | +| `onPostResponse` | 响应已经发送 | 指标、访问日志、清理工作 | + +### 钩子的返回值 + +请求钩子是一种[生命周期方法](/api#lifecycle-methods):它接收 `(request, h)`,并且必须返回某个值。返回什么决定了接下来发生什么: + +| 返回值 | `onRequest` | `onPreAuth` 到 `onPreHandler` | `onPostHandler`、`onPreResponse` | `onPostResponse` | +| ------------------------------ | -------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------- | +| `h.continue` | 运行下一步 | 运行下一步 | 保留当前响应 | 忽略 | +| `h.response(value).takeover()` | 跳到 `onPreResponse` | 跳到路由的响应验证,然后是 `onPreResponse` | 替换响应;同类型的后续钩子被跳过 | 忽略 | +| 一个错误,抛出或返回 | 跳到 `onPreResponse` | 跳到 `onPreResponse` | 替换响应;同类型的后续钩子被跳过 | 抛出的错误或返回的 boom:记录日志 | +| 其他任何值 | 500 错误 | 500 错误 | 替换响应;同类型的后续钩子仍然运行 | 忽略 | +| 什么都不返回(`undefined`) | 500 错误 | 500 错误 | 500 错误 | 忽略 | + +[`h.continue`](/api#h-continue) 是钩子表示 "这里没什么要做的" 的方式。takeover 是 handler 之前的钩子自己应答请求的方式,即使是来自 `onRequest` 的 takeover 也会经过 `onPreResponse`。完整规则见 API 参考中的[生命周期流程](/api#lifecycle-workflow)。 + +## 常见模式 + +### 请求 id 与耗时 + +请求 id 把客户端的错误报告和你的日志关联起来。下面这个插件接受客户端传来的 id,或者自己生成一个,在响应头中返回它,并记录每个请求的耗时: + +```ts +import { randomUUID } from 'node:crypto'; +import Boom from '@hapi/boom'; + +import type { Plugin } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface PluginsStates { + 'request-tracing': { id: string }; + } +} + +const requestTracing: Plugin = { + name: 'request-tracing', + register(server) { + server.ext('onRequest', (request, h) => { + const incoming = request.headers['x-request-id']; + request.plugins['request-tracing'] = { id: typeof incoming === 'string' ? incoming : randomUUID() }; + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const { response } = request; + const { id } = request.plugins['request-tracing']; + if (Boom.isBoom(response)) { + response.output.headers['x-request-id'] = id; + } else { + response.header('x-request-id', id); + } + + return h.continue; + }); + + server.ext('onPostResponse', (request, h) => { + const { response } = request; + const statusCode = Boom.isBoom(response) ? response.output.statusCode : response.statusCode; + const duration = request.info.completed - request.info.received; + + request.log(['metrics'], { + id: request.plugins['request-tracing'].id, + path: request.path, + statusCode, + duration, + }); + return h.continue; + }); + }, +}; +``` + +- 插件把自己的每请求数据以自己的名称为键存放在 [`request.plugins`](/api#request-plugins) 中,`declare module` 代码块通过 hapi 的 `PluginsStates` 接口为这一项提供类型。[`request.app`](/api#request-app) 则留给应用自己的代码使用。 +- 请求失败时,`request.response` 是一个 boom 错误,所以 `onPreResponse` 把响应头设置在 `response.output.headers` 上,而不是调用 `.header()`。 +- `onPostResponse` 在响应发送之后运行,因此客户端永远不用等它,它的返回值也会被忽略。hapi 依次运行各个 `onPostResponse` 钩子,所以一个慢的钩子仍然会拖慢它后面的钩子。把通过网络发送指标这类慢操作交给队列处理,然后直接返回。 +- hapi 已经记录了 [`request.info.received`](/api#request-info) 和 `request.info.completed`,因此不需要自己保存开始时间。 + +### 重写 URL + +`onRequest` 是唯一在路由查找之前运行的钩子,所以只有在这里重写 URL 才能改变由哪个路由处理请求。[`request.setUrl()`](/api#request-seturl-url-striptrailingslash) 负责重写: + +```ts +server.ext('onRequest', (request, h) => { + if (request.path.startsWith('/api/v1/')) { + request.setUrl(request.url.pathname.replace('/api/v1/', '/api/') + request.url.search); + } + + return h.continue; +}); +``` + +旧客户端继续调用 `/api/v1/users?page=2`,由 `/api/users` 路由应答,查询字符串保持不变。 + +### 维护模式 + +来自 `onRequest` 的 takeover 响应会在任何路由运行之前应答所有请求: + +```ts +let maintenance = false; + +server.ext('onRequest', (request, h) => { + if (!maintenance || request.path === '/health') { + return h.continue; + } + + return h.response({ message: 'Down for maintenance' }).code(503).header('retry-after', '600').takeover(); +}); +``` + +健康检查仍然可以访问,这样负载均衡器就能区分计划内的停机和崩溃。 + +### 加载角色:onCredentials 还是 onPostAuth + +这两个钩子都在身份认证之后运行。区别在于它们之间的授权步骤,该步骤会检查路由的 [`auth.access.scope`](/api#route-options-auth-access-scope)(参见 [Scopes](/tutorials/zh_CN/auth#scopes))。 + +当你要添加授权步骤需要看到的内容时,使用 `onCredentials`。token 通常能标识用户,但不携带用户的角色,所以你从数据库加载角色,并将其作为 scope 添加进去: + +```ts +server.ext('onCredentials', async (request, h) => { + if (!request.auth.isAuthenticated) { + return h.continue; + } + + const { user } = request.auth.credentials; + if (user) { + request.auth.credentials.scope = await loadRoles(user.id); + } + + return h.continue; +}); + +server.route<{ Params: { id: string } }>({ + method: 'DELETE', + path: '/projects/{id}', + options: { auth: { access: { scope: 'admin' } } }, + handler: (request, h) => deleteProject(request.params.id), +}); +``` + +只要路由配置了身份认证,`onCredentials` 就会运行,包括在 `'try'` 和 `'optional'` 路由上没有找到凭证、`request.auth.credentials` 为 `null` 的情况,所以要先检查 `request.auth.isAuthenticated`。`user.id` 依赖于[身份认证教程](/tutorials/zh_CN/auth#schemes)中展示的 `UserCredentials` 声明。 + +对于不改变用户权限的工作,或者必须在没有身份认证的路由上也运行的工作(例如加载 `Host` 请求头中指定的租户),使用 `onPostAuth`。在 `auth: false` 的路由上,`onCredentials` 永远不会运行。 + +### 统一的错误响应 + +`onPreResponse` 能看到每个即将发出的响应,所以这里是改变错误外观的地方。用自己的响应替换错误的钩子必须保留错误的响应头,例如 401 上的 `WWW-Authenticate`: + +```ts +import Boom from '@hapi/boom'; + +server.ext( + 'onPreResponse', + (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ error: payload.message }).code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; + }, + { before: ['request-tracing'] }, +); +``` + +新的响应会替换错误,在此之后运行的 `onPreResponse` 钩子看到的是新响应。有了 `before: ['request-tracing']`,[请求 id 钩子](#request-id)会在这个钩子之后运行,并把它的响应头添加到新响应上。没有这个选项的话,请求 id 钩子会先把响应头设置在错误上,只能靠上面复制响应头的代码来保留它。对于会替换响应的钩子,顺序尤其重要。[响应编组教程](/tutorials/zh_CN/response-marshalling#errors)中有完整的错误格式和 HTML 错误页面。 + +## 顺序与作用范围 + +默认情况下,同类型的钩子按添加顺序运行,服务器钩子、插件钩子和路由钩子一起计算。有三个选项可以改变钩子能看到哪些请求以及何时运行。 + +**`before` 和 `after`** 接受插件名称,用于确定你的插件钩子相对于这些插件钩子的顺序。读取 yar 所加载会话的插件可以这样写: + +```ts +server.ext('onPreAuth', loadPreferences, { after: ['@hapi/yar'] }); +``` + +**`sandbox: 'plugin'`** 将钩子限制在其插件所添加的路由上。它适用于除 `onRequest` 之外的所有请求钩子,因为它运行时 hapi 还不知道路由: + +```ts +import type { Plugin } from '@hapi/hapi'; + +const adminPanel: Plugin = { + name: 'admin-panel', + register(server) { + server.ext('onPreHandler', auditAdminAccess, { sandbox: 'plugin' }); + + server.route({ method: 'GET', path: '/admin', handler: (request, h) => 'admin' }); + }, +}; +``` + +**路由级钩子** 写在路由的 [`options.ext`](/api#route-options-ext) 中,只对该路由运行: + +```ts +server.route({ + method: 'POST', + path: '/uploads', + options: { + ext: { + onPreHandler: { method: checkStorageQuota }, + }, + }, + handler: (request, h) => saveUpload(request.payload), +}); +``` + +`server.ext()` 也接受数组,这样一个插件的钩子可以集中放在一处: + +```ts +server.ext([ + { type: 'onRequest', method: assignRequestId }, + { type: 'onPreResponse', method: addRequestIdHeader }, + { type: 'onPostResponse', method: recordMetrics }, +]); +``` + +## 为钩子添加类型 + +直接写在 `server.ext()` 中的钩子不需要类型注解:TypeScript 会根据钩子名称推断出 `request` 和 `h`,对服务器钩子则推断出 `server`。单独定义的钩子是一个 `Lifecycle.Method`: + +```ts +import Boom from '@hapi/boom'; + +import type { Lifecycle } from '@hapi/hapi'; + +const checkStorageQuota: Lifecycle.Method = async (request, h) => { + if (!request.auth.isAuthenticated) { + throw Boom.unauthorized(); + } + + const quota = await getQuota(request.auth.credentials.user?.id); + if (quota.used >= quota.limit) { + throw Boom.forbidden('Storage quota exceeded'); + } + + return h.continue; +}; +``` + +要为钩子存放在请求上的数据提供类型,可以像[请求 id 示例](#request-id)那样扩展 `PluginsStates`,在应用代码中则为 `request.app` 扩展 `RequestApplicationState`。`user?.id` 依赖于[身份认证教程](/tutorials/zh_CN/auth#schemes)中展示的 `UserCredentials` 声明。 From 32951081ab62fb01c4784f1ddfc60772ba409dc5 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:55:11 -0400 Subject: [PATCH 20/22] docs: add response marshalling tutorial --- .vitepress/config.ts | 1 + docs/tutorials/en_US/response-marshalling.md | 352 +++++++++++++++++++ docs/tutorials/es_ES/response-marshalling.md | 352 +++++++++++++++++++ docs/tutorials/ko_KR/response-marshalling.md | 352 +++++++++++++++++++ docs/tutorials/pt_BR/response-marshalling.md | 352 +++++++++++++++++++ docs/tutorials/tr_TR/response-marshalling.md | 352 +++++++++++++++++++ docs/tutorials/zh_CN/response-marshalling.md | 352 +++++++++++++++++++ 7 files changed, 2113 insertions(+) create mode 100644 docs/tutorials/en_US/response-marshalling.md create mode 100644 docs/tutorials/es_ES/response-marshalling.md create mode 100644 docs/tutorials/ko_KR/response-marshalling.md create mode 100644 docs/tutorials/pt_BR/response-marshalling.md create mode 100644 docs/tutorials/tr_TR/response-marshalling.md create mode 100644 docs/tutorials/zh_CN/response-marshalling.md diff --git a/.vitepress/config.ts b/.vitepress/config.ts index eee4cb58..55b00d16 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -61,6 +61,7 @@ const tutorialItems = [ { slug: 'lifecycle-hooks', text: 'Lifecycle Hooks' }, { slug: 'logging', text: 'Logging' }, { slug: 'plugins', text: 'Plugins' }, + { slug: 'response-marshalling', text: 'Response Marshalling' }, { slug: 'routing', text: 'Routing' }, { slug: 'server-methods', text: 'Server Methods' }, { slug: 'serving-files', text: 'Serving Files' }, diff --git a/docs/tutorials/en_US/response-marshalling.md b/docs/tutorials/en_US/response-marshalling.md new file mode 100644 index 00000000..690c8fd2 --- /dev/null +++ b/docs/tutorials/en_US/response-marshalling.md @@ -0,0 +1,352 @@ +--- +title: Response Marshalling +--- + + + +# Response Marshalling + +_This tutorial is compatible with hapi v21 and newer_ + +## Overview + +A handler returns a value: a string, an object, a stream, an error. Response marshalling is everything hapi does to turn that value into an HTTP response: picking a status code, setting headers, serializing the payload, compressing it, and writing it to the socket. + +Knowing how it works tells you where to put code. A consistent error format, an HTML error page, caching headers, compression, a custom response type like CSV, a byte counter for metrics: each of these belongs at a specific point in the marshalling pipeline. The [response object](/api#response-object) and [response toolkit](/api#response-toolkit) API references list every method. + +## From return value to bytes + +Marshalling happens in two phases with the `onPreResponse` extension point in between: + +```mermaid +flowchart TD + A["handler returns a value"] --> B["wrap and prepare
value becomes a response object"] + B --> C["onPostHandler,
response validation"] + C --> D["onPreResponse
last chance to change it"] + D --> E["marshal
headers and payload serialization"] + E --> F["204 for empty payloads,
compression or range"] + F --> G["written to the socket"] + + class B,D,E highlight +``` + +The marshal step runs in a fixed order, which decides which settings can see which headers: + +1. `content-type` from the payload type +2. CORS and security headers, if the route enables them +3. ETag and `last-modified` from [`h.entity()`](/api#h-entity-options), then the 304 check on GET routes +4. `cache-control` from the route's `cache` option +5. `set-cookie` from [`h.state()`](/api#h-state-name-value-options) and cookies with an `autoValue` +6. payload serialization and `content-length` +7. the auth scheme's `response()` method, last, so it can read every header above + +The split matters in practice: + +- **Before `onPreResponse`**, the response is a response object or a [boom](/module/boom) error, and you can inspect it, change it, or replace it. Errors raised while wrapping and preparing, such as a file that does not exist, arrive in `onPreResponse` as boom errors. +- **After `onPreResponse`**, hapi serializes the payload and computes headers. An error at this stage, such as an object that `JSON.stringify()` cannot handle, becomes a 500 that `onPreResponse` never sees. Keep payloads plain data so marshalling cannot fail. + +The order of the [request lifecycle](/api#request-lifecycle) steps is covered in the [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks) tutorial. + +## What you return, what hapi sends + +| Handler returns | Status | Default `content-type` | Notes | +| ------------------- | -------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| string | 200, or 204 if empty | `text/html; charset=utf-8` | Set `.type('text/plain')` for plain text | +| object or array | 200 | `application/json; charset=utf-8` | Serialized with `JSON.stringify()` and the route's [`json` options](#json) | +| `Buffer` | 200 | `application/octet-stream` | | +| readable stream | 200 | `application/octet-stream` | Sent chunked. Must not be in object mode. Copies `statusCode` and `headers` if the stream has them | +| `null` | 204 | none | Set the route's `response.emptyStatusCode` to `200` to send 200 instead | +| `undefined` | 500 | | A handler must return something; hapi logs "handler method did not return a value" | +| `Error` | 500 | `application/json; charset=utf-8` | The message is hidden from the client | +| boom error | the error's status | `application/json; charset=utf-8` | `{ statusCode, error, message }`; 5xx messages are hidden | +| `h.response(value)` | as for `value` | as for `value` | Chain `.code()`, `.header()`, `.type()` and the other [response object](/api#response-object) methods | +| `h.redirect(uri)` | 302 | `text/html; charset=utf-8` | Empty body. `.permanent()` gives 301, `.rewritable(false)` gives 307, both give 308. 307 and 308 make the client repeat the same method, so a redirected POST stays a POST | + +- `Readable.from(array)` creates a stream in object mode, which hapi rejects with a 500. Pass `{ objectMode: false }`, or return a byte stream such as `Fs.createReadStream()`. +- A stream with `statusCode` and `headers` properties, such as an HTTP response from [wreck](/module/wreck), passes its status and headers through. That makes a basic proxy a one-line handler, and also means an upstream 404 becomes your 404. + +## Shaping a response with h.response() + +Return a plain value when the defaults fit. When they don't, wrap the value with [`h.response()`](/api#h-response-value) and chain what you need: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/users', + handler: (request, h) => { + const user = { id: '2133d32a', name: request.payload.name }; + + return h.response(user).created(`/users/${user.id}`).header('x-request-id', request.info.id); + }, +}); + +server.route({ + method: 'GET', + path: '/robots.txt', + handler: (request, h) => { + return h.response('User-agent: *\nDisallow: /admin\n').type('text/plain'); + }, +}); +``` + +`.created()` sets the status to 201 and the `location` header in one call. + +## Errors + +Every error ends up as a boom object. hapi wraps a plain `Error` with `Boom.boomify()`, which gives it status 500, and boom hides the message of any 5xx error from the client. Your users see `An internal server error occurred`, and hapi emits the real error as a `'request'` event on the `'error'` channel; see [Logging](/tutorials/en_US/logging) to record it. Throw a boom error such as `Boom.notFound('No such user')` when the client should see the message. + +A boom error's response comes from its `output` property: + +```ts +import Boom from '@hapi/boom'; + +const error = Boom.notFound('No such user'); + +error.output.statusCode; // 404 +error.output.payload; // { statusCode: 404, error: 'Not Found', message: 'No such user' } +error.output.headers; // {}, or headers such as WWW-Authenticate from auth errors +``` + +### A consistent error format + +APIs often need errors in their own format rather than boom's. Convert them in `onPreResponse`, where every error passes through, including 404s for unknown routes and errors thrown by plugins: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ + error: { + status: statusCode, + code: payload.error, + message: payload.message, + requestId: request.info.id, + }, + }); + + reply.code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; +}); +``` + +Copy `output.headers` to the new response. Auth errors carry `WWW-Authenticate` there, and `Boom.methodNotAllowed()` can carry `Allow`. `payload.message` is already the safe message: for 5xx errors boom has replaced it with the generic text. + +### HTML error pages + +A site that renders HTML can return an error page from the same hook instead; the [Views tutorial](/tutorials/en_US/views#errors) shows it with @hapi/vision. In an app that serves both pages and an API, skip the page for API routes, for example with `request.path.startsWith('/api/')`, so API clients keep getting JSON. + +## Custom response types + +Plugins that send files and templates don't return strings. [@hapi/inert](/module/inert) and [@hapi/vision](/module/vision) create their responses with [`request.generateResponse()`](/api#request-generateresponse-source-options), which takes a source value and optional processors. `prepare` and `marshal` run at the two phases of the pipeline, and `close` runs when the response is done, whether it was sent or replaced: + +| Plugin | `variety` | `prepare` runs before `onPreResponse` | `marshal` runs after `onPreResponse` | +| ------ | --------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| inert | `'file'` | opens the file; sets `content-length`, `content-type`, `last-modified`, ETag | returns a read stream, or a precompressed file (`.gz`, or other extensions from `lookupMap`) | +| vision | `'view'` | loads and compiles the template | renders the template with its context; sets `content-type` | + +The split is deliberate. A missing file fails in `prepare`, so it reaches `onPreResponse` as a 404 your error handler can format. The expensive work, reading the file or rendering the template, happens in `marshal`, after `onPreResponse` has had the chance to replace the response. inert also passes `close`, which closes the file even when an `onPreResponse` hook replaced the response; a custom response that opens a resource needs one too. + +You can use the same mechanism for your own formats. Here is a CSV response: + +```ts +import Hapi from '@hapi/hapi'; + +import type { Request } from '@hapi/hapi'; + +const field = (value: string) => (/[",\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value); + +const csv = (request: Request, rows: string[][]) => { + return request + .generateResponse(rows, { + variety: 'csv', + marshal: async () => rows.map((row) => row.map(field).join(',')).join('\n'), + }) + .type('text/csv'); +}; + +const server = Hapi.server({ port: 4000 }); + +server.route({ + method: 'GET', + path: '/users.csv', + handler: (request, h) => { + return csv(request, [ + ['id', 'name'], + ['2133d32a', 'John Doe'], + ]); + }, +}); +``` + +`field()` quotes values that contain a comma, a quote, or a line break, so they cannot split a row. To call it as `h.csv(rows)` from any handler, add it to the response toolkit with a decorator; see the [Decorators](/tutorials/en_US/decorators) tutorial. + +## JSON serialization + +Objects are serialized with `JSON.stringify()`. The route's [`json` options](/api#route-options-json) control how: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ + port: 4000, + routes: { + json: { + space: process.env.NODE_ENV === 'production' ? 0 : 2, + }, + }, +}); + +server.route({ + method: 'GET', + path: '/widget-config', + options: { + json: { escape: true }, + }, + handler: (request, h) => { + return { title: '' }; + }, +}); +``` + +- `space` indents the output. Readable JSON helps during development and costs bytes in production. +- `escape: true` writes `<`, `>`, and `&` as `\u003c`, `\u003e`, and `\u0026`, and also escapes the U+2028 and U+2029 line separators. Use it on any route whose JSON gets embedded in an HTML page, so a value containing `` cannot close the surrounding script tag. +- `replacer` is passed to `JSON.stringify()`, and `suffix` is appended to the output. A single response can override them with `.spaces()`, `.replacer()`, and `.suffix()`. + +Don't rely on `replacer` to remove secrets such as password hashes. Build the response object from the fields you mean to send, and use [response validation](#validation) to catch mistakes. + +## Caching and conditional requests + +The route's [`cache` option](/api#route-options-cache) sets `cache-control` during marshalling: + +```ts +server.route({ + method: 'GET', + path: '/articles/{id}', + options: { + cache: { expiresIn: 60 * 1000, privacy: 'public' }, + }, + handler: (request, h) => { + const article = { id: request.params.id, version: 7, body: '...' }; + + const notModified = h.entity({ etag: `${article.id}-${article.version}` }); + if (notModified) { + return notModified; + } + + return article; + }, +}); +``` + +- `cache` produces `cache-control: max-age=60, must-revalidate, public`. hapi switches it to `private` on its own when the request is authenticated. Cookies set with `h.state()` leave it unchanged, and session plugins such as yar and @hapi/cookie set theirs that way, so on a route that sets a cookie use `privacy: 'private'` or no `cache` at all. Otherwise a shared cache can store one user's cookie and hand it to another. +- [`h.entity()`](/api#h-entity-options) compares the ETag with the request's `If-None-Match` header. When they match, it returns a 304 response, and the handler skips building the body. When they don't, it sets the `etag` header and returns nothing. + +Base the ETag on something cheap that changes whenever the content does, such as a version number or an update timestamp. Hashing the full body saves bandwidth but not the work of building it. + +## Compression + +hapi compresses a response with gzip or deflate when all of these are true: + +- the client sends a matching `Accept-Encoding` header, +- the content type is compressible (text, JSON, JavaScript, SVG, and similar; not images or archives), +- the payload is at least [`compression.minBytes`](/api#server-options-compression-minbytes), 1024 bytes by default, or its size is unknown, as with streams. + +hapi also adds `vary: accept-encoding`, so caches keep compressed and uncompressed copies apart. To support Brotli, register an encoder with [`server.encoder()`](/api#server-encoder-encoding-encoder): + +```ts +import Zlib from 'node:zlib'; + +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.encoder('br', (options) => Zlib.createBrotliCompress(options)); +``` + +hapi now prefers `br` when the client accepts it. A route's [`compression` option](/api#route-options-compression) passes settings to an encoder, such as `compression: { br: { params: { [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 } } }` to trade size for speed. When a payload is already compressed, such as a `.gz` file read from disk, mark it with `.compressed('gzip')` so hapi sets the header and leaves the bytes alone. + +## Response validation + +[Response validation](/api#route-options-response) checks the payload against a schema before `onPreResponse`. It catches handlers that return the wrong shape, including fields that should never leave the server: + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; + +const server = Hapi.server({ port: 4000 }); + +const User = Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), +}); + +server.route({ + method: 'GET', + path: '/users/{id}', + options: { + response: { + schema: User, + sample: process.env.NODE_ENV === 'production' ? 10 : 100, + failAction: process.env.NODE_ENV === 'production' ? 'log' : 'error', + }, + }, + handler: (request, h) => { + return { id: request.params.id, name: 'John Doe' }; + }, +}); +``` + +- With the default `failAction: 'error'`, a payload that fails the schema becomes a 500. Use it in development and tests, so mistakes fail loudly. +- `failAction: 'log'` sends the original payload and logs the failure with the tags `validation`, `response`, and `error`. In production this keeps a schema mistake from turning into an outage, but the payload still goes out, including any field the schema rejected. +- `sample` validates only a percentage of responses, which limits the cost on busy routes. + +To remove unknown fields instead of rejecting them, set `modify: true` and `options: { stripUnknown: true }`, and remove `sample`. With `modify`, validation is part of your response logic, so hapi validates every response and does not accept `sample`. + +## Measuring responses + +A response object emits [events](/api#response-events) while it is written: `'peek'` for each chunk and `'finish'` at the end. A plugin can use them to record response sizes: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (Boom.isBoom(response)) { + return h.continue; + } + + let bytes = 0; + response.events.on('peek', (chunk) => { + bytes += Buffer.byteLength(chunk); + }); + response.events.once('finish', () => { + request.log(['metrics'], { path: request.path, bytes }); + }); + + return h.continue; +}); +``` + +`'peek'` sees the payload before compression, so `bytes` is the uncompressed size. Error responses are skipped here: a boom error becomes a new response object during marshalling, after this hook runs. For timing a whole request, use the `onPostResponse` extension or the server's `'response'` event, covered in [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks) and [Logging](/tutorials/en_US/logging). diff --git a/docs/tutorials/es_ES/response-marshalling.md b/docs/tutorials/es_ES/response-marshalling.md new file mode 100644 index 00000000..538c4190 --- /dev/null +++ b/docs/tutorials/es_ES/response-marshalling.md @@ -0,0 +1,352 @@ +--- +title: Serialización de respuestas +--- + + + +# Serialización de respuestas + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Un handler devuelve un valor: un string, un objeto, un stream, un error. La serialización de respuestas (response marshalling) es todo lo que hace hapi para convertir ese valor en una respuesta HTTP: elegir el código de estado, poner las cabeceras, serializar el payload, comprimirlo y escribirlo en el socket. + +Saber cómo funciona te dice dónde poner el código. Un formato de error uniforme, una página de error en HTML, cabeceras de caché, compresión, un tipo de respuesta propio como CSV, un contador de bytes para métricas: cada una de estas cosas tiene su sitio en un punto concreto del proceso de serialización. Las referencias de la API del [objeto response](/api#response-object) y del [response toolkit](/api#response-toolkit) recogen todos los métodos. + +## Del valor devuelto a los bytes + +La serialización ocurre en dos fases, con el punto de extensión `onPreResponse` entre ambas: + +```mermaid +flowchart TD + A["el handler devuelve un valor"] --> B["envolver y preparar
el valor pasa a ser un objeto response"] + B --> C["onPostHandler,
validación de la respuesta"] + C --> D["onPreResponse
última ocasión para cambiarla"] + D --> E["serializar
cabeceras y serialización del payload"] + E --> F["204 para payloads vacíos,
compresión o rangos"] + F --> G["se escribe en el socket"] + + class B,D,E highlight +``` + +El paso de serialización sigue un orden fijo, que determina qué ajustes pueden ver qué cabeceras: + +1. `content-type` según el tipo del payload +2. cabeceras CORS y de seguridad, si la ruta las activa +3. ETag y `last-modified` desde [`h.entity()`](/api#h-entity-options), y después la comprobación del 304 en las rutas GET +4. `cache-control` según la opción `cache` de la ruta +5. `set-cookie` desde [`h.state()`](/api#h-state-name-value-options) y las cookies con `autoValue` +6. serialización del payload y `content-length` +7. el método `response()` del scheme de autenticación, el último, para que pueda leer todas las cabeceras anteriores + +En la práctica, la separación importa: + +- **Antes de `onPreResponse`**, la respuesta es un objeto response o un error de [boom](/module/boom), y puedes inspeccionarla, modificarla o sustituirla. Los errores que se producen al envolver y preparar, como un fichero que no existe, llegan a `onPreResponse` como errores de boom. +- **Después de `onPreResponse`**, hapi serializa el payload y calcula las cabeceras. Un error en esta fase, como un objeto que `JSON.stringify()` no puede procesar, se convierte en un 500 que `onPreResponse` nunca ve. Usa payloads que sean datos simples para que la serialización no pueda fallar. + +El orden de los pasos del [ciclo de vida de la petición](/api#request-lifecycle) se explica en el tutorial [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks). + +## Qué devuelves y qué envía hapi + +| El handler devuelve | Estado | `content-type` por defecto | Notas | +| ------------------- | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| string | 200, o 204 si está vacío | `text/html; charset=utf-8` | Usa `.type('text/plain')` para texto plano | +| objeto o array | 200 | `application/json; charset=utf-8` | Se serializa con `JSON.stringify()` y las [opciones `json`](#json) de la ruta | +| `Buffer` | 200 | `application/octet-stream` | | +| stream legible | 200 | `application/octet-stream` | Se envía por chunks. No puede estar en modo objeto. Copia `statusCode` y `headers` si el stream los tiene | +| `null` | 204 | ninguno | Pon `response.emptyStatusCode` de la ruta a `200` para enviar un 200 | +| `undefined` | 500 | | Un handler tiene que devolver algo; hapi registra "handler method did not return a value" | +| `Error` | 500 | `application/json; charset=utf-8` | El mensaje no se muestra al cliente | +| error de boom | el estado del error | `application/json; charset=utf-8` | `{ statusCode, error, message }`; los mensajes 5xx se ocultan | +| `h.response(value)` | el de `value` | el de `value` | Encadena `.code()`, `.header()`, `.type()` y los demás métodos del [objeto response](/api#response-object) | +| `h.redirect(uri)` | 302 | `text/html; charset=utf-8` | Cuerpo vacío. `.permanent()` da 301, `.rewritable(false)` da 307 y los dos juntos dan 308. Con 307 y 308 el cliente repite el mismo método, así que un POST redirigido sigue siendo un POST | + +- `Readable.from(array)` crea un stream en modo objeto, que hapi rechaza con un 500. Pasa `{ objectMode: false }` o devuelve un stream de bytes, como `Fs.createReadStream()`. +- Un stream con propiedades `statusCode` y `headers`, como una respuesta HTTP de [wreck](/module/wreck), transmite su estado y sus cabeceras. Eso convierte un proxy básico en un handler de una línea, y también significa que un 404 del servidor de origen se convierte en tu 404. + +## Dar forma a una respuesta con h.response() + +Devuelve un valor simple cuando los valores por defecto te sirven. Cuando no, envuelve el valor con [`h.response()`](/api#h-response-value) y encadena lo que necesites: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/users', + handler: (request, h) => { + const user = { id: '2133d32a', name: request.payload.name }; + + return h.response(user).created(`/users/${user.id}`).header('x-request-id', request.info.id); + }, +}); + +server.route({ + method: 'GET', + path: '/robots.txt', + handler: (request, h) => { + return h.response('User-agent: *\nDisallow: /admin\n').type('text/plain'); + }, +}); +``` + +`.created()` pone el estado a 201 y la cabecera `location` en una sola llamada. + +## Errores + +Todo error acaba siendo un objeto de boom. hapi envuelve un `Error` normal con `Boom.boomify()`, que le asigna el estado 500, y boom oculta al cliente el mensaje de cualquier error 5xx. Tus usuarios ven `An internal server error occurred`, y hapi emite el error real como un evento `'request'` en el canal `'error'`; consulta [Logging](/tutorials/es_ES/logging) para registrarlo. Lanza un error de boom como `Boom.notFound('No such user')` cuando el cliente deba ver el mensaje. + +La respuesta de un error de boom sale de su propiedad `output`: + +```ts +import Boom from '@hapi/boom'; + +const error = Boom.notFound('No such user'); + +error.output.statusCode; // 404 +error.output.payload; // { statusCode: 404, error: 'Not Found', message: 'No such user' } +error.output.headers; // {}, or headers such as WWW-Authenticate from auth errors +``` + +### Un formato de error uniforme + +Las APIs suelen necesitar los errores en su propio formato en lugar del de boom. Conviértelos en `onPreResponse`, por donde pasan todos los errores, incluidos los 404 de rutas desconocidas y los errores que lanzan los plugins: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ + error: { + status: statusCode, + code: payload.error, + message: payload.message, + requestId: request.info.id, + }, + }); + + reply.code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; +}); +``` + +Copia `output.headers` en la nueva respuesta. Los errores de autenticación llevan ahí `WWW-Authenticate`, y `Boom.methodNotAllowed()` puede llevar `Allow`. `payload.message` ya es el mensaje seguro: en los errores 5xx boom lo ha sustituido por el texto genérico. + +### Páginas de error en HTML + +Un sitio que renderiza HTML puede devolver una página de error desde el mismo hook; el [tutorial de vistas](/tutorials/es_ES/views#errors) lo muestra con @hapi/vision. En una app que sirve tanto páginas como una API, sáltate la página en las rutas de la API, por ejemplo con `request.path.startsWith('/api/')`, para que los clientes de la API sigan recibiendo JSON. + +## Tipos de respuesta personalizados + +Los plugins que envían ficheros y plantillas no devuelven strings. [@hapi/inert](/module/inert) y [@hapi/vision](/module/vision) crean sus respuestas con [`request.generateResponse()`](/api#request-generateresponse-source-options), que recibe un valor de origen y procesadores opcionales. `prepare` y `marshal` se ejecutan en las dos fases del proceso, y `close` se ejecuta cuando la respuesta termina, tanto si se envió como si se sustituyó: + +| Plugin | `variety` | `prepare` se ejecuta antes de `onPreResponse` | `marshal` se ejecuta después de `onPreResponse` | +| ------ | --------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| inert | `'file'` | abre el fichero; pone `content-length`, `content-type`, `last-modified` y el ETag | devuelve un stream de lectura o un fichero precomprimido (`.gz`, u otras extensiones de `lookupMap`) | +| vision | `'view'` | carga y compila la plantilla | renderiza la plantilla con su contexto; pone `content-type` | + +La separación es intencionada. Un fichero que no existe falla en `prepare`, así que llega a `onPreResponse` como un 404 al que tu gestor de errores puede dar formato. El trabajo costoso, leer el fichero o renderizar la plantilla, ocurre en `marshal`, después de que `onPreResponse` haya tenido ocasión de sustituir la respuesta. inert también pasa `close`, que cierra el fichero incluso cuando un hook `onPreResponse` ha sustituido la respuesta; una respuesta personalizada que abre un recurso también necesita uno. + +Puedes usar el mismo mecanismo para tus propios formatos. Esta es una respuesta CSV: + +```ts +import Hapi from '@hapi/hapi'; + +import type { Request } from '@hapi/hapi'; + +const field = (value: string) => (/[",\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value); + +const csv = (request: Request, rows: string[][]) => { + return request + .generateResponse(rows, { + variety: 'csv', + marshal: async () => rows.map((row) => row.map(field).join(',')).join('\n'), + }) + .type('text/csv'); +}; + +const server = Hapi.server({ port: 4000 }); + +server.route({ + method: 'GET', + path: '/users.csv', + handler: (request, h) => { + return csv(request, [ + ['id', 'name'], + ['2133d32a', 'John Doe'], + ]); + }, +}); +``` + +`field()` pone entre comillas los valores que contienen una coma, unas comillas o un salto de línea, para que no puedan partir una fila. Para llamarlo como `h.csv(rows)` desde cualquier handler, añádelo al response toolkit con un decorador; consulta el tutorial [Decoradores](/tutorials/es_ES/decorators). + +## Serialización JSON + +Los objetos se serializan con `JSON.stringify()`. Las [opciones `json`](/api#route-options-json) de la ruta controlan cómo: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ + port: 4000, + routes: { + json: { + space: process.env.NODE_ENV === 'production' ? 0 : 2, + }, + }, +}); + +server.route({ + method: 'GET', + path: '/widget-config', + options: { + json: { escape: true }, + }, + handler: (request, h) => { + return { title: '' }; + }, +}); +``` + +- `space` indenta la salida. Un JSON legible ayuda durante el desarrollo y cuesta bytes en producción. +- `escape: true` escribe `<`, `>` y `&` como `\u003c`, `\u003e` y `\u0026`, y también escapa los separadores de línea U+2028 y U+2029. Úsalo en cualquier ruta cuyo JSON se incruste en una página HTML, para que un valor que contenga `` no pueda cerrar la etiqueta script que lo rodea. +- `replacer` se pasa a `JSON.stringify()`, y `suffix` se añade al final de la salida. Una respuesta concreta puede sobrescribirlos con `.spaces()`, `.replacer()` y `.suffix()`. + +No confíes en `replacer` para eliminar secretos como los hashes de contraseñas. Construye el objeto response con los campos que quieres enviar y usa la [validación de respuestas](#validation) para detectar errores. + +## Caché y peticiones condicionales + +La [opción `cache`](/api#route-options-cache) de la ruta pone `cache-control` durante la serialización: + +```ts +server.route({ + method: 'GET', + path: '/articles/{id}', + options: { + cache: { expiresIn: 60 * 1000, privacy: 'public' }, + }, + handler: (request, h) => { + const article = { id: request.params.id, version: 7, body: '...' }; + + const notModified = h.entity({ etag: `${article.id}-${article.version}` }); + if (notModified) { + return notModified; + } + + return article; + }, +}); +``` + +- `cache` produce `cache-control: max-age=60, must-revalidate, public`. hapi lo cambia a `private` por su cuenta cuando la petición está autenticada. Las cookies puestas con `h.state()` no lo cambian, y los plugins de sesión como yar y @hapi/cookie ponen las suyas así, de modo que en una ruta que pone una cookie usa `privacy: 'private'` o no uses `cache`. Si no, una caché compartida puede guardar la cookie de un usuario y entregársela a otro. +- [`h.entity()`](/api#h-entity-options) compara el ETag con la cabecera `If-None-Match` de la petición. Si coinciden, devuelve una respuesta 304 y el handler se ahorra construir el cuerpo. Si no, pone la cabecera `etag` y no devuelve nada. + +Basa el ETag en algo barato que cambie cada vez que cambie el contenido, como un número de versión o una marca de tiempo de actualización. Calcular el hash del cuerpo completo ahorra ancho de banda, pero no el trabajo de construirlo. + +## Compresión + +hapi comprime una respuesta con gzip o deflate cuando se cumple todo esto: + +- el cliente envía una cabecera `Accept-Encoding` compatible, +- el tipo de contenido es comprimible (texto, JSON, JavaScript, SVG y similares; no imágenes ni ficheros comprimidos), +- el payload ocupa al menos [`compression.minBytes`](/api#server-options-compression-minbytes), 1024 bytes por defecto, o su tamaño es desconocido, como en los streams. + +hapi también añade `vary: accept-encoding`, para que las cachés guarden por separado las copias comprimidas y sin comprimir. Para soportar Brotli, registra un codificador con [`server.encoder()`](/api#server-encoder-encoding-encoder): + +```ts +import Zlib from 'node:zlib'; + +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.encoder('br', (options) => Zlib.createBrotliCompress(options)); +``` + +A partir de ahí hapi prefiere `br` cuando el cliente lo acepta. La [opción `compression`](/api#route-options-compression) de una ruta pasa ajustes a un codificador, como `compression: { br: { params: { [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 } } }` para sacrificar tamaño a cambio de velocidad. Cuando un payload ya está comprimido, como un fichero `.gz` leído del disco, márcalo con `.compressed('gzip')` para que hapi ponga la cabecera y no toque los bytes. + +## Validación de respuestas + +La [validación de respuestas](/api#route-options-response) comprueba el payload contra un esquema antes de `onPreResponse`. Detecta handlers que devuelven una forma incorrecta, incluidos campos que nunca deberían salir del servidor: + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; + +const server = Hapi.server({ port: 4000 }); + +const User = Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), +}); + +server.route({ + method: 'GET', + path: '/users/{id}', + options: { + response: { + schema: User, + sample: process.env.NODE_ENV === 'production' ? 10 : 100, + failAction: process.env.NODE_ENV === 'production' ? 'log' : 'error', + }, + }, + handler: (request, h) => { + return { id: request.params.id, name: 'John Doe' }; + }, +}); +``` + +- Con el `failAction: 'error'` por defecto, un payload que no cumple el esquema se convierte en un 500. Úsalo en desarrollo y en los tests, para que los errores salten a la vista. +- `failAction: 'log'` envía el payload original y registra el fallo con las etiquetas `validation`, `response` y `error`. En producción evita que un error en el esquema provoque una caída del servicio, pero el payload sale igualmente, incluido cualquier campo que el esquema haya rechazado. +- `sample` valida solo un porcentaje de las respuestas, lo que limita el coste en rutas con mucho tráfico. + +Para eliminar los campos desconocidos en lugar de rechazarlos, pon `modify: true` y `options: { stripUnknown: true }`, y quita `sample`. Con `modify`, la validación forma parte de la lógica de tu respuesta, así que hapi valida todas las respuestas y no acepta `sample`. + +## Medir las respuestas + +Un objeto response emite [eventos](/api#response-events) mientras se escribe: `'peek'` por cada chunk y `'finish'` al final. Un plugin puede usarlos para registrar el tamaño de las respuestas: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (Boom.isBoom(response)) { + return h.continue; + } + + let bytes = 0; + response.events.on('peek', (chunk) => { + bytes += Buffer.byteLength(chunk); + }); + response.events.once('finish', () => { + request.log(['metrics'], { path: request.path, bytes }); + }); + + return h.continue; +}); +``` + +`'peek'` ve el payload antes de la compresión, así que `bytes` es el tamaño sin comprimir. Aquí se omiten las respuestas de error: un error de boom se convierte en un nuevo objeto response durante la serialización, después de que se ejecute este hook. Para medir el tiempo de una petición completa, usa la extensión `onPostResponse` o el evento `'response'` del servidor, que se explican en [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) y [Logging](/tutorials/es_ES/logging). diff --git a/docs/tutorials/ko_KR/response-marshalling.md b/docs/tutorials/ko_KR/response-marshalling.md new file mode 100644 index 00000000..b44f07ff --- /dev/null +++ b/docs/tutorials/ko_KR/response-marshalling.md @@ -0,0 +1,352 @@ +--- +title: 응답 마샬링 +--- + + + +# 응답 마샬링 + +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ + +## 개요 + +핸들러는 문자열, 객체, 스트림, 에러 같은 값을 반환합니다. 응답 마샬링(response marshalling)은 hapi가 이 값을 HTTP 응답으로 바꾸기 위해 하는 모든 일을 말합니다. 상태 코드를 정하고, 헤더를 설정하고, 페이로드를 직렬화하고, 압축해서 소켓에 씁니다. + +이 과정을 알면 코드를 어디에 둘지 알 수 있습니다. 일관된 에러 형식, HTML 에러 페이지, 캐싱 헤더, 압축, CSV 같은 커스텀 응답 타입, 메트릭용 바이트 카운터는 각각 마샬링 파이프라인의 특정 지점에 들어가야 합니다. 모든 메서드는 [응답 객체](/api#response-object)와 [응답 툴킷](/api#response-toolkit) API 레퍼런스에 정리되어 있습니다. + +## 반환 값에서 바이트까지 + +마샬링은 두 단계로 이루어지고, 그 사이에 `onPreResponse` 확장 지점이 있습니다: + +```mermaid +flowchart TD + A["핸들러가 값을 반환"] --> B["래핑과 준비
값이 응답 객체가 됨"] + B --> C["onPostHandler,
응답 검증"] + C --> D["onPreResponse
바꿀 수 있는 마지막 기회"] + D --> E["마샬
헤더와 페이로드 직렬화"] + E --> F["빈 페이로드는 204,
압축 또는 range"] + F --> G["소켓에 쓰기"] + + class B,D,E highlight +``` + +마샬 단계는 정해진 순서로 실행되며, 이 순서가 어떤 설정이 어떤 헤더를 볼 수 있는지를 결정합니다: + +1. 페이로드 타입에 따른 `content-type` +2. 라우트가 활성화한 경우 CORS와 보안 헤더 +3. [`h.entity()`](/api#h-entity-options)에서 온 ETag와 `last-modified`, 그리고 GET 라우트의 304 검사 +4. 라우트의 `cache` 옵션에서 온 `cache-control` +5. [`h.state()`](/api#h-state-name-value-options)와 `autoValue`가 있는 쿠키에서 온 `set-cookie` +6. 페이로드 직렬화와 `content-length` +7. 인증 스킴의 `response()` 메서드. 위의 모든 헤더를 읽을 수 있도록 마지막에 실행됩니다 + +이 구분은 실제로 중요합니다: + +- **`onPreResponse` 이전**에는 응답이 응답 객체 또는 [boom](/module/boom) 에러이고, 이를 검사하거나 바꾸거나 교체할 수 있습니다. 래핑과 준비 단계에서 발생한 에러(예: 존재하지 않는 파일)는 boom 에러로 `onPreResponse`에 도착합니다. +- **`onPreResponse` 이후**에는 hapi가 페이로드를 직렬화하고 헤더를 계산합니다. 이 단계의 에러(예: `JSON.stringify()`가 처리할 수 없는 객체)는 `onPreResponse`가 보지 못하는 500이 됩니다. 마샬링이 실패하지 않도록 페이로드는 순수한 데이터로 유지하세요. + +[요청 라이프사이클](/api#request-lifecycle) 단계의 순서는 [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks) 튜토리얼에서 다룹니다. + +## 반환하는 값과 hapi가 보내는 것 + +| 핸들러 반환 값 | 상태 | 기본 `content-type` | 비고 | +| ------------------- | -------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 문자열 | 200, 비어 있으면 204 | `text/html; charset=utf-8` | 일반 텍스트는 `.type('text/plain')`을 설정 | +| 객체 또는 배열 | 200 | `application/json; charset=utf-8` | `JSON.stringify()`와 라우트의 [`json` 옵션](#json)으로 직렬화 | +| `Buffer` | 200 | `application/octet-stream` | | +| readable 스트림 | 200 | `application/octet-stream` | chunked로 전송. 객체 모드이면 안 됨. 스트림에 `statusCode`와 `headers`가 있으면 복사 | +| `null` | 204 | 없음 | 200을 보내려면 라우트의 `response.emptyStatusCode`를 `200`으로 설정 | +| `undefined` | 500 | | 핸들러는 반드시 무언가를 반환해야 함. hapi는 "handler method did not return a value"를 로그로 남김 | +| `Error` | 500 | `application/json; charset=utf-8` | 메시지는 클라이언트에게 숨겨짐 | +| boom 에러 | 에러의 상태 | `application/json; charset=utf-8` | `{ statusCode, error, message }`. 5xx 메시지는 숨겨짐 | +| `h.response(value)` | `value`와 같음 | `value`와 같음 | `.code()`, `.header()`, `.type()` 등 [응답 객체](/api#response-object) 메서드를 체이닝 | +| `h.redirect(uri)` | 302 | `text/html; charset=utf-8` | 본문 없음. `.permanent()`는 301, `.rewritable(false)`는 307, 둘 다 쓰면 308. 307과 308은 클라이언트가 같은 메서드를 반복하게 하므로 리다이렉트된 POST는 POST로 유지됨 | + +- `Readable.from(array)`는 객체 모드 스트림을 만들고, hapi는 이를 500으로 거부합니다. `{ objectMode: false }`를 넘기거나 `Fs.createReadStream()` 같은 바이트 스트림을 반환하세요. +- [wreck](/module/wreck)의 HTTP 응답처럼 `statusCode`와 `headers` 속성이 있는 스트림은 상태와 헤더를 그대로 전달합니다. 그래서 기본적인 프록시를 한 줄짜리 핸들러로 만들 수 있지만, 업스트림의 404가 그대로 여러분의 404가 된다는 뜻이기도 합니다. + +## h.response()로 응답 다듬기 + +기본값이 맞으면 일반 값을 반환하세요. 맞지 않으면 값을 [`h.response()`](/api#h-response-value)로 감싸고 필요한 것을 체이닝합니다: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/users', + handler: (request, h) => { + const user = { id: '2133d32a', name: request.payload.name }; + + return h.response(user).created(`/users/${user.id}`).header('x-request-id', request.info.id); + }, +}); + +server.route({ + method: 'GET', + path: '/robots.txt', + handler: (request, h) => { + return h.response('User-agent: *\nDisallow: /admin\n').type('text/plain'); + }, +}); +``` + +`.created()`는 상태를 201로 설정하고 `location` 헤더를 한 번에 설정합니다. + +## 에러 + +모든 에러는 결국 boom 객체가 됩니다. hapi는 일반 `Error`를 `Boom.boomify()`로 감싸 상태 500을 부여하고, boom은 모든 5xx 에러의 메시지를 클라이언트에게 숨깁니다. 사용자는 `An internal server error occurred`를 보게 되고, hapi는 실제 에러를 `'error'` 채널의 `'request'` 이벤트로 내보냅니다. 이를 기록하는 방법은 [로깅](/tutorials/ko_KR/logging)을 참고하세요. 클라이언트가 메시지를 봐야 한다면 `Boom.notFound('No such user')` 같은 boom 에러를 던지세요. + +boom 에러의 응답은 `output` 속성에서 나옵니다: + +```ts +import Boom from '@hapi/boom'; + +const error = Boom.notFound('No such user'); + +error.output.statusCode; // 404 +error.output.payload; // { statusCode: 404, error: 'Not Found', message: 'No such user' } +error.output.headers; // {}, or headers such as WWW-Authenticate from auth errors +``` + +### 일관된 에러 형식 + +API는 boom의 형식이 아닌 자체 형식의 에러가 필요한 경우가 많습니다. 모든 에러가 거쳐 가는 `onPreResponse`에서 변환하세요. 알 수 없는 라우트의 404와 플러그인이 던진 에러도 여기를 지나갑니다: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ + error: { + status: statusCode, + code: payload.error, + message: payload.message, + requestId: request.info.id, + }, + }); + + reply.code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; +}); +``` + +`output.headers`를 새 응답에 복사하세요. 인증 에러는 여기에 `WWW-Authenticate`를 담고 있고, `Boom.methodNotAllowed()`는 `Allow`를 담을 수 있습니다. `payload.message`는 이미 안전한 메시지입니다. 5xx 에러의 경우 boom이 일반적인 문구로 바꿔 두었습니다. + +### HTML 에러 페이지 + +HTML을 렌더링하는 사이트라면 같은 훅에서 에러 페이지를 반환할 수 있습니다. [뷰 튜토리얼](/tutorials/ko_KR/views#errors)에서 @hapi/vision으로 구현하는 방법을 보여 줍니다. 페이지와 API를 함께 제공하는 앱에서는 API 클라이언트가 계속 JSON을 받도록 API 라우트에서는 페이지를 건너뛰세요. 예를 들어 `request.path.startsWith('/api/')`로 구분할 수 있습니다. + +## 커스텀 응답 타입 + +파일과 템플릿을 보내는 플러그인은 문자열을 반환하지 않습니다. [@hapi/inert](/module/inert)와 [@hapi/vision](/module/vision)은 [`request.generateResponse()`](/api#request-generateresponse-source-options)로 응답을 만드는데, 이 메서드는 소스 값과 선택적인 프로세서를 받습니다. `prepare`와 `marshal`은 파이프라인의 두 단계에서 실행되고, `close`는 응답이 전송되었든 교체되었든 응답이 끝날 때 실행됩니다: + +| 플러그인 | `variety` | `onPreResponse` 전에 실행되는 `prepare` | `onPreResponse` 후에 실행되는 `marshal` | +| -------- | --------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| inert | `'file'` | 파일을 열고 `content-length`, `content-type`, `last-modified`, ETag를 설정 | 읽기 스트림 또는 미리 압축된 파일(`.gz`, 또는 `lookupMap`의 다른 확장자)을 반환 | +| vision | `'view'` | 템플릿을 로드하고 컴파일 | 컨텍스트로 템플릿을 렌더링하고 `content-type`을 설정 | + +이렇게 나눈 데는 이유가 있습니다. 없는 파일은 `prepare`에서 실패하므로 에러 핸들러가 형식을 맞출 수 있는 404로 `onPreResponse`에 도착합니다. 파일 읽기나 템플릿 렌더링 같은 비용이 큰 작업은 `onPreResponse`가 응답을 교체할 기회를 가진 뒤인 `marshal`에서 일어납니다. inert는 `close`도 넘기는데, `onPreResponse` 훅이 응답을 교체한 경우에도 파일을 닫아 줍니다. 리소스를 여는 커스텀 응답에도 이 처리가 필요합니다. + +같은 메커니즘을 직접 만든 형식에도 쓸 수 있습니다. 다음은 CSV 응답입니다: + +```ts +import Hapi from '@hapi/hapi'; + +import type { Request } from '@hapi/hapi'; + +const field = (value: string) => (/[",\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value); + +const csv = (request: Request, rows: string[][]) => { + return request + .generateResponse(rows, { + variety: 'csv', + marshal: async () => rows.map((row) => row.map(field).join(',')).join('\n'), + }) + .type('text/csv'); +}; + +const server = Hapi.server({ port: 4000 }); + +server.route({ + method: 'GET', + path: '/users.csv', + handler: (request, h) => { + return csv(request, [ + ['id', 'name'], + ['2133d32a', 'John Doe'], + ]); + }, +}); +``` + +`field()`는 쉼표, 따옴표, 줄바꿈이 들어 있는 값을 따옴표로 감싸서 행이 나뉘지 않게 합니다. 어느 핸들러에서든 `h.csv(rows)`로 호출하려면 데코레이터로 응답 툴킷에 추가하세요. [데코레이터](/tutorials/ko_KR/decorators) 튜토리얼을 참고하세요. + +## JSON 직렬화 + +객체는 `JSON.stringify()`로 직렬화됩니다. 라우트의 [`json` 옵션](/api#route-options-json)으로 직렬화 방식을 제어합니다: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ + port: 4000, + routes: { + json: { + space: process.env.NODE_ENV === 'production' ? 0 : 2, + }, + }, +}); + +server.route({ + method: 'GET', + path: '/widget-config', + options: { + json: { escape: true }, + }, + handler: (request, h) => { + return { title: '' }; + }, +}); +``` + +- `space`는 출력을 들여씁니다. 읽기 쉬운 JSON은 개발 중에는 도움이 되지만 프로덕션에서는 바이트를 늘립니다. +- `escape: true`는 `<`, `>`, `&`를 `\u003c`, `\u003e`, `\u0026`로 쓰고, U+2028과 U+2029 줄 구분자도 이스케이프합니다. JSON이 HTML 페이지에 삽입되는 라우트에서는 이 옵션을 사용해 ``가 들어 있는 값이 주변 script 태그를 닫지 못하게 하세요. +- `replacer`는 `JSON.stringify()`에 전달되고, `suffix`는 출력 뒤에 붙습니다. 개별 응답에서는 `.spaces()`, `.replacer()`, `.suffix()`로 이를 덮어쓸 수 있습니다. + +비밀번호 해시 같은 민감한 값을 제거하는 데 `replacer`에 의존하지 마세요. 보내려는 필드로 응답 객체를 직접 만들고, 실수를 잡으려면 [응답 검증](#validation)을 사용하세요. + +## 캐싱과 조건부 요청 + +라우트의 [`cache` 옵션](/api#route-options-cache)은 마샬링 중에 `cache-control`을 설정합니다: + +```ts +server.route({ + method: 'GET', + path: '/articles/{id}', + options: { + cache: { expiresIn: 60 * 1000, privacy: 'public' }, + }, + handler: (request, h) => { + const article = { id: request.params.id, version: 7, body: '...' }; + + const notModified = h.entity({ etag: `${article.id}-${article.version}` }); + if (notModified) { + return notModified; + } + + return article; + }, +}); +``` + +- `cache`는 `cache-control: max-age=60, must-revalidate, public`을 만듭니다. 요청이 인증된 경우 hapi는 이를 알아서 `private`으로 바꿉니다. `h.state()`로 설정한 쿠키는 이 값을 바꾸지 않으며, yar와 @hapi/cookie 같은 세션 플러그인도 이 방식으로 쿠키를 설정합니다. 따라서 쿠키를 설정하는 라우트에서는 `privacy: 'private'`을 쓰거나 `cache`를 아예 쓰지 마세요. 그렇지 않으면 공유 캐시가 한 사용자의 쿠키를 저장해 다른 사용자에게 넘길 수 있습니다. +- [`h.entity()`](/api#h-entity-options)는 ETag를 요청의 `If-None-Match` 헤더와 비교합니다. 일치하면 304 응답을 반환하고 핸들러는 본문 생성을 건너뜁니다. 일치하지 않으면 `etag` 헤더를 설정하고 아무것도 반환하지 않습니다. + +ETag는 콘텐츠가 바뀔 때마다 함께 바뀌는 값 중 계산 비용이 낮은 것, 예를 들어 버전 번호나 수정 타임스탬프를 기반으로 만드세요. 본문 전체를 해싱하면 대역폭은 아끼지만 본문을 만드는 작업은 아끼지 못합니다. + +## 압축 + +hapi는 다음 조건이 모두 참일 때 응답을 gzip 또는 deflate로 압축합니다: + +- 클라이언트가 일치하는 `Accept-Encoding` 헤더를 보냄 +- 콘텐츠 타입이 압축 가능함(텍스트, JSON, JavaScript, SVG 등. 이미지나 아카이브는 제외) +- 페이로드가 [`compression.minBytes`](/api#server-options-compression-minbytes)(기본값 1024바이트) 이상이거나, 스트림처럼 크기를 알 수 없음 + +hapi는 `vary: accept-encoding`도 추가해서 캐시가 압축된 사본과 압축되지 않은 사본을 구분하게 합니다. Brotli를 지원하려면 [`server.encoder()`](/api#server-encoder-encoding-encoder)로 인코더를 등록하세요: + +```ts +import Zlib from 'node:zlib'; + +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.encoder('br', (options) => Zlib.createBrotliCompress(options)); +``` + +이제 클라이언트가 `br`을 받아들이면 hapi는 이를 우선합니다. 라우트의 [`compression` 옵션](/api#route-options-compression)은 인코더에 설정을 전달합니다. 예를 들어 `compression: { br: { params: { [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 } } }`로 크기와 속도를 맞바꿀 수 있습니다. 디스크에서 읽은 `.gz` 파일처럼 페이로드가 이미 압축되어 있다면 `.compressed('gzip')`로 표시하세요. 그러면 hapi는 헤더를 설정하고 바이트는 건드리지 않습니다. + +## 응답 검증 + +[응답 검증](/api#route-options-response)은 `onPreResponse` 전에 페이로드를 스키마와 대조합니다. 서버 밖으로 나가면 안 되는 필드를 포함해, 잘못된 형태를 반환하는 핸들러를 잡아냅니다: + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; + +const server = Hapi.server({ port: 4000 }); + +const User = Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), +}); + +server.route({ + method: 'GET', + path: '/users/{id}', + options: { + response: { + schema: User, + sample: process.env.NODE_ENV === 'production' ? 10 : 100, + failAction: process.env.NODE_ENV === 'production' ? 'log' : 'error', + }, + }, + handler: (request, h) => { + return { id: request.params.id, name: 'John Doe' }; + }, +}); +``` + +- 기본값인 `failAction: 'error'`에서는 스키마를 통과하지 못한 페이로드가 500이 됩니다. 실수가 확실히 드러나도록 개발과 테스트에서 사용하세요. +- `failAction: 'log'`는 원래 페이로드를 보내고 실패를 `validation`, `response`, `error` 태그로 로그에 남깁니다. 프로덕션에서 스키마 실수가 장애로 번지는 것을 막아 주지만, 스키마가 거부한 필드를 포함해 페이로드는 그대로 나갑니다. +- `sample`은 응답 중 일정 비율만 검증해서 트래픽이 많은 라우트의 비용을 줄입니다. + +알 수 없는 필드를 거부하는 대신 제거하려면 `modify: true`와 `options: { stripUnknown: true }`를 설정하고 `sample`을 제거하세요. `modify`를 쓰면 검증이 응답 로직의 일부가 되므로 hapi는 모든 응답을 검증하며 `sample`을 받아들이지 않습니다. + +## 응답 측정 + +응답 객체는 쓰이는 동안 [이벤트](/api#response-events)를 내보냅니다. 청크마다 `'peek'`, 끝날 때 `'finish'`입니다. 플러그인은 이를 이용해 응답 크기를 기록할 수 있습니다: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (Boom.isBoom(response)) { + return h.continue; + } + + let bytes = 0; + response.events.on('peek', (chunk) => { + bytes += Buffer.byteLength(chunk); + }); + response.events.once('finish', () => { + request.log(['metrics'], { path: request.path, bytes }); + }); + + return h.continue; +}); +``` + +`'peek'`는 압축 전의 페이로드를 보므로 `bytes`는 압축되지 않은 크기입니다. 여기서는 에러 응답을 건너뜁니다. boom 에러는 이 훅이 실행된 뒤 마샬링 중에 새 응답 객체가 되기 때문입니다. 요청 전체의 시간을 재려면 `onPostResponse` 확장이나 서버의 `'response'` 이벤트를 사용하세요. [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks)과 [로깅](/tutorials/ko_KR/logging)에서 다룹니다. diff --git a/docs/tutorials/pt_BR/response-marshalling.md b/docs/tutorials/pt_BR/response-marshalling.md new file mode 100644 index 00000000..0ba07554 --- /dev/null +++ b/docs/tutorials/pt_BR/response-marshalling.md @@ -0,0 +1,352 @@ +--- +title: Marshalling de respostas +--- + + + +# Marshalling de respostas + +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ + +## Visão geral + +Um handler retorna um valor: uma string, um objeto, um stream, um erro. O marshalling de respostas é tudo o que o hapi faz para transformar esse valor em uma resposta HTTP: escolher o status code, definir os headers, serializar o payload, comprimi-lo e escrevê-lo no socket. + +Entender como isso funciona mostra onde colocar cada código. Um formato de erro consistente, uma página de erro em HTML, headers de cache, compressão, um tipo de resposta próprio como CSV, um contador de bytes para métricas: cada um deles pertence a um ponto específico do pipeline de marshalling. As referências da API do [objeto de resposta](/api#response-object) e do [response toolkit](/api#response-toolkit) listam todos os métodos. + +## Do valor retornado aos bytes + +O marshalling acontece em duas fases, com o ponto de extensão `onPreResponse` entre elas: + +```mermaid +flowchart TD + A["o handler retorna um valor"] --> B["wrap e prepare
o valor vira um objeto de resposta"] + B --> C["onPostHandler,
validação da resposta"] + C --> D["onPreResponse
última chance de alterá-lo"] + D --> E["marshal
headers e serialização do payload"] + E --> F["204 para payloads vazios,
compressão ou range"] + F --> G["escrito no socket"] + + class B,D,E highlight +``` + +A etapa de marshal roda em uma ordem fixa, que define quais configurações enxergam quais headers: + +1. `content-type` a partir do tipo do payload +2. headers de CORS e de segurança, se a rota os habilitar +3. ETag e `last-modified` a partir de [`h.entity()`](/api#h-entity-options), depois a verificação de 304 em rotas GET +4. `cache-control` a partir da opção `cache` da rota +5. `set-cookie` a partir de [`h.state()`](/api#h-state-name-value-options) e de cookies com `autoValue` +6. serialização do payload e `content-length` +7. o método `response()` do auth scheme, por último, para que ele consiga ler todos os headers acima + +Essa divisão importa na prática: + +- **Antes do `onPreResponse`**, a resposta é um objeto de resposta ou um erro do [boom](/module/boom), e você pode inspecioná-la, alterá-la ou substituí-la. Erros gerados durante o wrap e o prepare, como um arquivo que não existe, chegam ao `onPreResponse` como erros do boom. +- **Depois do `onPreResponse`**, o hapi serializa o payload e calcula os headers. Um erro nessa fase, como um objeto que o `JSON.stringify()` não consegue tratar, vira um 500 que o `onPreResponse` nunca vê. Mantenha os payloads como dados simples para que o marshalling não possa falhar. + +A ordem das etapas do [ciclo de vida da requisição](/api#request-lifecycle) é explicada no tutorial [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks). + +## O que você retorna, o que o hapi envia + +| O handler retorna | Status | `content-type` padrão | Observações | +| ------------------- | -------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| string | 200, ou 204 se vazia | `text/html; charset=utf-8` | Use `.type('text/plain')` para texto puro | +| objeto ou array | 200 | `application/json; charset=utf-8` | Serializado com `JSON.stringify()` e as [opções `json`](#json) da rota | +| `Buffer` | 200 | `application/octet-stream` | | +| readable stream | 200 | `application/octet-stream` | Enviado em chunks. Não pode estar em object mode. Copia `statusCode` e `headers` se o stream os tiver | +| `null` | 204 | nenhum | Defina `response.emptyStatusCode` da rota como `200` para enviar 200 | +| `undefined` | 500 | | Um handler precisa retornar algo; o hapi registra no log "handler method did not return a value" | +| `Error` | 500 | `application/json; charset=utf-8` | A mensagem fica oculta para o cliente | +| erro do boom | o status do erro | `application/json; charset=utf-8` | `{ statusCode, error, message }`; mensagens de 5xx ficam ocultas | +| `h.response(value)` | o mesmo de `value` | o mesmo de `value` | Encadeie `.code()`, `.header()`, `.type()` e os outros métodos do [objeto de resposta](/api#response-object) | +| `h.redirect(uri)` | 302 | `text/html; charset=utf-8` | Corpo vazio. `.permanent()` gera 301, `.rewritable(false)` gera 307, os dois juntos geram 308. 307 e 308 fazem o cliente repetir o mesmo método, então um POST redirecionado continua POST | + +- `Readable.from(array)` cria um stream em object mode, que o hapi rejeita com 500. Passe `{ objectMode: false }` ou retorne um stream de bytes, como `Fs.createReadStream()`. +- Um stream com as propriedades `statusCode` e `headers`, como uma resposta HTTP do [wreck](/module/wreck), repassa seu status e seus headers. Isso transforma um proxy básico em um handler de uma linha, e também significa que um 404 do upstream vira o seu 404. + +## Montando uma resposta com h.response() + +Retorne um valor simples quando os padrões servem. Quando não servem, envolva o valor com [`h.response()`](/api#h-response-value) e encadeie o que precisar: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/users', + handler: (request, h) => { + const user = { id: '2133d32a', name: request.payload.name }; + + return h.response(user).created(`/users/${user.id}`).header('x-request-id', request.info.id); + }, +}); + +server.route({ + method: 'GET', + path: '/robots.txt', + handler: (request, h) => { + return h.response('User-agent: *\nDisallow: /admin\n').type('text/plain'); + }, +}); +``` + +`.created()` define o status 201 e o header `location` em uma única chamada. + +## Erros + +Todo erro acaba virando um objeto do boom. O hapi envolve um `Error` comum com `Boom.boomify()`, que atribui o status 500, e o boom esconde do cliente a mensagem de qualquer erro 5xx. Seus usuários veem `An internal server error occurred`, e o hapi emite o erro real como um evento `'request'` no canal `'error'`; veja [Logging](/tutorials/pt_BR/logging) para registrá-lo. Lance um erro do boom, como `Boom.notFound('No such user')`, quando o cliente deve ver a mensagem. + +A resposta de um erro do boom vem da sua propriedade `output`: + +```ts +import Boom from '@hapi/boom'; + +const error = Boom.notFound('No such user'); + +error.output.statusCode; // 404 +error.output.payload; // { statusCode: 404, error: 'Not Found', message: 'No such user' } +error.output.headers; // {}, or headers such as WWW-Authenticate from auth errors +``` + +### Um formato de erro consistente + +APIs muitas vezes precisam dos erros em um formato próprio, e não no do boom. Converta-os no `onPreResponse`, por onde passam todos os erros, inclusive os 404 de rotas desconhecidas e os erros lançados por plugins: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ + error: { + status: statusCode, + code: payload.error, + message: payload.message, + requestId: request.info.id, + }, + }); + + reply.code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; +}); +``` + +Copie `output.headers` para a nova resposta. Erros de autenticação trazem `WWW-Authenticate` ali, e `Boom.methodNotAllowed()` pode trazer `Allow`. `payload.message` já é a mensagem segura: em erros 5xx o boom já a trocou pelo texto genérico. + +### Páginas de erro em HTML + +Um site que renderiza HTML pode retornar uma página de erro a partir do mesmo hook; o tutorial [Views](/tutorials/pt_BR/views#errors) mostra como fazer isso com o @hapi/vision. Em uma aplicação que serve tanto páginas quanto uma API, pule a página nas rotas da API, por exemplo com `request.path.startsWith('/api/')`, para que os clientes da API continuem recebendo JSON. + +## Tipos de resposta próprios + +Plugins que enviam arquivos e templates não retornam strings. O [@hapi/inert](/module/inert) e o [@hapi/vision](/module/vision) criam suas respostas com [`request.generateResponse()`](/api#request-generateresponse-source-options), que recebe um valor de origem e processors opcionais. `prepare` e `marshal` rodam nas duas fases do pipeline, e `close` roda quando a resposta termina, tenha ela sido enviada ou substituída: + +| Plugin | `variety` | `prepare` roda antes do `onPreResponse` | `marshal` roda depois do `onPreResponse` | +| ------ | --------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| inert | `'file'` | abre o arquivo; define `content-length`, `content-type`, `last-modified`, ETag | retorna um read stream, ou um arquivo pré-comprimido (`.gz`, ou outras extensões de `lookupMap`) | +| vision | `'view'` | carrega e compila o template | renderiza o template com seu contexto; define `content-type` | + +A divisão é proposital. Um arquivo inexistente falha no `prepare`, então chega ao `onPreResponse` como um 404 que o seu tratador de erros pode formatar. O trabalho caro, ler o arquivo ou renderizar o template, acontece no `marshal`, depois que o `onPreResponse` teve a chance de substituir a resposta. O inert também passa `close`, que fecha o arquivo mesmo quando um hook `onPreResponse` substituiu a resposta; uma resposta própria que abre um recurso também precisa de um. + +Você pode usar o mesmo mecanismo para os seus próprios formatos. Aqui está uma resposta em CSV: + +```ts +import Hapi from '@hapi/hapi'; + +import type { Request } from '@hapi/hapi'; + +const field = (value: string) => (/[",\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value); + +const csv = (request: Request, rows: string[][]) => { + return request + .generateResponse(rows, { + variety: 'csv', + marshal: async () => rows.map((row) => row.map(field).join(',')).join('\n'), + }) + .type('text/csv'); +}; + +const server = Hapi.server({ port: 4000 }); + +server.route({ + method: 'GET', + path: '/users.csv', + handler: (request, h) => { + return csv(request, [ + ['id', 'name'], + ['2133d32a', 'John Doe'], + ]); + }, +}); +``` + +`field()` coloca entre aspas os valores que contêm vírgula, aspas ou quebra de linha, para que eles não possam dividir uma linha. Para chamá-la como `h.csv(rows)` em qualquer handler, adicione-a ao response toolkit com um decorator; veja o tutorial [Decorators](/tutorials/pt_BR/decorators). + +## Serialização de JSON + +Objetos são serializados com `JSON.stringify()`. As [opções `json`](/api#route-options-json) da rota controlam como: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ + port: 4000, + routes: { + json: { + space: process.env.NODE_ENV === 'production' ? 0 : 2, + }, + }, +}); + +server.route({ + method: 'GET', + path: '/widget-config', + options: { + json: { escape: true }, + }, + handler: (request, h) => { + return { title: '' }; + }, +}); +``` + +- `space` indenta a saída. JSON legível ajuda durante o desenvolvimento e custa bytes em produção. +- `escape: true` escreve `<`, `>` e `&` como `\u003c`, `\u003e` e `\u0026`, e também escapa os separadores de linha U+2028 e U+2029. Use em qualquer rota cujo JSON seja embutido em uma página HTML, para que um valor contendo `` não consiga fechar a tag script ao redor. +- `replacer` é passado para o `JSON.stringify()`, e `suffix` é adicionado ao final da saída. Uma resposta específica pode sobrescrevê-los com `.spaces()`, `.replacer()` e `.suffix()`. + +Não dependa do `replacer` para remover segredos como hashes de senha. Monte o objeto de resposta com os campos que você pretende enviar e use a [validação de resposta](#validation) para pegar erros. + +## Cache e requisições condicionais + +A [opção `cache`](/api#route-options-cache) da rota define o `cache-control` durante o marshalling: + +```ts +server.route({ + method: 'GET', + path: '/articles/{id}', + options: { + cache: { expiresIn: 60 * 1000, privacy: 'public' }, + }, + handler: (request, h) => { + const article = { id: request.params.id, version: 7, body: '...' }; + + const notModified = h.entity({ etag: `${article.id}-${article.version}` }); + if (notModified) { + return notModified; + } + + return article; + }, +}); +``` + +- `cache` gera `cache-control: max-age=60, must-revalidate, public`. O hapi troca para `private` por conta própria quando a requisição é autenticada. Cookies definidos com `h.state()` não alteram isso, e plugins de sessão como o yar e o @hapi/cookie definem os seus dessa forma, então em uma rota que define um cookie use `privacy: 'private'` ou não use `cache`. Caso contrário, um cache compartilhado pode guardar o cookie de um usuário e entregá-lo a outro. +- [`h.entity()`](/api#h-entity-options) compara a ETag com o header `If-None-Match` da requisição. Quando elas coincidem, ele retorna uma resposta 304 e o handler pula a montagem do corpo. Quando não coincidem, ele define o header `etag` e não retorna nada. + +Baseie a ETag em algo barato que mude sempre que o conteúdo mudar, como um número de versão ou um timestamp de atualização. Fazer o hash do corpo inteiro economiza banda, mas não o trabalho de montá-lo. + +## Compressão + +O hapi comprime uma resposta com gzip ou deflate quando todas estas condições são verdadeiras: + +- o cliente envia um header `Accept-Encoding` compatível, +- o content type é comprimível (texto, JSON, JavaScript, SVG e similares; não imagens nem arquivos compactados), +- o payload tem pelo menos [`compression.minBytes`](/api#server-options-compression-minbytes), 1024 bytes por padrão, ou seu tamanho é desconhecido, como acontece com streams. + +O hapi também adiciona `vary: accept-encoding`, para que os caches mantenham as cópias comprimidas e não comprimidas separadas. Para suportar Brotli, registre um encoder com [`server.encoder()`](/api#server-encoder-encoding-encoder): + +```ts +import Zlib from 'node:zlib'; + +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.encoder('br', (options) => Zlib.createBrotliCompress(options)); +``` + +Agora o hapi prefere `br` quando o cliente o aceita. A [opção `compression`](/api#route-options-compression) de uma rota passa configurações para um encoder, como `compression: { br: { params: { [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 } } }` para trocar tamanho por velocidade. Quando um payload já está comprimido, como um arquivo `.gz` lido do disco, marque-o com `.compressed('gzip')` para que o hapi defina o header e não mexa nos bytes. + +## Validação de resposta + +A [validação de resposta](/api#route-options-response) verifica o payload contra um schema antes do `onPreResponse`. Ela pega handlers que retornam o formato errado, incluindo campos que nunca deveriam sair do servidor: + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; + +const server = Hapi.server({ port: 4000 }); + +const User = Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), +}); + +server.route({ + method: 'GET', + path: '/users/{id}', + options: { + response: { + schema: User, + sample: process.env.NODE_ENV === 'production' ? 10 : 100, + failAction: process.env.NODE_ENV === 'production' ? 'log' : 'error', + }, + }, + handler: (request, h) => { + return { id: request.params.id, name: 'John Doe' }; + }, +}); +``` + +- Com o padrão `failAction: 'error'`, um payload que não passa no schema vira um 500. Use isso em desenvolvimento e nos testes, para que os erros apareçam de forma clara. +- `failAction: 'log'` envia o payload original e registra a falha no log com as tags `validation`, `response` e `error`. Em produção, isso evita que um erro no schema vire uma indisponibilidade, mas o payload ainda é enviado, incluindo qualquer campo que o schema rejeitou. +- `sample` valida apenas uma porcentagem das respostas, o que limita o custo em rotas com muito tráfego. + +Para remover campos desconhecidos em vez de rejeitá-los, defina `modify: true` e `options: { stripUnknown: true }`, e remova `sample`. Com `modify`, a validação passa a fazer parte da lógica da resposta, então o hapi valida todas as respostas e não aceita `sample`. + +## Medindo respostas + +Um objeto de resposta emite [eventos](/api#response-events) enquanto é escrito: `'peek'` para cada chunk e `'finish'` no final. Um plugin pode usá-los para registrar o tamanho das respostas: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (Boom.isBoom(response)) { + return h.continue; + } + + let bytes = 0; + response.events.on('peek', (chunk) => { + bytes += Buffer.byteLength(chunk); + }); + response.events.once('finish', () => { + request.log(['metrics'], { path: request.path, bytes }); + }); + + return h.continue; +}); +``` + +`'peek'` vê o payload antes da compressão, então `bytes` é o tamanho sem compressão. As respostas de erro são ignoradas aqui: um erro do boom vira um novo objeto de resposta durante o marshalling, depois que este hook roda. Para medir o tempo de uma requisição inteira, use a extensão `onPostResponse` ou o evento `'response'` do servidor, explicados em [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks) e [Logging](/tutorials/pt_BR/logging). diff --git a/docs/tutorials/tr_TR/response-marshalling.md b/docs/tutorials/tr_TR/response-marshalling.md new file mode 100644 index 00000000..4212aca2 --- /dev/null +++ b/docs/tutorials/tr_TR/response-marshalling.md @@ -0,0 +1,352 @@ +--- +title: Yanıt Hazırlama +--- + + + +# Yanıt Hazırlama + +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ + +## Genel Bakış + +Bir handler bir değer döndürür: bir string, bir nesne, bir stream ya da bir hata. Yanıt hazırlama (response marshalling), hapi'nin bu değeri bir HTTP yanıtına dönüştürmek için yaptığı her şeydir: durum kodunu seçmek, header'ları ayarlamak, payload'u serileştirmek, sıkıştırmak ve sokete yazmak. + +Bunun nasıl çalıştığını bilmek, kodu nereye koyacağını söyler. Tutarlı bir hata formatı, bir HTML hata sayfası, önbellek header'ları, sıkıştırma, CSV gibi özel bir yanıt tipi, metrikler için bir bayt sayacı: bunların her biri marshalling akışında belirli bir noktaya aittir. [Response nesnesi](/api#response-object) ve [response toolkit](/api#response-toolkit) API referansları tüm metotları listeler. + +## Dönüş değerinden baytlara + +Marshalling, aralarında `onPreResponse` extension point'i bulunan iki aşamada gerçekleşir: + +```mermaid +flowchart TD + A["handler bir değer döndürür"] --> B["sarmala ve hazırla
değer bir response object olur"] + B --> C["onPostHandler,
yanıt doğrulama"] + C --> D["onPreResponse
değiştirmek için son fırsat"] + D --> E["marshal
header'lar ve payload serileştirme"] + E --> F["boş payload için 204,
sıkıştırma ya da range"] + F --> G["sokete yazılır"] + + class B,D,E highlight +``` + +Marshal adımı sabit bir sırayla çalışır ve bu sıra hangi ayarın hangi header'ları görebileceğini belirler: + +1. payload tipinden `content-type` +2. route etkinleştirdiyse CORS ve güvenlik header'ları +3. [`h.entity()`](/api#h-entity-options) ile ETag ve `last-modified`, ardından GET route'larında 304 kontrolü +4. route'un `cache` seçeneğinden `cache-control` +5. [`h.state()`](/api#h-state-name-value-options) ve `autoValue` tanımlı cookie'lerden `set-cookie` +6. payload serileştirme ve `content-length` +7. auth şemasının `response()` metodu; en sonda çalışır, böylece yukarıdaki tüm header'ları okuyabilir + +Bu ayrımın pratikte önemi şudur: + +- **`onPreResponse` öncesinde** yanıt, bir response nesnesi ya da bir [boom](/module/boom) hatasıdır; onu inceleyebilir, değiştirebilir ya da tamamen başkasıyla değiştirebilirsin. Sarmalama ve hazırlama sırasında oluşan hatalar, örneğin var olmayan bir dosya, `onPreResponse`'a boom hatası olarak ulaşır. +- **`onPreResponse` sonrasında** hapi payload'u serileştirir ve header'ları hesaplar. Bu aşamadaki bir hata, örneğin `JSON.stringify()`'ın işleyemediği bir nesne, `onPreResponse`'un hiç görmediği bir 500'e dönüşür. Marshalling'in başarısız olmaması için payload'ları düz veri olarak tut. + +[İstek yaşam döngüsü](/api#request-lifecycle) adımlarının sırası [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks) eğitiminde anlatılıyor. + +## Ne döndürürsün, hapi ne gönderir + +| Handler'ın döndürdüğü | Durum | Varsayılan `content-type` | Notlar | +| --------------------- | ------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| string | 200, boşsa 204 | `text/html; charset=utf-8` | Düz metin için `.type('text/plain')` kullan | +| nesne ya da dizi | 200 | `application/json; charset=utf-8` | `JSON.stringify()` ve route'un [`json` seçenekleri](#json) ile serileştirilir | +| `Buffer` | 200 | `application/octet-stream` | | +| readable stream | 200 | `application/octet-stream` | Chunked olarak gönderilir. Object mode'da olmamalıdır. Stream'de `statusCode` ve `headers` varsa onları kopyalar | +| `null` | 204 | yok | 200 göndermek için route'un `response.emptyStatusCode` değerini `200` yap | +| `undefined` | 500 | | Bir handler bir şey döndürmek zorundadır; hapi "handler method did not return a value" mesajını loglar | +| `Error` | 500 | `application/json; charset=utf-8` | Mesaj istemciden gizlenir | +| boom hatası | hatanın durum kodu | `application/json; charset=utf-8` | `{ statusCode, error, message }`; 5xx mesajları gizlenir | +| `h.response(value)` | `value` ile aynı | `value` ile aynı | `.code()`, `.header()`, `.type()` ve diğer [response nesnesi](/api#response-object) metotlarını zincirle | +| `h.redirect(uri)` | 302 | `text/html; charset=utf-8` | Gövde boştur. `.permanent()` 301, `.rewritable(false)` 307, ikisi birlikte 308 verir. 307 ve 308 istemcinin aynı metodu tekrarlamasını sağlar, yani yönlendirilen bir POST yine POST olur | + +- `Readable.from(array)` object mode'da bir stream oluşturur ve hapi bunu 500 ile reddeder. `{ objectMode: false }` geç ya da `Fs.createReadStream()` gibi bir bayt stream'i döndür. +- [wreck](/module/wreck)'ten gelen bir HTTP yanıtı gibi `statusCode` ve `headers` özelliklerine sahip bir stream, kendi durum kodunu ve header'larını aynen aktarır. Bu sayede basit bir proxy tek satırlık bir handler olur; ama aynı zamanda upstream'deki bir 404 senin 404'ün olur. + +## h.response() ile yanıtı şekillendirmek + +Varsayılanlar uyuyorsa düz bir değer döndür. Uymuyorsa değeri [`h.response()`](/api#h-response-value) ile sarmala ve ihtiyacın olanı zincirle: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/users', + handler: (request, h) => { + const user = { id: '2133d32a', name: request.payload.name }; + + return h.response(user).created(`/users/${user.id}`).header('x-request-id', request.info.id); + }, +}); + +server.route({ + method: 'GET', + path: '/robots.txt', + handler: (request, h) => { + return h.response('User-agent: *\nDisallow: /admin\n').type('text/plain'); + }, +}); +``` + +`.created()` tek çağrıda durum kodunu 201 yapar ve `location` header'ını ayarlar. + +## Hatalar + +Her hata sonunda bir boom nesnesine dönüşür. hapi düz bir `Error`'ı `Boom.boomify()` ile sarmalar ve ona 500 durum kodunu verir; boom da herhangi bir 5xx hatanın mesajını istemciden gizler. Kullanıcıların `An internal server error occurred` görür, hapi ise gerçek hatayı `'error'` kanalında bir `'request'` olayı olarak yayar; bunu kaydetmek için [Loglama](/tutorials/tr_TR/logging) eğitimine bak. İstemcinin mesajı görmesi gerekiyorsa `Boom.notFound('No such user')` gibi bir boom hatası fırlat. + +Bir boom hatasının yanıtı `output` özelliğinden gelir: + +```ts +import Boom from '@hapi/boom'; + +const error = Boom.notFound('No such user'); + +error.output.statusCode; // 404 +error.output.payload; // { statusCode: 404, error: 'Not Found', message: 'No such user' } +error.output.headers; // {}, or headers such as WWW-Authenticate from auth errors +``` + +### Tutarlı bir hata formatı + +API'ler genellikle hataları boom'un formatında değil kendi formatlarında döndürmek ister. Dönüşümü `onPreResponse` içinde yap; bilinmeyen route'lar için oluşan 404'ler ve plugin'lerin fırlattığı hatalar dahil her hata buradan geçer: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ + error: { + status: statusCode, + code: payload.error, + message: payload.message, + requestId: request.info.id, + }, + }); + + reply.code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; +}); +``` + +`output.headers`'ı yeni yanıta kopyala. Auth hataları orada `WWW-Authenticate` taşır, `Boom.methodNotAllowed()` da `Allow` taşıyabilir. `payload.message` zaten güvenli mesajdır: 5xx hatalarda boom onu genel metinle değiştirmiştir. + +### HTML hata sayfaları + +HTML render eden bir site, aynı hook'tan bunun yerine bir hata sayfası döndürebilir; [View'lar](/tutorials/tr_TR/views#errors) eğitimi bunu @hapi/vision ile gösteriyor. Hem sayfa hem API sunan bir uygulamada, API istemcileri JSON almaya devam etsin diye API route'ları için sayfayı atla; örneğin `request.path.startsWith('/api/')` ile. + +## Özel yanıt tipleri + +Dosya ve şablon gönderen plugin'ler string döndürmez. [@hapi/inert](/module/inert) ve [@hapi/vision](/module/vision), yanıtlarını bir kaynak değer ve isteğe bağlı işlemciler alan [`request.generateResponse()`](/api#request-generateresponse-source-options) ile oluşturur. `prepare` ve `marshal` akışın iki aşamasında çalışır; `close` ise yanıt işi bittiğinde, gönderilmiş ya da başka bir yanıtla değiştirilmiş olsun, çalışır: + +| Plugin | `variety` | `prepare`, `onPreResponse` öncesinde çalışır | `marshal`, `onPreResponse` sonrasında çalışır | +| ------ | --------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| inert | `'file'` | dosyayı açar; `content-length`, `content-type`, `last-modified`, ETag ayarlar | bir okuma stream'i ya da önceden sıkıştırılmış bir dosya (`.gz` ya da `lookupMap`'teki diğer uzantılar) döndürür | +| vision | `'view'` | şablonu yükler ve derler | şablonu context'iyle render eder; `content-type` ayarlar | + +Bu ayrım bilinçlidir. Eksik bir dosya `prepare` aşamasında hata verir, böylece `onPreResponse`'a hata handler'ının formatlayabileceği bir 404 olarak ulaşır. Pahalı iş, yani dosyayı okumak ya da şablonu render etmek, `marshal` aşamasında, `onPreResponse` yanıtı değiştirme fırsatını bulduktan sonra yapılır. inert ayrıca `close` da verir; bu, bir `onPreResponse` hook'u yanıtı değiştirse bile dosyayı kapatır. Bir kaynak açan özel bir yanıtın da buna ihtiyacı vardır. + +Aynı mekanizmayı kendi formatların için kullanabilirsin. İşte bir CSV yanıtı: + +```ts +import Hapi from '@hapi/hapi'; + +import type { Request } from '@hapi/hapi'; + +const field = (value: string) => (/[",\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value); + +const csv = (request: Request, rows: string[][]) => { + return request + .generateResponse(rows, { + variety: 'csv', + marshal: async () => rows.map((row) => row.map(field).join(',')).join('\n'), + }) + .type('text/csv'); +}; + +const server = Hapi.server({ port: 4000 }); + +server.route({ + method: 'GET', + path: '/users.csv', + handler: (request, h) => { + return csv(request, [ + ['id', 'name'], + ['2133d32a', 'John Doe'], + ]); + }, +}); +``` + +`field()`, virgül, tırnak ya da satır sonu içeren değerleri tırnak içine alır, böylece bu değerler bir satırı bölemez. Bunu herhangi bir handler'dan `h.csv(rows)` olarak çağırmak için bir decorator ile response toolkit'e ekle; [Decorator'lar](/tutorials/tr_TR/decorators) eğitimine bak. + +## JSON serileştirme + +Nesneler `JSON.stringify()` ile serileştirilir. Bunun nasıl yapılacağını route'un [`json` seçenekleri](/api#route-options-json) belirler: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ + port: 4000, + routes: { + json: { + space: process.env.NODE_ENV === 'production' ? 0 : 2, + }, + }, +}); + +server.route({ + method: 'GET', + path: '/widget-config', + options: { + json: { escape: true }, + }, + handler: (request, h) => { + return { title: '' }; + }, +}); +``` + +- `space` çıktıyı girintiler. Okunabilir JSON geliştirme sırasında işe yarar, production'da ise bayt harcar. +- `escape: true`, `<`, `>` ve `&` karakterlerini `\u003c`, `\u003e` ve `\u0026` olarak yazar; ayrıca U+2028 ve U+2029 satır ayırıcılarını da escape eder. JSON'u bir HTML sayfasına gömülen her route'ta bunu kullan; böylece `` içeren bir değer çevresindeki script tag'ini kapatamaz. +- `replacer`, `JSON.stringify()`'a aktarılır; `suffix` ise çıktının sonuna eklenir. Tek bir yanıt bunları `.spaces()`, `.replacer()` ve `.suffix()` ile geçersiz kılabilir. + +Parola hash'leri gibi gizli bilgileri kaldırmak için `replacer`'a güvenme. Yanıt nesnesini göndermek istediğin alanlardan oluştur ve hataları yakalamak için [yanıt doğrulama](#validation) kullan. + +## Önbellek ve koşullu istekler + +Route'un [`cache` seçeneği](/api#route-options-cache), marshalling sırasında `cache-control` değerini ayarlar: + +```ts +server.route({ + method: 'GET', + path: '/articles/{id}', + options: { + cache: { expiresIn: 60 * 1000, privacy: 'public' }, + }, + handler: (request, h) => { + const article = { id: request.params.id, version: 7, body: '...' }; + + const notModified = h.entity({ etag: `${article.id}-${article.version}` }); + if (notModified) { + return notModified; + } + + return article; + }, +}); +``` + +- `cache`, `cache-control: max-age=60, must-revalidate, public` üretir. İstek kimliği doğrulanmışsa hapi bunu kendiliğinden `private` yapar. `h.state()` ile ayarlanan cookie'ler ise bunu değiştirmez; yar ve @hapi/cookie gibi oturum plugin'leri de cookie'lerini bu şekilde ayarlar. Bu yüzden cookie ayarlayan bir route'ta `privacy: 'private'` kullan ya da hiç `cache` kullanma. Aksi halde paylaşılan bir önbellek bir kullanıcının cookie'sini saklayıp başka bir kullanıcıya verebilir. +- [`h.entity()`](/api#h-entity-options), ETag'i isteğin `If-None-Match` header'ıyla karşılaştırır. Eşleşirlerse bir 304 yanıtı döndürür ve handler gövdeyi oluşturmayı atlar. Eşleşmezlerse `etag` header'ını ayarlar ve hiçbir şey döndürmez. + +ETag'i, içerik her değiştiğinde değişen ucuz bir şeye dayandır; örneğin bir sürüm numarası ya da güncelleme zaman damgası. Gövdenin tamamını hash'lemek bant genişliğinden tasarruf sağlar ama gövdeyi oluşturma işinden kurtarmaz. + +## Sıkıştırma + +hapi, şu koşulların hepsi sağlandığında yanıtı gzip ya da deflate ile sıkıştırır: + +- istemci uygun bir `Accept-Encoding` header'ı gönderir, +- içerik tipi sıkıştırılabilir (metin, JSON, JavaScript, SVG ve benzerleri; resimler ya da arşivler değil), +- payload en az [`compression.minBytes`](/api#server-options-compression-minbytes) büyüklüğündedir (varsayılan 1024 bayt) ya da stream'lerde olduğu gibi boyutu bilinmiyordur. + +hapi ayrıca `vary: accept-encoding` ekler, böylece önbellekler sıkıştırılmış ve sıkıştırılmamış kopyaları birbirinden ayrı tutar. Brotli desteği için [`server.encoder()`](/api#server-encoder-encoding-encoder) ile bir encoder kaydet: + +```ts +import Zlib from 'node:zlib'; + +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.encoder('br', (options) => Zlib.createBrotliCompress(options)); +``` + +Artık istemci kabul ettiğinde hapi `br`'yi tercih eder. Bir route'un [`compression` seçeneği](/api#route-options-compression) bir encoder'a ayar aktarır; örneğin boyuttan feragat edip hız kazanmak için `compression: { br: { params: { [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 } } }`. Payload zaten sıkıştırılmışsa, örneğin diskten okunan bir `.gz` dosyası, onu `.compressed('gzip')` ile işaretle; böylece hapi header'ı ayarlar ve baytlara dokunmaz. + +## Yanıt doğrulama + +[Yanıt doğrulama](/api#route-options-response), payload'u `onPreResponse` öncesinde bir şemaya göre kontrol eder. Yanlış şekilde veri döndüren handler'ları yakalar; sunucudan asla çıkmaması gereken alanlar da buna dahildir: + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; + +const server = Hapi.server({ port: 4000 }); + +const User = Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), +}); + +server.route({ + method: 'GET', + path: '/users/{id}', + options: { + response: { + schema: User, + sample: process.env.NODE_ENV === 'production' ? 10 : 100, + failAction: process.env.NODE_ENV === 'production' ? 'log' : 'error', + }, + }, + handler: (request, h) => { + return { id: request.params.id, name: 'John Doe' }; + }, +}); +``` + +- Varsayılan `failAction: 'error'` ile şemaya uymayan bir payload 500'e dönüşür. Hataların açıkça ortaya çıkması için bunu geliştirme ve testlerde kullan. +- `failAction: 'log'` orijinal payload'u gönderir ve hatayı `validation`, `response` ve `error` tag'leriyle loglar. Production'da bu, bir şema hatasının kesintiye dönüşmesini engeller; ama payload, şemanın reddettiği alanlar dahil yine de gönderilir. +- `sample` yanıtların yalnızca belli bir yüzdesini doğrular; bu da yoğun route'larda maliyeti sınırlar. + +Bilinmeyen alanları reddetmek yerine kaldırmak için `modify: true` ve `options: { stripUnknown: true }` ayarla ve `sample`'ı kaldır. `modify` ile doğrulama yanıt mantığının bir parçası olur; bu yüzden hapi her yanıtı doğrular ve `sample` kabul etmez. + +## Yanıtları ölçmek + +Bir response nesnesi yazılırken [olaylar](/api#response-events) yayar: her chunk için `'peek'` ve sonda `'finish'`. Bir plugin bunları yanıt boyutlarını kaydetmek için kullanabilir: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (Boom.isBoom(response)) { + return h.continue; + } + + let bytes = 0; + response.events.on('peek', (chunk) => { + bytes += Buffer.byteLength(chunk); + }); + response.events.once('finish', () => { + request.log(['metrics'], { path: request.path, bytes }); + }); + + return h.continue; +}); +``` + +`'peek'` payload'u sıkıştırmadan önce görür, bu yüzden `bytes` sıkıştırılmamış boyuttur. Hata yanıtları burada atlanır: bir boom hatası marshalling sırasında, bu hook çalıştıktan sonra yeni bir response nesnesine dönüşür. Bir isteğin tamamını zamanlamak için `onPostResponse` extension'ını ya da sunucunun `'response'` olayını kullan; bunlar [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks) ve [Loglama](/tutorials/tr_TR/logging) eğitimlerinde anlatılıyor. diff --git a/docs/tutorials/zh_CN/response-marshalling.md b/docs/tutorials/zh_CN/response-marshalling.md new file mode 100644 index 00000000..102493c8 --- /dev/null +++ b/docs/tutorials/zh_CN/response-marshalling.md @@ -0,0 +1,352 @@ +--- +title: 响应编组 +--- + + + +# 响应编组 + +_本教程适用于 hapi v21 及更高版本_ + +## 概述 + +handler 返回一个值:字符串、对象、流或错误。响应编组(response marshalling)指的是 hapi 把这个值变成 HTTP 响应的全部过程:选定状态码、设置响应头、序列化 payload、压缩,最后写入 socket。 + +了解这个过程,你就知道代码该放在哪里。统一的错误格式、HTML 错误页、缓存响应头、压缩、CSV 这类自定义响应类型、用于监控的字节计数器,每一项都对应编组流程中的一个特定位置。[响应对象](/api#response-object)和[响应工具包](/api#response-toolkit)的 API 文档列出了所有方法。 + +## 从返回值到字节 + +编组分为两个阶段,中间是 `onPreResponse` 扩展点: + +```mermaid +flowchart TD + A["handler 返回一个值"] --> B["包装与准备
值变成响应对象"] + B --> C["onPostHandler,
响应验证"] + C --> D["onPreResponse
最后一次修改机会"] + D --> E["编组
响应头与 payload 序列化"] + E --> F["空 payload 返回 204,
压缩或 range 处理"] + F --> G["写入 socket"] + + class B,D,E highlight +``` + +编组步骤按固定顺序执行,这决定了每项设置能看到哪些响应头: + +1. 根据 payload 类型设置 `content-type` +2. CORS 和安全相关的响应头(如果路由启用了它们) +3. 来自 [`h.entity()`](/api#h-entity-options) 的 ETag 和 `last-modified`,然后在 GET 路由上做 304 检查 +4. 根据路由的 `cache` 选项设置 `cache-control` +5. 来自 [`h.state()`](/api#h-state-name-value-options) 以及带 `autoValue` 的 cookie 的 `set-cookie` +6. payload 序列化和 `content-length` +7. 身份认证 scheme 的 `response()` 方法,放在最后执行,因此它能读取上面所有的响应头 + +这种划分在实际中很重要: + +- **在 `onPreResponse` 之前**,响应是一个响应对象或一个 [boom](/module/boom) 错误,你可以检查、修改或替换它。包装与准备阶段产生的错误,例如文件不存在,会以 boom 错误的形式到达 `onPreResponse`。 +- **在 `onPreResponse` 之后**,hapi 序列化 payload 并计算响应头。这个阶段出现的错误,例如 `JSON.stringify()` 无法处理的对象,会变成 500,而 `onPreResponse` 永远看不到它。让 payload 保持为纯数据,编组就不会失败。 + +[请求生命周期](/api#request-lifecycle)各步骤的顺序在[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)教程中介绍。 + +## 返回什么,hapi 发送什么 + +| handler 返回 | 状态码 | 默认 `content-type` | 说明 | +| ------------------- | ----------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 字符串 | 200,为空时为 204 | `text/html; charset=utf-8` | 纯文本请设置 `.type('text/plain')` | +| 对象或数组 | 200 | `application/json; charset=utf-8` | 使用 `JSON.stringify()` 和路由的 [`json` 选项](#json)序列化 | +| `Buffer` | 200 | `application/octet-stream` | | +| 可读流 | 200 | `application/octet-stream` | 以分块方式发送。不能是对象模式。如果流带有 `statusCode` 和 `headers`,会被复制到响应上 | +| `null` | 204 | 无 | 将路由的 `response.emptyStatusCode` 设为 `200` 可改为发送 200 | +| `undefined` | 500 | | handler 必须返回某个值;hapi 会记录 "handler method did not return a value" | +| `Error` | 500 | `application/json; charset=utf-8` | 错误消息对客户端隐藏 | +| boom 错误 | 错误自身的状态码 | `application/json; charset=utf-8` | `{ statusCode, error, message }`;5xx 的消息会被隐藏 | +| `h.response(value)` | 与 `value` 相同 | 与 `value` 相同 | 可链式调用 `.code()`、`.header()`、`.type()` 及其他[响应对象](/api#response-object)方法 | +| `h.redirect(uri)` | 302 | `text/html; charset=utf-8` | 响应体为空。`.permanent()` 得到 301,`.rewritable(false)` 得到 307,两者同时使用得到 308。307 和 308 要求客户端用同样的方法重发请求,所以重定向后的 POST 仍是 POST | + +- `Readable.from(array)` 创建的是对象模式的流,hapi 会以 500 拒绝它。传入 `{ objectMode: false }`,或者返回 `Fs.createReadStream()` 这样的字节流。 +- 带有 `statusCode` 和 `headers` 属性的流,例如 [wreck](/module/wreck) 返回的 HTTP 响应,会把自身的状态码和响应头传递下去。因此一个简单的代理只需一行 handler,同时也意味着上游的 404 会变成你的 404。 + +## 用 h.response() 定制响应 + +默认行为合适时,直接返回普通值。不合适时,用 [`h.response()`](/api#h-response-value) 包装这个值,再链式调用需要的方法: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.route<{ Payload: { name: string } }>({ + method: 'POST', + path: '/users', + handler: (request, h) => { + const user = { id: '2133d32a', name: request.payload.name }; + + return h.response(user).created(`/users/${user.id}`).header('x-request-id', request.info.id); + }, +}); + +server.route({ + method: 'GET', + path: '/robots.txt', + handler: (request, h) => { + return h.response('User-agent: *\nDisallow: /admin\n').type('text/plain'); + }, +}); +``` + +`.created()` 一次调用就把状态码设为 201 并设置 `location` 响应头。 + +## 错误 + +每个错误最终都会成为一个 boom 对象。hapi 用 `Boom.boomify()` 包装普通的 `Error`,赋予它 500 状态码,而 boom 会对客户端隐藏所有 5xx 错误的消息。用户看到的是 `An internal server error occurred`,hapi 则把真实错误作为 `'request'` 事件在 `'error'` 频道上发出;如何记录它请参阅[日志](/tutorials/zh_CN/logging)。如果希望客户端看到错误消息,就抛出 boom 错误,例如 `Boom.notFound('No such user')`。 + +boom 错误的响应来自它的 `output` 属性: + +```ts +import Boom from '@hapi/boom'; + +const error = Boom.notFound('No such user'); + +error.output.statusCode; // 404 +error.output.payload; // { statusCode: 404, error: 'Not Found', message: 'No such user' } +error.output.headers; // {}, or headers such as WWW-Authenticate from auth errors +``` + +### 统一的错误格式 + +API 往往需要使用自己的错误格式,而不是 boom 的格式。在 `onPreResponse` 中进行转换,所有错误都会经过这里,包括未知路由的 404 和插件抛出的错误: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (!Boom.isBoom(response)) { + return h.continue; + } + + const { statusCode, payload, headers } = response.output; + const reply = h.response({ + error: { + status: statusCode, + code: payload.error, + message: payload.message, + requestId: request.info.id, + }, + }); + + reply.code(statusCode); + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.header(name, String(value)); + } + } + + return reply; +}); +``` + +记得把 `output.headers` 复制到新的响应上。认证错误在这里带有 `WWW-Authenticate`,`Boom.methodNotAllowed()` 可能带有 `Allow`。`payload.message` 已经是可以安全展示的消息:对于 5xx 错误,boom 已经把它替换成了通用文本。 + +### HTML 错误页 + +渲染 HTML 的网站可以在同一个钩子里返回错误页面;[视图教程](/tutorials/zh_CN/views#errors)展示了如何用 @hapi/vision 实现。如果一个应用同时提供页面和 API,就对 API 路由跳过错误页,例如用 `request.path.startsWith('/api/')` 判断,这样 API 客户端仍然收到 JSON。 + +## 自定义响应类型 + +发送文件和模板的插件并不返回字符串。[@hapi/inert](/module/inert) 和 [@hapi/vision](/module/vision) 用 [`request.generateResponse()`](/api#request-generateresponse-source-options) 创建响应,它接收一个源值和可选的处理函数。`prepare` 和 `marshal` 分别在流程的两个阶段运行,`close` 在响应结束时运行,无论响应是被发送还是被替换: + +| 插件 | `variety` | `prepare` 在 `onPreResponse` 之前运行 | `marshal` 在 `onPreResponse` 之后运行 | +| ------ | --------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| inert | `'file'` | 打开文件;设置 `content-length`、`content-type`、`last-modified` 和 ETag | 返回读取流,或预压缩文件(`.gz`,或 `lookupMap` 中的其他扩展名) | +| vision | `'view'` | 加载并编译模板 | 用上下文渲染模板;设置 `content-type` | + +这种划分是有意为之。文件不存在时会在 `prepare` 阶段失败,因此它会以 404 的形式到达 `onPreResponse`,由你的错误处理器来格式化。读取文件或渲染模板这些开销较大的工作在 `marshal` 中进行,此时 `onPreResponse` 已经有机会替换响应。inert 还传入了 `close`,即使 `onPreResponse` 钩子替换了响应,它也会关闭文件;打开了某种资源的自定义响应同样需要这样一个函数。 + +你可以用同样的机制实现自己的格式。下面是一个 CSV 响应: + +```ts +import Hapi from '@hapi/hapi'; + +import type { Request } from '@hapi/hapi'; + +const field = (value: string) => (/[",\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value); + +const csv = (request: Request, rows: string[][]) => { + return request + .generateResponse(rows, { + variety: 'csv', + marshal: async () => rows.map((row) => row.map(field).join(',')).join('\n'), + }) + .type('text/csv'); +}; + +const server = Hapi.server({ port: 4000 }); + +server.route({ + method: 'GET', + path: '/users.csv', + handler: (request, h) => { + return csv(request, [ + ['id', 'name'], + ['2133d32a', 'John Doe'], + ]); + }, +}); +``` + +`field()` 会给包含逗号、引号或换行符的值加上引号,这样它们就不会把一行拆开。如果想在任意 handler 中以 `h.csv(rows)` 的方式调用,可以用装饰器把它添加到响应工具包上;请参阅[装饰器](/tutorials/zh_CN/decorators)教程。 + +## JSON 序列化 + +对象使用 `JSON.stringify()` 序列化。路由的 [`json` 选项](/api#route-options-json)控制序列化方式: + +```ts +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ + port: 4000, + routes: { + json: { + space: process.env.NODE_ENV === 'production' ? 0 : 2, + }, + }, +}); + +server.route({ + method: 'GET', + path: '/widget-config', + options: { + json: { escape: true }, + }, + handler: (request, h) => { + return { title: '' }; + }, +}); +``` + +- `space` 用于缩进输出。可读的 JSON 在开发时有用,但在生产环境中会多占字节。 +- `escape: true` 把 `<`、`>` 和 `&` 写成 `\u003c`、`\u003e` 和 `\u0026`,同时转义 U+2028 和 U+2029 行分隔符。凡是 JSON 会被嵌入 HTML 页面的路由都应启用它,这样包含 `` 的值就无法闭合外层的 script 标签。 +- `replacer` 会传给 `JSON.stringify()`,`suffix` 会追加到输出末尾。单个响应可以用 `.spaces()`、`.replacer()` 和 `.suffix()` 覆盖它们。 + +不要依赖 `replacer` 去移除密码哈希这类敏感数据。应当只用你打算发送的字段来构建响应对象,并用[响应验证](#validation)来发现错误。 + +## 缓存与条件请求 + +路由的 [`cache` 选项](/api#route-options-cache)在编组期间设置 `cache-control`: + +```ts +server.route({ + method: 'GET', + path: '/articles/{id}', + options: { + cache: { expiresIn: 60 * 1000, privacy: 'public' }, + }, + handler: (request, h) => { + const article = { id: request.params.id, version: 7, body: '...' }; + + const notModified = h.entity({ etag: `${article.id}-${article.version}` }); + if (notModified) { + return notModified; + } + + return article; + }, +}); +``` + +- `cache` 生成 `cache-control: max-age=60, must-revalidate, public`。当请求已认证时,hapi 会自动把它改为 `private`。用 `h.state()` 设置 cookie 不会改变它,而 yar 和 @hapi/cookie 这类会话插件正是这样设置 cookie 的,所以在设置 cookie 的路由上,要么使用 `privacy: 'private'`,要么完全不设置 `cache`。否则共享缓存可能存下某个用户的 cookie,再把它交给另一个用户。 +- [`h.entity()`](/api#h-entity-options) 会把 ETag 与请求的 `If-None-Match` 请求头进行比较。两者匹配时,它返回一个 304 响应,handler 也就不必构建响应体。不匹配时,它设置 `etag` 响应头并且不返回任何值。 + +ETag 应基于某个计算成本低、且内容一变它就变的值,例如版本号或更新时间戳。对整个响应体做哈希可以节省带宽,但省不掉构建响应体的工作。 + +## 压缩 + +满足以下所有条件时,hapi 会用 gzip 或 deflate 压缩响应: + +- 客户端发送了匹配的 `Accept-Encoding` 请求头, +- 内容类型可压缩(文本、JSON、JavaScript、SVG 等;图片和压缩包不算), +- payload 至少为 [`compression.minBytes`](/api#server-options-compression-minbytes)(默认 1024 字节),或者大小未知,例如流。 + +hapi 还会添加 `vary: accept-encoding`,让缓存把压缩和未压缩的副本分开存放。要支持 Brotli,请用 [`server.encoder()`](/api#server-encoder-encoding-encoder) 注册一个编码器: + +```ts +import Zlib from 'node:zlib'; + +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.encoder('br', (options) => Zlib.createBrotliCompress(options)); +``` + +此后只要客户端接受 `br`,hapi 就会优先使用它。路由的 [`compression` 选项](/api#route-options-compression)可以向编码器传递设置,例如用 `compression: { br: { params: { [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 } } }` 以压缩率换取速度。如果 payload 已经压缩过,例如从磁盘读取的 `.gz` 文件,就用 `.compressed('gzip')` 标记它,hapi 会设置相应的响应头,不再改动这些字节。 + +## 响应验证 + +[响应验证](/api#route-options-response)会在 `onPreResponse` 之前用 schema 检查 payload。它能发现返回了错误结构的 handler,包括那些本不该离开服务器的字段: + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; + +const server = Hapi.server({ port: 4000 }); + +const User = Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), +}); + +server.route({ + method: 'GET', + path: '/users/{id}', + options: { + response: { + schema: User, + sample: process.env.NODE_ENV === 'production' ? 10 : 100, + failAction: process.env.NODE_ENV === 'production' ? 'log' : 'error', + }, + }, + handler: (request, h) => { + return { id: request.params.id, name: 'John Doe' }; + }, +}); +``` + +- 使用默认的 `failAction: 'error'` 时,未通过 schema 的 payload 会变成 500。在开发和测试中使用它,让错误尽早暴露。 +- `failAction: 'log'` 会照常发送原始 payload,并以 `validation`、`response` 和 `error` 标签记录这次失败。在生产环境中,这能避免 schema 写错导致服务中断,但 payload 仍然会发出去,包括 schema 拒绝的字段。 +- `sample` 只验证一定百分比的响应,用来限制繁忙路由上的开销。 + +如果想移除未知字段而不是拒绝它们,请设置 `modify: true` 和 `options: { stripUnknown: true }`,并去掉 `sample`。使用 `modify` 时,验证成为响应逻辑的一部分,所以 hapi 会验证每一个响应,不接受 `sample`。 + +## 测量响应 + +响应对象在写出过程中会发出[事件](/api#response-events):每个数据块触发 `'peek'`,结束时触发 `'finish'`。插件可以利用它们记录响应大小: + +```ts +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; + +const server = Hapi.server({ port: 4000 }); + +server.ext('onPreResponse', (request, h) => { + const { response } = request; + if (Boom.isBoom(response)) { + return h.continue; + } + + let bytes = 0; + response.events.on('peek', (chunk) => { + bytes += Buffer.byteLength(chunk); + }); + response.events.once('finish', () => { + request.log(['metrics'], { path: request.path, bytes }); + }); + + return h.continue; +}); +``` + +`'peek'` 看到的是压缩前的 payload,所以 `bytes` 是未压缩的大小。这里跳过了错误响应:boom 错误会在编组期间、也就是这个钩子运行之后,才变成新的响应对象。如果要统计整个请求的耗时,请使用 `onPostResponse` 扩展或服务器的 `'response'` 事件,详见[生命周期钩子](/tutorials/zh_CN/lifecycle-hooks)和[日志](/tutorials/zh_CN/logging)。 From 7cc9d76fc3d8434b9688edf0e69dbd2eb1bfec2c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:55:12 -0400 Subject: [PATCH 21/22] docs: add server-sent events tutorial --- .vitepress/config.ts | 1 + docs/tutorials/en_US/sse.md | 307 ++++++++++++++++++++++++++++++++++++ docs/tutorials/es_ES/sse.md | 307 ++++++++++++++++++++++++++++++++++++ docs/tutorials/ko_KR/sse.md | 307 ++++++++++++++++++++++++++++++++++++ docs/tutorials/pt_BR/sse.md | 307 ++++++++++++++++++++++++++++++++++++ docs/tutorials/tr_TR/sse.md | 307 ++++++++++++++++++++++++++++++++++++ docs/tutorials/zh_CN/sse.md | 307 ++++++++++++++++++++++++++++++++++++ 7 files changed, 1843 insertions(+) create mode 100644 docs/tutorials/en_US/sse.md create mode 100644 docs/tutorials/es_ES/sse.md create mode 100644 docs/tutorials/ko_KR/sse.md create mode 100644 docs/tutorials/pt_BR/sse.md create mode 100644 docs/tutorials/tr_TR/sse.md create mode 100644 docs/tutorials/zh_CN/sse.md diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 55b00d16..649dcdf0 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -65,6 +65,7 @@ const tutorialItems = [ { slug: 'routing', text: 'Routing' }, { slug: 'server-methods', text: 'Server Methods' }, { slug: 'serving-files', text: 'Serving Files' }, + { slug: 'sse', text: 'Server-Sent Events' }, { slug: 'testing', text: 'Testing' }, { slug: 'validation', text: 'Validation' }, { slug: 'views', text: 'Views' }, diff --git a/docs/tutorials/en_US/sse.md b/docs/tutorials/en_US/sse.md new file mode 100644 index 00000000..aa486352 --- /dev/null +++ b/docs/tutorials/en_US/sse.md @@ -0,0 +1,307 @@ +--- +title: Server-Sent Events +--- + + + +# Server-Sent Events + +_This tutorial is compatible with hapi v21 and newer_ + +## Overview + +Server-Sent Events (SSE) is a way for the server to push a stream of events to the client over one ordinary HTTP response. The browser opens it with the built-in `EventSource` API, which also reconnects on its own when the connection drops. [@hapi/sse](/module/sse) adds SSE to hapi. + +## When to use SSE + +| | SSE | [WebSockets](/tutorials/en_US/websockets) | Polling | +| --------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------- | +| Direction | server to client only | both ways | client asks, server answers | +| Transport | a normal HTTP response | a protocol upgrade | normal HTTP requests | +| Reconnects | built into `EventSource`, resumes from the last event id | the nes client reconnects and resubscribes; messages sent while offline are lost | not needed | +| Works through proxies | yes, with buffering turned off | the proxy must support upgrades | yes | +| Latency | immediate | immediate | up to the polling interval | + +Pick SSE when the server has news and the client only listens: job progress, notifications, live dashboards, log tails, and token-by-token output from an AI model. When the client also needs to send a steady stream of messages, use WebSockets. A client that only sends now and then can use SSE for the stream and plain `POST` requests for the rest. + +## Setup + +```sh +npm install @hapi/sse +``` + +```ts +import Hapi from '@hapi/hapi'; +import { SsePlugin } from '@hapi/sse'; + +const server = Hapi.server({ port: 3000 }); + +await server.register(SsePlugin); +``` + +The plugin gives you two ways to open a stream: + +| Use | Handler mode: `handler: { sse: { stream } }` | Subscriptions: `server.sse.subscription()` | +| -------------------------- | ----------------------------------------------- | ------------------------------------------ | +| Who produces the events | your `stream` function, for that one connection | any code, through `server.sse.publish()` | +| Typical stream | progress of one job, an AI response, a log tail | notifications, a live feed, a chat room | +| Events per connection | different for every client | the same event goes to every subscriber | +| Resuming after a reconnect | you read `session.lastEventId` and continue | a replayer resends what the client missed | + +## Handler mode: one stream per request + +A route with the `sse` handler runs your `stream` function once per connection. Everything you `push()` reaches the client as it happens: + +```ts +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + for await (const update of watchJob(String(request.params.id))) { + session.push(update, 'progress', String(update.percent)); + } + + await session.complete(); + }, + }, + }, +}); +``` + +Each `push(data, event, id)` becomes one event on the wire. Objects are sent as JSON: + +```text +event: progress +id: 50 +data: {"percent":50} +``` + +`complete()` sends a final `complete` event and ends the response. Ending a response is not enough to stop a browser, because `EventSource` treats every closed connection as a dropped one and reconnects. The client has to close the connection itself when it sees `complete`: + +```ts +const source = new EventSource('/jobs/7/progress'); + +source.addEventListener('progress', (event) => { + const { percent } = JSON.parse(event.data); + console.log(`${percent}%`); +}); + +source.addEventListener('complete', () => source.close()); +``` + +Without the `close()`, the browser reconnects after the `retry` delay and the stream starts over. + +Node.js has no global `EventSource` unless you start it with `--experimental-eventsource`. In Node, use that flag or the [`eventsource`](https://www.npmjs.com/package/eventsource) package; the client code is the same as in the browser. + +## Subscriptions: one event to many clients + +A subscription is a route that clients connect to and that your code publishes to. The path can have parameters, so each user or room gets its own stream: + +```ts +import Joi from 'joi'; + +server.sse.subscription('/rooms/{room}/messages'); + +server.route<{ Params: { room: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{room}/messages', + options: { + validate: { + params: Joi.object({ room: Joi.string().alphanum().max(50).required() }), + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: async (request, h) => { + const message = { text: request.payload.text, at: new Date().toISOString() }; + const delivered = await server.sse.publish(`/rooms/${request.params.room}/messages`, message, { + event: 'message', + matchMode: 'literal', + }); + + return { delivered }; + }, +}); +``` + +- `matchMode: 'literal'` sends the event only to clients of that exact path, so a message posted to `/rooms/lobby/messages` reaches the lobby and no other room. See [`server.sse.publish()`](/module/sse#server-sse-publish-path-data-opts). +- The route publishes only fields it [validated](/tutorials/en_US/validation), never the raw payload. Give it the same [route auth](/tutorials/en_US/auth) as any route that writes data. +- `publish()` returns the number of clients the event was delivered to. + +Subscriptions also take `filter`, `onSubscribe`, `refuse`, and `maxSessions`; see [`server.sse.subscription()`](/module/sse#server-sse-subscription-path-config). + +## Reconnects and `Last-Event-ID` + +When a connection drops, the browser waits for the `retry` time the server sent, then reconnects with the id of the last event it received in a `Last-Event-ID` header: + +```mermaid +sequenceDiagram + participant B as Browser + participant S as hapi server + B->>S: GET /feed + S-->>B: retry: 2000, then events a1, a2 + Note over B,S: connection drops, and a3 is published meanwhile + B->>S: GET /feed with Last-Event-ID: a2 + Note over S: the replayer finds a2
and resends what came after it + S-->>B: event a3, then live events +``` + +The browser sends the header, but only your server can act on it, and the two modes differ: + +- **Subscriptions:** give the subscription a replayer, which keeps recent events in memory and resends everything after the client's last id. A replayer keeps one history for the whole subscription, shared by every path and every client, so use it on subscriptions without path parameters, such as a public `/feed`. For per-user or per-room streams, resume from your database instead, as in handler mode. + + ```ts + import { FiniteReplayer } from '@hapi/sse'; + + server.sse.subscription('/feed', { + replay: new FiniteReplayer({ size: 100 }), + }); + + await server.sse.publish('/feed', item, { event: 'news', id: item.id }); + ``` + +- **Handler mode:** read `session.lastEventId` in `stream` and continue from there, for example by querying your database for rows newer than that id. + +If the client's last id is no longer in the buffer, for example after the server restarted, the replayer resends everything it holds, so give your events ids the client can use to drop duplicates. [Event replay](/module/sse#event-replay) describes what each replayer keeps. Replay buffers live in the memory of one process; see [Scaling](#scaling). + +## Cleaning up when a stream ends + +A stream stays open until one side closes it, so anything you start for a connection must stop when the stream ends, whether the client left, the stream completed, or the server is shutting down: + +```ts +import type { Session } from '@hapi/sse'; + +const openStreams = new Map void>(); + +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + const watcher = watchJob(String(request.params.id)); + openStreams.set(session, () => watcher.stop()); + request.events.once('disconnect', () => watcher.stop()); + + try { + for await (const update of watcher) { + session.push(update, 'progress', String(update.percent)); + } + + if (session.isOpen) { + await session.complete(); + } + } finally { + watcher.stop(); + openStreams.delete(session); + } + }, + }, + }, +}); + +server.ext('onPreStop', () => { + for (const [session, stop] of openStreams) { + stop(); + session.close(); + } +}); +``` + +- **Client leaves:** `request.events` emits `'disconnect'`, which stops the watcher and ends the loop. +- **Stream ends on the server:** the `finally` block runs, so the `Map` never holds a finished stream. +- **Server shuts down:** the `onPreStop` [server hook](/tutorials/en_US/lifecycle-hooks#server-hooks) stops every watcher and closes every stream, so `server.stop()` finishes without waiting out its `timeout`. +- **Subscriptions:** use the subscription's `onUnsubscribe`, or the plugin's `onSessionClose` hook for every subscription stream. The plugin removes the client from the subscription on its own. + +## Proxies, buffering, and idle timeouts + +Most SSE problems in production come from something between the server and the browser holding events back or closing a quiet connection. The plugin handles part of it: + +| Problem | What the plugin does | What you do | +| ------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | +| A proxy buffers the response and delivers events in bursts | sends `X-Accel-Buffering: no` and `Cache-Control: no-cache` | turn off response buffering in proxies that do not honor those headers | +| Compression holds events until enough bytes accumulate | flushes gzip and deflate after every write | nothing; events arrive one by one even when compressed | +| A load balancer or proxy closes connections that are idle too long | sends a `:` comment line every 15 seconds | keep `keepAlive.interval` below the shortest idle timeout on the path | +| Browsers allow only 6 HTTP/1.1 connections per domain | nothing | serve the site over HTTP/2, usually at the proxy, so streams share one connection | + +## Authentication + +An SSE stream is a normal hapi route, so the usual [route auth](/tutorials/en_US/auth) applies, scopes included. For subscriptions, pass it in the `auth` option: + +```ts +server.sse.subscription('/users/{userId}/notifications', { + auth: { strategy: 'session', access: { scope: 'user-{params.userId}' } }, +}); + +await server.sse.publish(`/users/${userId}/notifications`, notification, { + event: 'notification', + matchMode: 'literal', +}); +``` + +With the `user-{params.userId}` scope, a user can only open their own notification stream; anyone else gets a `403`. See [Scopes](/tutorials/en_US/auth#scopes). Publish with `matchMode: 'literal'` so each notification reaches only its own user's stream. + +`EventSource` cannot set request headers, so a bearer token in `Authorization` is not an option. The choices: + +| Approach | Good | Watch out for | +| -------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Cookie session, such as `@hapi/cookie` | the browser sends it, including on reconnects | cross-origin streams need `new EventSource(url, { withCredentials: true })` and CORS with credentials | +| Token in the query string | works with any client | URLs end up in server and proxy access logs; use a short-lived token made only for this stream | +| `fetch()` with a streaming body | can send any header | you lose `EventSource`'s reconnect and `Last-Event-ID` handling and must write them yourself | + +For a browser app on your own domain, a cookie is the simplest choice. + +## Scaling across instances + +Two server instances do not share memory. An SSE stream is an open connection held by one process: a client connected to instance A exists only on A, and instance B has no way to write to it. An event published on A reaches only A's clients. This holds for every SSE library and hosting provider: each process can only write to the connections it owns. + +The connected clients, the replay buffers, and by default the record of completed streams all live in that per-process memory. Scaling out needs message passing between the instances. A pub/sub backend such as Redis, NATS, or Postgres `LISTEN`/`NOTIFY` fans each event out to every instance, and each instance writes it to its own open streams: + +```mermaid +flowchart TD + P["an event is created
on instance A"] -->|publish| R[("broker
Redis, NATS, Postgres")] + R -->|fan out| A[instance A] + R -->|fan out| B[instance B] + A --> CA[clients on A] + B --> CB[clients on B] +``` + +With Redis: + +```ts +import { createClient } from 'redis'; + +interface RoomMessage { + room: string; + text: string; + at: string; +} + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('rooms', async (raw) => { + const message: RoomMessage = JSON.parse(raw); + await server.sse.publish(`/rooms/${message.room}/messages`, message, { event: 'message', matchMode: 'literal' }); +}); + +const publishToRoom = async (message: RoomMessage) => { + await publisher.publish('rooms', JSON.stringify(message)); +}; +``` + +The `POST` route calls `publishToRoom()` instead of `server.sse.publish()`, and every instance, including the one that handled the request, publishes to its own streams. + +- With the broker in place, every instance publishes every event, so every instance's replayer holds the same history and a reconnecting client can resume on any instance. Only events from before an instance started are missing, which the duplicate-safe ids from [Reconnects](#reconnect) already cover. +- Point the plugin's `completion.cache` option at a shared cache, such as catbox-redis, so a completed subscription stream is recognized on every instance. +- `maxDuration` closes each stream after a set time (with jitter), and the client reconnects. Long-lived streams then spread out again after you add instances, instead of staying on the old ones. + +## Next steps + +- [@hapi/sse API reference](/module/sse): every option, `Session`, replayers, [backpressure](/module/sse#backpressure) for slow clients, and the [built-in security defenses](/module/sse#security). +- [WebSockets](/tutorials/en_US/websockets) when the client needs to send messages too. +- [Lifecycle Hooks](/tutorials/en_US/lifecycle-hooks) for `onPreStop` and other server hooks. diff --git a/docs/tutorials/es_ES/sse.md b/docs/tutorials/es_ES/sse.md new file mode 100644 index 00000000..569b2048 --- /dev/null +++ b/docs/tutorials/es_ES/sse.md @@ -0,0 +1,307 @@ +--- +title: Server-Sent Events +--- + + + +# Server-Sent Events + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +Server-Sent Events (SSE) es una forma de que el servidor envíe un stream de eventos al cliente a través de una única respuesta HTTP normal. El navegador lo abre con la API `EventSource` integrada, que además se reconecta por sí sola cuando se cae la conexión. [@hapi/sse](/module/sse) añade SSE a hapi. + +## Cuándo usar SSE + +| | SSE | [WebSockets](/tutorials/es_ES/websockets) | Polling | +| ---------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Dirección | solo del servidor al cliente | en ambos sentidos | el cliente pregunta, el servidor responde | +| Transporte | una respuesta HTTP normal | un upgrade de protocolo | peticiones HTTP normales | +| Reconexión | integrada en `EventSource`, continúa desde el id del último evento | el cliente de nes se reconecta y vuelve a suscribirse; los mensajes enviados mientras estaba desconectado se pierden | no hace falta | +| Funciona a través de proxies | sí, con el buffering desactivado | el proxy tiene que admitir upgrades | sí | +| Latencia | inmediata | inmediata | hasta el intervalo de polling | + +Elige SSE cuando el servidor tiene novedades y el cliente solo escucha: el progreso de un trabajo, notificaciones, dashboards en directo, el final de un log y la salida token a token de un modelo de IA. Cuando el cliente también necesita enviar un flujo constante de mensajes, usa WebSockets. Un cliente que solo envía de vez en cuando puede usar SSE para el stream y peticiones `POST` normales para lo demás. + +## Configuración inicial + +```sh +npm install @hapi/sse +``` + +```ts +import Hapi from '@hapi/hapi'; +import { SsePlugin } from '@hapi/sse'; + +const server = Hapi.server({ port: 3000 }); + +await server.register(SsePlugin); +``` + +El plugin te da dos formas de abrir un stream: + +| Uso | Modo handler: `handler: { sse: { stream } }` | Suscripciones: `server.sse.subscription()` | +| ----------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------- | +| Quién produce los eventos | tu función `stream`, para esa única conexión | cualquier código, mediante `server.sse.publish()` | +| Stream típico | el progreso de un trabajo, una respuesta de IA, el final de un log | notificaciones, un feed en directo, una sala de chat | +| Eventos por conexión | distintos para cada cliente | el mismo evento llega a todos los suscriptores | +| Continuar tras una reconexión | lees `session.lastEventId` y continúas | un replayer reenvía lo que el cliente se perdió | + +## Modo handler: un stream por petición + +Una ruta con el handler `sse` ejecuta tu función `stream` una vez por conexión. Todo lo que envías con `push()` llega al cliente en el momento: + +```ts +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + for await (const update of watchJob(String(request.params.id))) { + session.push(update, 'progress', String(update.percent)); + } + + await session.complete(); + }, + }, + }, +}); +``` + +Cada `push(data, event, id)` se convierte en un evento en la conexión. Los objetos se envían como JSON: + +```text +event: progress +id: 50 +data: {"percent":50} +``` + +`complete()` envía un último evento `complete` y termina la respuesta. Terminar la respuesta no basta para detener a un navegador, porque `EventSource` trata cada conexión cerrada como una conexión caída y se reconecta. El cliente tiene que cerrar la conexión él mismo cuando ve `complete`: + +```ts +const source = new EventSource('/jobs/7/progress'); + +source.addEventListener('progress', (event) => { + const { percent } = JSON.parse(event.data); + console.log(`${percent}%`); +}); + +source.addEventListener('complete', () => source.close()); +``` + +Sin el `close()`, el navegador se reconecta tras el tiempo de `retry` y el stream vuelve a empezar. + +Node.js no tiene un `EventSource` global salvo que lo arranques con `--experimental-eventsource`. En Node, usa ese flag o el paquete [`eventsource`](https://www.npmjs.com/package/eventsource); el código del cliente es el mismo que en el navegador. + +## Suscripciones: un evento para muchos clientes + +Una suscripción es una ruta a la que se conectan los clientes y en la que publica tu código. El path puede tener parámetros, así que cada usuario o sala tiene su propio stream: + +```ts +import Joi from 'joi'; + +server.sse.subscription('/rooms/{room}/messages'); + +server.route<{ Params: { room: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{room}/messages', + options: { + validate: { + params: Joi.object({ room: Joi.string().alphanum().max(50).required() }), + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: async (request, h) => { + const message = { text: request.payload.text, at: new Date().toISOString() }; + const delivered = await server.sse.publish(`/rooms/${request.params.room}/messages`, message, { + event: 'message', + matchMode: 'literal', + }); + + return { delivered }; + }, +}); +``` + +- `matchMode: 'literal'` envía el evento solo a los clientes de ese path exacto, así que un mensaje enviado a `/rooms/lobby/messages` llega al lobby y a ninguna otra sala. Consulta [`server.sse.publish()`](/module/sse#server-sse-publish-path-data-opts). +- La ruta publica solo los campos que ha [validado](/tutorials/es_ES/validation), nunca el payload tal cual. Dale la misma [autenticación de ruta](/tutorials/es_ES/auth) que a cualquier ruta que escribe datos. +- `publish()` devuelve el número de clientes a los que se entregó el evento. + +Las suscripciones también aceptan `filter`, `onSubscribe`, `refuse` y `maxSessions`; consulta [`server.sse.subscription()`](/module/sse#server-sse-subscription-path-config). + +## Reconexiones y `Last-Event-ID` + +Cuando se cae una conexión, el navegador espera el tiempo de `retry` que le envió el servidor y después se reconecta con el id del último evento que recibió en una cabecera `Last-Event-ID`: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as servidor hapi + B->>S: GET /feed + S-->>B: retry: 2000, después los eventos a1, a2 + Note over B,S: se cae la conexión, y mientras tanto se publica a3 + B->>S: GET /feed con Last-Event-ID: a2 + Note over S: el replayer encuentra a2
y reenvía lo que vino después + S-->>B: evento a3, después los eventos en directo +``` + +El navegador envía la cabecera, pero solo tu servidor puede actuar en consecuencia, y los dos modos funcionan distinto: + +- **Suscripciones:** dale a la suscripción un replayer, que guarda los eventos recientes en memoria y reenvía todo lo posterior al último id del cliente. Un replayer guarda un único historial para toda la suscripción, compartido por todos los paths y todos los clientes, así que úsalo en suscripciones sin parámetros en el path, como un `/feed` público. Para streams por usuario o por sala, continúa a partir de tu base de datos, como en el modo handler. + + ```ts + import { FiniteReplayer } from '@hapi/sse'; + + server.sse.subscription('/feed', { + replay: new FiniteReplayer({ size: 100 }), + }); + + await server.sse.publish('/feed', item, { event: 'news', id: item.id }); + ``` + +- **Modo handler:** lee `session.lastEventId` en `stream` y continúa desde ahí, por ejemplo consultando en tu base de datos las filas posteriores a ese id. + +Si el último id del cliente ya no está en el buffer, por ejemplo después de reiniciar el servidor, el replayer reenvía todo lo que tiene, así que dale a tus eventos ids que el cliente pueda usar para descartar duplicados. [Event replay](/module/sse#event-replay) describe lo que guarda cada replayer. Los buffers de replay viven en la memoria de un único proceso; consulta [Escalar a varias instancias](#scaling). + +## Limpiar cuando termina un stream + +Un stream sigue abierto hasta que uno de los dos lados lo cierra, así que todo lo que arranques para una conexión debe detenerse cuando termina el stream, tanto si el cliente se fue como si el stream se completó o el servidor se está apagando: + +```ts +import type { Session } from '@hapi/sse'; + +const openStreams = new Map void>(); + +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + const watcher = watchJob(String(request.params.id)); + openStreams.set(session, () => watcher.stop()); + request.events.once('disconnect', () => watcher.stop()); + + try { + for await (const update of watcher) { + session.push(update, 'progress', String(update.percent)); + } + + if (session.isOpen) { + await session.complete(); + } + } finally { + watcher.stop(); + openStreams.delete(session); + } + }, + }, + }, +}); + +server.ext('onPreStop', () => { + for (const [session, stop] of openStreams) { + stop(); + session.close(); + } +}); +``` + +- **El cliente se va:** `request.events` emite `'disconnect'`, que detiene el watcher y termina el bucle. +- **El stream termina en el servidor:** se ejecuta el bloque `finally`, así que el `Map` nunca guarda un stream terminado. +- **El servidor se apaga:** el [hook del servidor](/tutorials/es_ES/lifecycle-hooks#server-hooks) `onPreStop` detiene todos los watchers y cierra todos los streams, así que `server.stop()` termina sin esperar a que se agote su `timeout`. +- **Suscripciones:** usa el `onUnsubscribe` de la suscripción, o el hook `onSessionClose` del plugin para todos los streams de suscripción. El plugin quita al cliente de la suscripción por sí solo. + +## Proxies, buffering y timeouts de inactividad + +La mayoría de los problemas de SSE en producción vienen de algo entre el servidor y el navegador que retiene los eventos o cierra una conexión sin tráfico. El plugin resuelve una parte: + +| Problema | Qué hace el plugin | Qué haces tú | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Un proxy hace buffering de la respuesta y entrega los eventos a ráfagas | envía `X-Accel-Buffering: no` y `Cache-Control: no-cache` | desactivar el buffering de respuestas en los proxies que no respetan esas cabeceras | +| La compresión retiene los eventos hasta acumular suficientes bytes | vacía gzip y deflate después de cada escritura | nada; los eventos llegan uno a uno aunque vayan comprimidos | +| Un balanceador de carga o un proxy cierra las conexiones inactivas demasiado tiempo | envía una línea de comentario `:` cada 15 segundos | mantener `keepAlive.interval` por debajo del timeout de inactividad más corto del camino | +| Los navegadores solo permiten 6 conexiones HTTP/1.1 por dominio | nada | servir el sitio por HTTP/2, normalmente en el proxy, para que los streams compartan una conexión | + +## Autenticación + +Un stream SSE es una ruta normal de hapi, así que se aplica la [autenticación de ruta](/tutorials/es_ES/auth) de siempre, scopes incluidos. En las suscripciones, pásala en la opción `auth`: + +```ts +server.sse.subscription('/users/{userId}/notifications', { + auth: { strategy: 'session', access: { scope: 'user-{params.userId}' } }, +}); + +await server.sse.publish(`/users/${userId}/notifications`, notification, { + event: 'notification', + matchMode: 'literal', +}); +``` + +Con el scope `user-{params.userId}`, un usuario solo puede abrir su propio stream de notificaciones; cualquier otro recibe un `403`. Consulta [Scopes](/tutorials/es_ES/auth#scopes). Publica con `matchMode: 'literal'` para que cada notificación llegue solo al stream de su usuario. + +`EventSource` no puede poner cabeceras en la petición, así que un bearer token en `Authorization` no es una opción. Las alternativas: + +| Enfoque | Ventaja | Cuidado con | +| -------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Sesión con cookie, como `@hapi/cookie` | el navegador la envía, también en las reconexiones | los streams de otro origen necesitan `new EventSource(url, { withCredentials: true })` y CORS con credenciales | +| Token en la query string | funciona con cualquier cliente | las URL acaban en los logs de acceso del servidor y del proxy; usa un token de corta duración creado solo para este stream | +| `fetch()` con un body en streaming | puede enviar cualquier cabecera | pierdes la reconexión y la gestión de `Last-Event-ID` de `EventSource` y tienes que escribirlas tú | + +Para una app de navegador en tu propio dominio, una cookie es la opción más sencilla. + +## Escalar a varias instancias + +Dos instancias del servidor no comparten memoria. Un stream SSE es una conexión abierta que mantiene un proceso: un cliente conectado a la instancia A solo existe en A, y la instancia B no tiene forma de escribir en él. Un evento publicado en A solo llega a los clientes de A. Esto vale para cualquier librería de SSE y cualquier proveedor de hosting: cada proceso solo puede escribir en las conexiones que tiene. + +Los clientes conectados, los buffers de replay y, por defecto, el registro de streams completados viven en esa memoria de cada proceso. Escalar a varias instancias requiere pasar mensajes entre ellas. Un backend de pub/sub como Redis, NATS o `LISTEN`/`NOTIFY` de Postgres reparte cada evento a todas las instancias, y cada instancia lo escribe en sus propios streams abiertos: + +```mermaid +flowchart TD + P["se crea un evento
en la instancia A"] -->|publica| R[("broker
Redis, NATS, Postgres")] + R -->|reparte| A[instancia A] + R -->|reparte| B[instancia B] + A --> CA[clientes en A] + B --> CB[clientes en B] +``` + +Con Redis: + +```ts +import { createClient } from 'redis'; + +interface RoomMessage { + room: string; + text: string; + at: string; +} + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('rooms', async (raw) => { + const message: RoomMessage = JSON.parse(raw); + await server.sse.publish(`/rooms/${message.room}/messages`, message, { event: 'message', matchMode: 'literal' }); +}); + +const publishToRoom = async (message: RoomMessage) => { + await publisher.publish('rooms', JSON.stringify(message)); +}; +``` + +La ruta `POST` llama a `publishToRoom()` en lugar de a `server.sse.publish()`, y todas las instancias, incluida la que atendió la petición, publican en sus propios streams. + +- Con el broker en marcha, todas las instancias publican todos los eventos, así que el replayer de cada instancia guarda el mismo historial y un cliente que se reconecta puede continuar en cualquier instancia. Solo faltan los eventos anteriores al arranque de una instancia, y eso ya lo cubren los ids que permiten descartar duplicados de [Reconexiones](#reconnect). +- Apunta la opción `completion.cache` del plugin a una caché compartida, como catbox-redis, para que todas las instancias reconozcan un stream de suscripción completado. +- `maxDuration` cierra cada stream pasado un tiempo fijo (con jitter), y el cliente se reconecta. Así los streams de larga duración se vuelven a repartir cuando añades instancias, en lugar de quedarse en las antiguas. + +## Siguientes pasos + +- [Referencia de la API de @hapi/sse](/module/sse): todas las opciones, `Session`, los replayers, la [backpressure](/module/sse#backpressure) para clientes lentos y las [defensas de seguridad integradas](/module/sse#security). +- [WebSockets](/tutorials/es_ES/websockets) cuando el cliente también necesita enviar mensajes. +- [Hooks del ciclo de vida](/tutorials/es_ES/lifecycle-hooks) para `onPreStop` y los demás hooks del servidor. diff --git a/docs/tutorials/ko_KR/sse.md b/docs/tutorials/ko_KR/sse.md new file mode 100644 index 00000000..bb56544b --- /dev/null +++ b/docs/tutorials/ko_KR/sse.md @@ -0,0 +1,307 @@ +--- +title: Server-Sent Events +--- + + + +# Server-Sent Events + +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ + +## 개요 + +Server-Sent Events(SSE)는 서버가 평범한 HTTP 응답 하나로 클라이언트에 이벤트 스트림을 보내는 방식입니다. 브라우저는 내장 `EventSource` API로 스트림을 열고, 연결이 끊기면 알아서 다시 연결합니다. [@hapi/sse](/module/sse)는 hapi에 SSE를 추가합니다. + +## SSE를 언제 쓸까 + +| | SSE | [WebSocket](/tutorials/ko_KR/websockets) | 폴링 | +| ----------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- | ----------------------------- | +| 방향 | 서버에서 클라이언트로만 | 양방향 | 클라이언트가 묻고 서버가 답함 | +| 전송 방식 | 일반 HTTP 응답 | 프로토콜 업그레이드 | 일반 HTTP 요청 | +| 재연결 | `EventSource`에 내장, 마지막 이벤트 id부터 이어서 받음 | nes 클라이언트가 다시 연결하고 다시 구독함. 연결이 끊긴 동안 보낸 메시지는 사라짐 | 필요 없음 | +| 프록시 통과 | 가능, 버퍼링을 꺼야 함 | 프록시가 업그레이드를 지원해야 함 | 가능 | +| 지연 시간 | 즉시 | 즉시 | 최대 폴링 간격만큼 | + +서버에 새 소식이 있고 클라이언트는 듣기만 할 때 SSE를 고르세요. 작업 진행률, 알림, 실시간 대시보드, 로그 tail, AI 모델이 토큰 단위로 내놓는 출력이 그런 예입니다. 클라이언트도 메시지를 꾸준히 보내야 한다면 WebSocket을 사용하세요. 가끔만 보내는 클라이언트라면 스트림은 SSE로 받고 나머지는 평범한 `POST` 요청으로 보내면 됩니다. + +## 설정 + +```sh +npm install @hapi/sse +``` + +```ts +import Hapi from '@hapi/hapi'; +import { SsePlugin } from '@hapi/sse'; + +const server = Hapi.server({ port: 3000 }); + +await server.register(SsePlugin); +``` + +플러그인은 스트림을 여는 방법을 두 가지 제공합니다. + +| 용도 | 핸들러 모드: `handler: { sse: { stream } }` | 구독: `server.sse.subscription()` | +| --------------------- | ------------------------------------------- | ------------------------------------------------- | +| 이벤트를 만드는 쪽 | 그 연결 하나를 위한 여러분의 `stream` 함수 | `server.sse.publish()`를 호출하는 모든 코드 | +| 대표적인 스트림 | 작업 하나의 진행률, AI 응답, 로그 tail | 알림, 실시간 피드, 채팅방 | +| 연결별 이벤트 | 클라이언트마다 다름 | 같은 이벤트가 모든 구독자에게 감 | +| 재연결 후 이어서 받기 | `session.lastEventId`를 읽고 이어서 보냄 | 리플레이어가 클라이언트가 놓친 이벤트를 다시 보냄 | + +## 핸들러 모드: 요청마다 스트림 하나 + +`sse` 핸들러를 쓰는 라우트는 연결마다 `stream` 함수를 한 번 실행합니다. `push()`한 내용은 그때그때 클라이언트에 도착합니다. + +```ts +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + for await (const update of watchJob(String(request.params.id))) { + session.push(update, 'progress', String(update.percent)); + } + + await session.complete(); + }, + }, + }, +}); +``` + +`push(data, event, id)` 한 번이 전송되는 이벤트 하나가 됩니다. 객체는 JSON으로 보냅니다. + +```text +event: progress +id: 50 +data: {"percent":50} +``` + +`complete()`는 마지막으로 `complete` 이벤트를 보내고 응답을 끝냅니다. 응답을 끝내는 것만으로는 브라우저를 멈출 수 없습니다. `EventSource`는 닫힌 연결을 모두 끊긴 연결로 보고 다시 연결하기 때문입니다. 클라이언트가 `complete`를 받으면 직접 연결을 닫아야 합니다. + +```ts +const source = new EventSource('/jobs/7/progress'); + +source.addEventListener('progress', (event) => { + const { percent } = JSON.parse(event.data); + console.log(`${percent}%`); +}); + +source.addEventListener('complete', () => source.close()); +``` + +`close()`가 없으면 브라우저는 `retry` 지연 시간이 지난 뒤 다시 연결하고, 스트림은 처음부터 다시 시작됩니다. + +Node.js는 `--experimental-eventsource`로 시작하지 않으면 전역 `EventSource`가 없습니다. Node에서는 이 플래그나 [`eventsource`](https://www.npmjs.com/package/eventsource) 패키지를 사용하세요. 클라이언트 코드는 브라우저와 같습니다. + +## 구독: 이벤트 하나를 여러 클라이언트에 + +구독은 클라이언트가 연결하고 여러분의 코드가 발행하는 라우트입니다. 경로에 파라미터를 둘 수 있으므로 사용자나 채팅방마다 스트림을 따로 가질 수 있습니다. + +```ts +import Joi from 'joi'; + +server.sse.subscription('/rooms/{room}/messages'); + +server.route<{ Params: { room: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{room}/messages', + options: { + validate: { + params: Joi.object({ room: Joi.string().alphanum().max(50).required() }), + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: async (request, h) => { + const message = { text: request.payload.text, at: new Date().toISOString() }; + const delivered = await server.sse.publish(`/rooms/${request.params.room}/messages`, message, { + event: 'message', + matchMode: 'literal', + }); + + return { delivered }; + }, +}); +``` + +- `matchMode: 'literal'`은 정확히 그 경로의 클라이언트에게만 이벤트를 보냅니다. 따라서 `/rooms/lobby/messages`에 올린 메시지는 lobby에만 가고 다른 방에는 가지 않습니다. [`server.sse.publish()`](/module/sse#server-sse-publish-path-data-opts)를 참고하세요. +- 이 라우트는 원본 페이로드가 아니라 [검증](/tutorials/ko_KR/validation)한 필드만 발행합니다. 데이터를 쓰는 다른 라우트와 같은 [라우트 인증](/tutorials/ko_KR/auth)을 설정하세요. +- `publish()`는 이벤트를 받은 클라이언트 수를 반환합니다. + +구독은 `filter`, `onSubscribe`, `refuse`, `maxSessions`도 받습니다. [`server.sse.subscription()`](/module/sse#server-sse-subscription-path-config)을 참고하세요. + +## 재연결과 `Last-Event-ID` + +연결이 끊기면 브라우저는 서버가 보낸 `retry` 시간만큼 기다린 뒤, 마지막으로 받은 이벤트의 id를 `Last-Event-ID` 헤더에 담아 다시 연결합니다. + +```mermaid +sequenceDiagram + participant B as 브라우저 + participant S as hapi 서버 + B->>S: GET /feed + S-->>B: retry: 2000, 이어서 이벤트 a1, a2 + Note over B,S: 연결이 끊기고, 그사이 a3가 발행됨 + B->>S: Last-Event-ID: a2를 담아 GET /feed + Note over S: 리플레이어가 a2를 찾아
그 뒤의 이벤트를 다시 보냄 + S-->>B: 이벤트 a3, 이어서 실시간 이벤트 +``` + +헤더는 브라우저가 보내지만, 그 헤더를 처리할 수 있는 것은 서버뿐이며 두 모드는 방식이 다릅니다. + +- **구독:** 구독에 리플레이어를 지정하세요. 리플레이어는 최근 이벤트를 메모리에 보관하고, 클라이언트의 마지막 id 이후의 이벤트를 모두 다시 보냅니다. 리플레이어는 구독 전체에 이력 하나만 두고 모든 경로와 모든 클라이언트가 이를 공유하므로, 공개 `/feed`처럼 경로 파라미터가 없는 구독에 사용하세요. 사용자별, 채팅방별 스트림은 핸들러 모드처럼 데이터베이스에서 이어서 보내세요. + + ```ts + import { FiniteReplayer } from '@hapi/sse'; + + server.sse.subscription('/feed', { + replay: new FiniteReplayer({ size: 100 }), + }); + + await server.sse.publish('/feed', item, { event: 'news', id: item.id }); + ``` + +- **핸들러 모드:** `stream`에서 `session.lastEventId`를 읽고 거기서부터 이어서 보냅니다. 예를 들어 데이터베이스에서 그 id보다 새로운 행을 조회합니다. + +서버가 재시작된 뒤처럼 클라이언트의 마지막 id가 버퍼에 더 이상 없으면, 리플레이어는 가지고 있는 이벤트를 모두 다시 보냅니다. 그러니 클라이언트가 중복을 걸러낼 수 있는 id를 이벤트에 붙이세요. 리플레이어마다 무엇을 보관하는지는 [이벤트 재전송](/module/sse#event-replay)에 나와 있습니다. 재전송 버퍼는 프로세스 하나의 메모리에 있습니다. [여러 인스턴스로 확장하기](#scaling)를 참고하세요. + +## 스트림이 끝날 때 정리하기 + +스트림은 한쪽이 닫을 때까지 열려 있습니다. 그러므로 연결을 위해 시작한 작업은 스트림이 끝날 때 반드시 멈춰야 합니다. 클라이언트가 떠났든, 스트림이 완료되었든, 서버가 종료되고 있든 마찬가지입니다. + +```ts +import type { Session } from '@hapi/sse'; + +const openStreams = new Map void>(); + +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + const watcher = watchJob(String(request.params.id)); + openStreams.set(session, () => watcher.stop()); + request.events.once('disconnect', () => watcher.stop()); + + try { + for await (const update of watcher) { + session.push(update, 'progress', String(update.percent)); + } + + if (session.isOpen) { + await session.complete(); + } + } finally { + watcher.stop(); + openStreams.delete(session); + } + }, + }, + }, +}); + +server.ext('onPreStop', () => { + for (const [session, stop] of openStreams) { + stop(); + session.close(); + } +}); +``` + +- **클라이언트가 떠날 때:** `request.events`가 `'disconnect'`를 발생시키고, 이것이 watcher를 멈춰 루프가 끝납니다. +- **서버에서 스트림이 끝날 때:** `finally` 블록이 실행되므로 `Map`에 끝난 스트림이 남지 않습니다. +- **서버가 종료될 때:** `onPreStop` [서버 훅](/tutorials/ko_KR/lifecycle-hooks#server-hooks)이 모든 watcher를 멈추고 모든 스트림을 닫습니다. 그래서 `server.stop()`이 `timeout`을 다 기다리지 않고 끝납니다. +- **구독:** 구독의 `onUnsubscribe`를 사용하거나, 모든 구독 스트림에 적용되는 플러그인의 `onSessionClose` 훅을 사용하세요. 클라이언트를 구독에서 빼는 일은 플러그인이 알아서 합니다. + +## 프록시, 버퍼링, 유휴 타임아웃 + +운영 환경에서 생기는 SSE 문제는 대부분 서버와 브라우저 사이의 무언가가 이벤트를 붙잡아 두거나 조용한 연결을 닫아서 생깁니다. 플러그인이 일부를 처리합니다. + +| 문제 | 플러그인이 하는 일 | 여러분이 할 일 | +| -------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------- | +| 프록시가 응답을 버퍼링해 이벤트가 몰아서 도착함 | `X-Accel-Buffering: no`와 `Cache-Control: no-cache`를 보냄 | 이 헤더를 따르지 않는 프록시에서 응답 버퍼링을 끔 | +| 압축이 충분한 바이트가 쌓일 때까지 이벤트를 붙잡아 둠 | 쓸 때마다 gzip과 deflate를 flush함 | 없음. 압축해도 이벤트가 하나씩 도착함 | +| 로드 밸런서나 프록시가 너무 오래 유휴 상태인 연결을 닫음 | 15초마다 `:` 주석 줄을 보냄 | `keepAlive.interval`을 경로상 가장 짧은 유휴 타임아웃보다 작게 유지 | +| 브라우저는 도메인당 HTTP/1.1 연결을 6개까지만 허용함 | 없음 | 보통 프록시에서 HTTP/2로 사이트를 제공해 스트림이 연결 하나를 공유하게 함 | + +## 인증 + +SSE 스트림은 평범한 hapi 라우트이므로, 스코프를 포함해 일반적인 [라우트 인증](/tutorials/ko_KR/auth)이 그대로 적용됩니다. 구독에서는 `auth` 옵션으로 전달합니다. + +```ts +server.sse.subscription('/users/{userId}/notifications', { + auth: { strategy: 'session', access: { scope: 'user-{params.userId}' } }, +}); + +await server.sse.publish(`/users/${userId}/notifications`, notification, { + event: 'notification', + matchMode: 'literal', +}); +``` + +`user-{params.userId}` 스코프가 있으면 사용자는 자기 알림 스트림만 열 수 있고, 다른 사람은 `403`을 받습니다. [스코프](/tutorials/ko_KR/auth#scopes)를 참고하세요. 알림이 해당 사용자의 스트림에만 가도록 `matchMode: 'literal'`로 발행하세요. + +`EventSource`는 요청 헤더를 설정할 수 없으므로 `Authorization`에 bearer 토큰을 넣는 방법은 쓸 수 없습니다. 선택지는 다음과 같습니다. + +| 방법 | 장점 | 주의할 점 | +| ------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `@hapi/cookie` 같은 쿠키 세션 | 재연결할 때도 브라우저가 알아서 보냄 | 다른 출처의 스트림에는 `new EventSource(url, { withCredentials: true })`와 자격 증명을 허용하는 CORS가 필요 | +| 쿼리 문자열에 담은 토큰 | 어떤 클라이언트에서든 동작 | URL이 서버와 프록시의 접근 로그에 남음. 이 스트림 전용의 수명이 짧은 토큰을 사용 | +| 스트리밍 본문을 쓰는 `fetch()` | 어떤 헤더든 보낼 수 있음 | `EventSource`의 재연결과 `Last-Event-ID` 처리가 없어지므로 직접 작성해야 함 | + +자기 도메인에서 동작하는 브라우저 앱이라면 쿠키가 가장 간단합니다. + +## 여러 인스턴스로 확장하기 + +두 서버 인스턴스는 메모리를 공유하지 않습니다. SSE 스트림은 프로세스 하나가 붙잡고 있는 열린 연결입니다. 인스턴스 A에 연결된 클라이언트는 A에만 존재하고, 인스턴스 B는 그 연결에 쓸 방법이 없습니다. A에서 발행한 이벤트는 A의 클라이언트에게만 갑니다. 이는 모든 SSE 라이브러리와 호스팅 업체에 해당합니다. 각 프로세스는 자기가 가진 연결에만 쓸 수 있습니다. + +연결된 클라이언트, 재전송 버퍼, 그리고 기본 설정에서는 완료된 스트림 기록까지 모두 이 프로세스별 메모리에 있습니다. 여러 인스턴스로 확장하려면 인스턴스 사이에 메시지를 전달해야 합니다. Redis, NATS, Postgres `LISTEN`/`NOTIFY` 같은 pub/sub 백엔드가 각 이벤트를 모든 인스턴스에 퍼뜨리고, 각 인스턴스는 이를 자기의 열린 스트림에 씁니다. + +```mermaid +flowchart TD + P["인스턴스 A에서
이벤트가 생성됨"] -->|발행| R[("브로커
Redis, NATS, Postgres")] + R -->|팬아웃| A[인스턴스 A] + R -->|팬아웃| B[인스턴스 B] + A --> CA[A의 클라이언트] + B --> CB[B의 클라이언트] +``` + +Redis를 사용하면 다음과 같습니다. + +```ts +import { createClient } from 'redis'; + +interface RoomMessage { + room: string; + text: string; + at: string; +} + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('rooms', async (raw) => { + const message: RoomMessage = JSON.parse(raw); + await server.sse.publish(`/rooms/${message.room}/messages`, message, { event: 'message', matchMode: 'literal' }); +}); + +const publishToRoom = async (message: RoomMessage) => { + await publisher.publish('rooms', JSON.stringify(message)); +}; +``` + +`POST` 라우트는 `server.sse.publish()` 대신 `publishToRoom()`을 호출하고, 요청을 처리한 인스턴스를 포함해 모든 인스턴스가 각자의 스트림에 발행합니다. + +- 브로커가 있으면 모든 인스턴스가 모든 이벤트를 발행하므로, 모든 인스턴스의 리플레이어가 같은 이력을 갖고 다시 연결한 클라이언트는 어느 인스턴스에서든 이어서 받을 수 있습니다. 인스턴스가 시작되기 전의 이벤트만 빠지는데, 이는 [재연결](#reconnect)에서 설명한 중복에 안전한 id로 이미 해결됩니다. +- 플러그인의 `completion.cache` 옵션을 catbox-redis 같은 공유 캐시로 지정하면, 완료된 구독 스트림을 모든 인스턴스가 알아봅니다. +- `maxDuration`은 정해진 시간(지터 포함)이 지나면 스트림을 닫고, 클라이언트는 다시 연결합니다. 그러면 인스턴스를 추가한 뒤 오래 유지되던 스트림이 기존 인스턴스에 머물지 않고 다시 고르게 퍼집니다. + +## 다음 단계 + +- [@hapi/sse API 레퍼런스](/module/sse): 모든 옵션, `Session`, 리플레이어, 느린 클라이언트를 위한 [백프레셔](/module/sse#backpressure), [내장 보안 방어](/module/sse#security). +- 클라이언트도 메시지를 보내야 할 때는 [WebSocket](/tutorials/ko_KR/websockets). +- `onPreStop`과 그 밖의 서버 훅은 [라이프사이클 훅](/tutorials/ko_KR/lifecycle-hooks). diff --git a/docs/tutorials/pt_BR/sse.md b/docs/tutorials/pt_BR/sse.md new file mode 100644 index 00000000..b50bb3bb --- /dev/null +++ b/docs/tutorials/pt_BR/sse.md @@ -0,0 +1,307 @@ +--- +title: Server-Sent Events +--- + + + +# Server-Sent Events + +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ + +## Visão geral + +Server-Sent Events (SSE) é uma forma de o servidor enviar um stream de eventos ao cliente por uma única resposta HTTP comum. O navegador abre o stream com a API nativa `EventSource`, que também reconecta sozinha quando a conexão cai. O [@hapi/sse](/module/sse) adiciona SSE ao hapi. + +## Quando usar SSE + +| | SSE | [WebSockets](/tutorials/pt_BR/websockets) | Polling | +| --------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------- | +| Direção | só do servidor para o cliente | nos dois sentidos | o cliente pergunta, o servidor responde | +| Transporte | uma resposta HTTP normal | um upgrade de protocolo | requisições HTTP normais | +| Reconexão | embutida no `EventSource`, retoma a partir do último id | o cliente do nes reconecta e refaz as assinaturas, mas as mensagens enviadas offline se perdem | não é necessária | +| Funciona através de proxies | sim, com o buffering desligado | o proxy precisa suportar upgrades | sim | +| Latência | imediata | imediata | até o intervalo de polling | + +Escolha SSE quando o servidor tem novidades e o cliente só escuta: progresso de jobs, notificações, dashboards ao vivo, acompanhamento de logs e a saída token a token de um modelo de IA. Quando o cliente também precisa enviar um fluxo constante de mensagens, use WebSockets. Um cliente que só envia de vez em quando pode usar SSE para o stream e requisições `POST` comuns para o resto. + +## Configuração + +```sh +npm install @hapi/sse +``` + +```ts +import Hapi from '@hapi/hapi'; +import { SsePlugin } from '@hapi/sse'; + +const server = Hapi.server({ port: 3000 }); + +await server.register(SsePlugin); +``` + +O plugin oferece duas formas de abrir um stream: + +| Uso | Modo handler: `handler: { sse: { stream } }` | Assinaturas: `server.sse.subscription()` | +| ----------------------------- | ------------------------------------------------------- | --------------------------------------------------- | +| Quem produz os eventos | sua função `stream`, para aquela conexão | qualquer código, por meio de `server.sse.publish()` | +| Stream típico | progresso de um job, uma resposta de IA, um log ao vivo | notificações, um feed ao vivo, uma sala de chat | +| Eventos por conexão | diferentes para cada cliente | o mesmo evento vai para todos os assinantes | +| Retomada depois de reconectar | você lê `session.lastEventId` e continua | um replayer reenvia o que o cliente perdeu | + +## Modo handler: um stream por requisição + +Uma rota com o handler `sse` executa sua função `stream` uma vez por conexão. Tudo o que você envia com `push()` chega ao cliente na hora: + +```ts +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + for await (const update of watchJob(String(request.params.id))) { + session.push(update, 'progress', String(update.percent)); + } + + await session.complete(); + }, + }, + }, +}); +``` + +Cada `push(data, event, id)` vira um evento na conexão. Objetos são enviados como JSON: + +```text +event: progress +id: 50 +data: {"percent":50} +``` + +`complete()` envia um evento `complete` final e encerra a resposta. Encerrar a resposta não basta para parar o navegador, porque o `EventSource` trata toda conexão fechada como uma conexão que caiu e reconecta. O próprio cliente precisa fechar a conexão quando recebe `complete`: + +```ts +const source = new EventSource('/jobs/7/progress'); + +source.addEventListener('progress', (event) => { + const { percent } = JSON.parse(event.data); + console.log(`${percent}%`); +}); + +source.addEventListener('complete', () => source.close()); +``` + +Sem o `close()`, o navegador reconecta depois do intervalo `retry` e o stream começa de novo. + +O Node.js não tem um `EventSource` global, a não ser que você o inicie com `--experimental-eventsource`. No Node, use essa flag ou o pacote [`eventsource`](https://www.npmjs.com/package/eventsource); o código do cliente é o mesmo do navegador. + +## Assinaturas: um evento para muitos clientes + +Uma assinatura é uma rota à qual os clientes se conectam e na qual o seu código publica. O caminho pode ter parâmetros, então cada usuário ou sala ganha seu próprio stream: + +```ts +import Joi from 'joi'; + +server.sse.subscription('/rooms/{room}/messages'); + +server.route<{ Params: { room: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{room}/messages', + options: { + validate: { + params: Joi.object({ room: Joi.string().alphanum().max(50).required() }), + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: async (request, h) => { + const message = { text: request.payload.text, at: new Date().toISOString() }; + const delivered = await server.sse.publish(`/rooms/${request.params.room}/messages`, message, { + event: 'message', + matchMode: 'literal', + }); + + return { delivered }; + }, +}); +``` + +- `matchMode: 'literal'` envia o evento só para os clientes daquele caminho exato, então uma mensagem postada em `/rooms/lobby/messages` chega ao lobby e a nenhuma outra sala. Veja [`server.sse.publish()`](/module/sse#server-sse-publish-path-data-opts). +- A rota publica só os campos que ela [validou](/tutorials/pt_BR/validation), nunca o payload bruto. Dê a ela a mesma [autenticação de rota](/tutorials/pt_BR/auth) de qualquer rota que grava dados. +- `publish()` retorna o número de clientes para os quais o evento foi entregue. + +As assinaturas também aceitam `filter`, `onSubscribe`, `refuse` e `maxSessions`; veja [`server.sse.subscription()`](/module/sse#server-sse-subscription-path-config). + +## Reconexões e `Last-Event-ID` + +Quando uma conexão cai, o navegador espera o tempo de `retry` que o servidor enviou e então reconecta, mandando o id do último evento que recebeu em um header `Last-Event-ID`: + +```mermaid +sequenceDiagram + participant B as Navegador + participant S as servidor hapi + B->>S: GET /feed + S-->>B: retry: 2000, depois os eventos a1, a2 + Note over B,S: a conexão cai, e a3 é publicado nesse meio-tempo + B->>S: GET /feed com Last-Event-ID: a2 + Note over S: o replayer encontra a2
e reenvia o que veio depois + S-->>B: evento a3, depois os eventos ao vivo +``` + +O navegador envia o header, mas só o seu servidor pode agir sobre ele, e os dois modos funcionam de forma diferente: + +- **Assinaturas:** dê à assinatura um replayer, que guarda os eventos recentes em memória e reenvia tudo o que veio depois do último id do cliente. Um replayer mantém um único histórico para a assinatura inteira, compartilhado por todos os caminhos e todos os clientes, então use-o em assinaturas sem parâmetros no caminho, como um `/feed` público. Para streams por usuário ou por sala, retome a partir do seu banco de dados, como no modo handler. + + ```ts + import { FiniteReplayer } from '@hapi/sse'; + + server.sse.subscription('/feed', { + replay: new FiniteReplayer({ size: 100 }), + }); + + await server.sse.publish('/feed', item, { event: 'news', id: item.id }); + ``` + +- **Modo handler:** leia `session.lastEventId` em `stream` e continue a partir dali, por exemplo consultando no banco as linhas mais novas que aquele id. + +Se o último id do cliente não está mais no buffer, por exemplo depois de o servidor reiniciar, o replayer reenvia tudo o que tem guardado, então dê aos seus eventos ids que o cliente possa usar para descartar duplicados. [Event replay](/module/sse#event-replay) descreve o que cada replayer guarda. Os buffers de replay ficam na memória de um único processo; veja [Escalando entre instâncias](#scaling). + +## Limpeza quando um stream termina + +Um stream fica aberto até que um dos lados o feche, então tudo o que você inicia para uma conexão precisa parar quando o stream termina, seja porque o cliente saiu, porque o stream foi concluído ou porque o servidor está desligando: + +```ts +import type { Session } from '@hapi/sse'; + +const openStreams = new Map void>(); + +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + const watcher = watchJob(String(request.params.id)); + openStreams.set(session, () => watcher.stop()); + request.events.once('disconnect', () => watcher.stop()); + + try { + for await (const update of watcher) { + session.push(update, 'progress', String(update.percent)); + } + + if (session.isOpen) { + await session.complete(); + } + } finally { + watcher.stop(); + openStreams.delete(session); + } + }, + }, + }, +}); + +server.ext('onPreStop', () => { + for (const [session, stop] of openStreams) { + stop(); + session.close(); + } +}); +``` + +- **O cliente sai:** `request.events` emite `'disconnect'`, o que para o watcher e encerra o loop. +- **O stream termina no servidor:** o bloco `finally` roda, então o `Map` nunca guarda um stream encerrado. +- **O servidor desliga:** o [server hook](/tutorials/pt_BR/lifecycle-hooks#server-hooks) `onPreStop` para todos os watchers e fecha todos os streams, então `server.stop()` termina sem esperar esgotar o seu `timeout`. +- **Assinaturas:** use o `onUnsubscribe` da assinatura, ou o hook `onSessionClose` do plugin para todos os streams de assinatura. O plugin remove o cliente da assinatura sozinho. + +## Proxies, buffering e timeouts de inatividade + +A maioria dos problemas de SSE em produção vem de algo entre o servidor e o navegador que segura os eventos ou fecha uma conexão parada. O plugin resolve parte disso: + +| Problema | O que o plugin faz | O que você faz | +| ------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Um proxy faz buffering da resposta e entrega os eventos em rajadas | envia `X-Accel-Buffering: no` e `Cache-Control: no-cache` | desligue o buffering de resposta nos proxies que não respeitam esses headers | +| A compressão segura os eventos até acumular bytes suficientes | faz flush do gzip e do deflate depois de cada escrita | nada; os eventos chegam um a um mesmo com compressão | +| Um balanceador de carga ou proxy fecha conexões inativas por tempo demais | envia uma linha de comentário `:` a cada 15 segundos | mantenha `keepAlive.interval` abaixo do menor timeout de inatividade no caminho | +| Os navegadores permitem só 6 conexões HTTP/1.1 por domínio | nada | sirva o site por HTTP/2, normalmente no proxy, para que os streams compartilhem uma única conexão | + +## Autenticação + +Um stream SSE é uma rota normal do hapi, então a [autenticação de rota](/tutorials/pt_BR/auth) de sempre se aplica, scopes incluídos. Para assinaturas, passe-a na opção `auth`: + +```ts +server.sse.subscription('/users/{userId}/notifications', { + auth: { strategy: 'session', access: { scope: 'user-{params.userId}' } }, +}); + +await server.sse.publish(`/users/${userId}/notifications`, notification, { + event: 'notification', + matchMode: 'literal', +}); +``` + +Com o scope `user-{params.userId}`, um usuário só consegue abrir o próprio stream de notificações; qualquer outra pessoa recebe um `403`. Veja [Scopes](/tutorials/pt_BR/auth#scopes). Publique com `matchMode: 'literal'` para que cada notificação chegue só ao stream do seu usuário. + +O `EventSource` não consegue definir headers de requisição, então um bearer token em `Authorization` não é uma opção. As alternativas: + +| Abordagem | Vantagem | Cuidado com | +| -------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Sessão por cookie, como `@hapi/cookie` | o navegador a envia, inclusive nas reconexões | streams cross-origin precisam de `new EventSource(url, { withCredentials: true })` e CORS com credenciais | +| Token na query string | funciona com qualquer cliente | as URLs acabam nos access logs do servidor e do proxy; use um token de vida curta feito só para esse stream | +| `fetch()` com um corpo em streaming | pode enviar qualquer header | você perde a reconexão e o tratamento de `Last-Event-ID` do `EventSource` e precisa escrevê-los por conta própria | + +Para uma aplicação de navegador no seu próprio domínio, um cookie é a opção mais simples. + +## Escalando entre instâncias + +Duas instâncias do servidor não compartilham memória. Um stream SSE é uma conexão aberta mantida por um processo: um cliente conectado à instância A existe só em A, e a instância B não tem como escrever nele. Um evento publicado em A chega só aos clientes de A. Isso vale para toda biblioteca de SSE e todo provedor de hospedagem: cada processo só consegue escrever nas conexões que ele mesmo mantém. + +Os clientes conectados, os buffers de replay e, por padrão, o registro dos streams concluídos ficam todos nessa memória de cada processo. Escalar horizontalmente exige troca de mensagens entre as instâncias. Um backend de pub/sub como Redis, NATS ou o `LISTEN`/`NOTIFY` do Postgres distribui cada evento para todas as instâncias, e cada instância o escreve nos seus próprios streams abertos: + +```mermaid +flowchart TD + P["um evento é criado
na instância A"] -->|publica| R[("broker
Redis, NATS, Postgres")] + R -->|distribui| A[instância A] + R -->|distribui| B[instância B] + A --> CA[clientes em A] + B --> CB[clientes em B] +``` + +Com Redis: + +```ts +import { createClient } from 'redis'; + +interface RoomMessage { + room: string; + text: string; + at: string; +} + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('rooms', async (raw) => { + const message: RoomMessage = JSON.parse(raw); + await server.sse.publish(`/rooms/${message.room}/messages`, message, { event: 'message', matchMode: 'literal' }); +}); + +const publishToRoom = async (message: RoomMessage) => { + await publisher.publish('rooms', JSON.stringify(message)); +}; +``` + +A rota `POST` chama `publishToRoom()` em vez de `server.sse.publish()`, e todas as instâncias, inclusive a que tratou a requisição, publicam nos seus próprios streams. + +- Com o broker no lugar, todas as instâncias publicam todos os eventos, então o replayer de cada instância guarda o mesmo histórico e um cliente que reconecta pode retomar em qualquer instância. Faltam só os eventos de antes de a instância iniciar, o que os ids seguros contra duplicados de [Reconexões](#reconnect) já cobrem. +- Aponte a opção `completion.cache` do plugin para um cache compartilhado, como o catbox-redis, para que um stream de assinatura concluído seja reconhecido em todas as instâncias. +- `maxDuration` fecha cada stream depois de um tempo definido (com jitter), e o cliente reconecta. Assim, streams de longa duração voltam a se distribuir depois que você adiciona instâncias, em vez de ficarem nas antigas. + +## Próximos passos + +- [Referência da API do @hapi/sse](/module/sse): todas as opções, `Session`, replayers, [backpressure](/module/sse#backpressure) para clientes lentos e as [defesas de segurança embutidas](/module/sse#security). +- [WebSockets](/tutorials/pt_BR/websockets) quando o cliente também precisa enviar mensagens. +- [Lifecycle hooks](/tutorials/pt_BR/lifecycle-hooks) para o `onPreStop` e outros server hooks. diff --git a/docs/tutorials/tr_TR/sse.md b/docs/tutorials/tr_TR/sse.md new file mode 100644 index 00000000..5aa8c0be --- /dev/null +++ b/docs/tutorials/tr_TR/sse.md @@ -0,0 +1,307 @@ +--- +title: Server-Sent Events +--- + + + +# Server-Sent Events + +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ + +## Genel Bakış + +Server-Sent Events (SSE), sunucunun tek bir sıradan HTTP yanıtı üzerinden istemciye bir olay akışı göndermesinin bir yoludur. Tarayıcı bu akışı yerleşik `EventSource` API'siyle açar; bu API bağlantı koptuğunda kendiliğinden yeniden bağlanır. [@hapi/sse](/module/sse), hapi'ye SSE desteği ekler. + +## SSE ne zaman kullanılır + +| | SSE | [WebSocket'ler](/tutorials/tr_TR/websockets) | Polling | +| -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------ | +| Yön | yalnızca sunucudan istemciye | iki yönlü | istemci sorar, sunucu yanıtlar | +| Taşıma | normal bir HTTP yanıtı | bir protokol yükseltmesi (upgrade) | normal HTTP istekleri | +| Yeniden bağlanma | `EventSource` içinde yerleşik, son olay id'sinden devam eder | nes istemcisi yeniden bağlanır ve aboneliklerini yeniler; çevrimdışıyken gönderilen mesajlar kaybolur | gerekmez | +| Proxy'lerin arkasında çalışır mı | evet, buffering kapatıldığında | proxy upgrade desteklemelidir | evet | +| Gecikme | anında | anında | polling aralığına kadar | + +Sunucunun söyleyecek bir şeyi olduğunda ve istemci yalnızca dinlediğinde SSE'yi seç: iş ilerlemesi, bildirimler, canlı dashboard'lar, log takibi ve bir yapay zekâ modelinin token token ürettiği çıktı. İstemcinin de sürekli mesaj göndermesi gerekiyorsa WebSocket kullan. Yalnızca ara sıra veri gönderen bir istemci, akış için SSE'yi, geri kalanı için düz `POST` isteklerini kullanabilir. + +## Kurulum + +```sh +npm install @hapi/sse +``` + +```ts +import Hapi from '@hapi/hapi'; +import { SsePlugin } from '@hapi/sse'; + +const server = Hapi.server({ port: 3000 }); + +await server.register(SsePlugin); +``` + +Plugin bir akış açmanın iki yolunu sunar: + +| Kullanım | Handler modu: `handler: { sse: { stream } }` | Abonelikler: `server.sse.subscription()` | +| -------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------- | +| Olayları kim üretir | o bağlantı için senin `stream` fonksiyonun | herhangi bir kod, `server.sse.publish()` ile | +| Tipik akış | tek bir işin ilerlemesi, bir yapay zekâ yanıtı, log takibi | bildirimler, canlı bir akış, bir sohbet odası | +| Bağlantı başına olaylar | her istemci için farklı | aynı olay her aboneye gider | +| Yeniden bağlandıktan sonra devam etmek | `session.lastEventId` değerini okur ve devam edersin | bir replayer istemcinin kaçırdıklarını yeniden gönderir | + +## Handler modu: istek başına bir akış + +`sse` handler'ını kullanan bir route, `stream` fonksiyonunu her bağlantı için bir kez çalıştırır. `push()` ile gönderdiğin her şey istemciye o anda ulaşır: + +```ts +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + for await (const update of watchJob(String(request.params.id))) { + session.push(update, 'progress', String(update.percent)); + } + + await session.complete(); + }, + }, + }, +}); +``` + +Her `push(data, event, id)` çağrısı ağ üzerinde bir olay olur. Nesneler JSON olarak gönderilir: + +```text +event: progress +id: 50 +data: {"percent":50} +``` + +`complete()` son bir `complete` olayı gönderir ve yanıtı sonlandırır. Yanıtı sonlandırmak tarayıcıyı durdurmaya yetmez, çünkü `EventSource` kapanan her bağlantıyı kopmuş sayar ve yeniden bağlanır. `complete` olayını gördüğünde bağlantıyı istemcinin kendisi kapatmalıdır: + +```ts +const source = new EventSource('/jobs/7/progress'); + +source.addEventListener('progress', (event) => { + const { percent } = JSON.parse(event.data); + console.log(`${percent}%`); +}); + +source.addEventListener('complete', () => source.close()); +``` + +`close()` olmazsa tarayıcı `retry` süresinden sonra yeniden bağlanır ve akış baştan başlar. + +Node.js, `--experimental-eventsource` ile başlatılmadıkça global bir `EventSource` sunmaz. Node'da bu bayrağı ya da [`eventsource`](https://www.npmjs.com/package/eventsource) paketini kullan; istemci kodu tarayıcıdakiyle aynıdır. + +## Abonelikler: bir olay, birçok istemci + +Abonelik, istemcilerin bağlandığı ve senin kodunun olay yayınladığı bir route'tur. Path parametre içerebilir, böylece her kullanıcı ya da oda kendi akışını alır: + +```ts +import Joi from 'joi'; + +server.sse.subscription('/rooms/{room}/messages'); + +server.route<{ Params: { room: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{room}/messages', + options: { + validate: { + params: Joi.object({ room: Joi.string().alphanum().max(50).required() }), + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: async (request, h) => { + const message = { text: request.payload.text, at: new Date().toISOString() }; + const delivered = await server.sse.publish(`/rooms/${request.params.room}/messages`, message, { + event: 'message', + matchMode: 'literal', + }); + + return { delivered }; + }, +}); +``` + +- `matchMode: 'literal'` olayı yalnızca tam olarak o path'e bağlı istemcilere gönderir; böylece `/rooms/lobby/messages` adresine gönderilen bir mesaj lobby odasına ulaşır, başka hiçbir odaya ulaşmaz. Bkz. [`server.sse.publish()`](/module/sse#server-sse-publish-path-data-opts). +- Route yalnızca [doğruladığı](/tutorials/tr_TR/validation) alanları yayınlar, ham payload'u asla yayınlamaz. Veri yazan diğer route'larda olduğu gibi ona da [route kimlik doğrulaması](/tutorials/tr_TR/auth) ekle. +- `publish()`, olayın ulaştırıldığı istemci sayısını döndürür. + +Abonelikler ayrıca `filter`, `onSubscribe`, `refuse` ve `maxSessions` seçeneklerini de alır; bkz. [`server.sse.subscription()`](/module/sse#server-sse-subscription-path-config). + +## Yeniden bağlanma ve `Last-Event-ID` + +Bağlantı koptuğunda tarayıcı, sunucunun gönderdiği `retry` süresi kadar bekler, sonra aldığı son olayın id'sini bir `Last-Event-ID` header'ında göndererek yeniden bağlanır: + +```mermaid +sequenceDiagram + participant B as Tarayıcı + participant S as hapi sunucusu + B->>S: GET /feed + S-->>B: retry: 2000, ardından a1 ve a2 olayları + Note over B,S: bağlantı kopar, bu sırada a3 yayınlanır + B->>S: Last-Event-ID: a2 ile GET /feed + Note over S: replayer a2'yi bulur
ve ondan sonra gelenleri yeniden gönderir + S-->>B: a3 olayı, ardından canlı olaylar +``` + +Header'ı tarayıcı gönderir ama ona göre davranabilecek tek taraf senin sunucundur ve iki mod bu konuda farklıdır: + +- **Abonelikler:** aboneliğe bir replayer ver. Replayer son olayları bellekte tutar ve istemcinin son id'sinden sonraki her şeyi yeniden gönderir. Bir replayer tüm abonelik için, her path ve her istemci tarafından paylaşılan tek bir geçmiş tutar; bu yüzden onu herkese açık bir `/feed` gibi path parametresi olmayan aboneliklerde kullan. Kullanıcı ya da oda başına akışlarda, handler modunda olduğu gibi kaldığın yerden veritabanından devam et. + + ```ts + import { FiniteReplayer } from '@hapi/sse'; + + server.sse.subscription('/feed', { + replay: new FiniteReplayer({ size: 100 }), + }); + + await server.sse.publish('/feed', item, { event: 'news', id: item.id }); + ``` + +- **Handler modu:** `stream` içinde `session.lastEventId` değerini oku ve oradan devam et; örneğin veritabanından o id'den daha yeni satırları sorgulayarak. + +İstemcinin son id'si artık buffer'da değilse, örneğin sunucu yeniden başladıktan sonra, replayer elindeki her şeyi yeniden gönderir; bu yüzden olaylarına istemcinin tekrarları ayıklamak için kullanabileceği id'ler ver. Her replayer'ın neyi tuttuğu [Event replay](/module/sse#event-replay) bölümünde anlatılıyor. Replay buffer'ları tek bir sürecin belleğinde yaşar; bkz. [Instance'lar arasında ölçekleme](#scaling). + +## Akış sona erdiğinde temizlik + +Bir akış, taraflardan biri onu kapatana kadar açık kalır. Bu yüzden bir bağlantı için başlattığın her şey, akış sona erdiğinde durmalıdır: istemci ayrıldığında, akış tamamlandığında ya da sunucu kapanırken: + +```ts +import type { Session } from '@hapi/sse'; + +const openStreams = new Map void>(); + +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + const watcher = watchJob(String(request.params.id)); + openStreams.set(session, () => watcher.stop()); + request.events.once('disconnect', () => watcher.stop()); + + try { + for await (const update of watcher) { + session.push(update, 'progress', String(update.percent)); + } + + if (session.isOpen) { + await session.complete(); + } + } finally { + watcher.stop(); + openStreams.delete(session); + } + }, + }, + }, +}); + +server.ext('onPreStop', () => { + for (const [session, stop] of openStreams) { + stop(); + session.close(); + } +}); +``` + +- **İstemci ayrılır:** `request.events` bir `'disconnect'` olayı yayar; bu da watcher'ı durdurur ve döngüyü bitirir. +- **Akış sunucuda sona erer:** `finally` bloğu çalışır, böylece `Map` hiçbir zaman bitmiş bir akışı tutmaz. +- **Sunucu kapanır:** `onPreStop` [sunucu hook'u](/tutorials/tr_TR/lifecycle-hooks#server-hooks) her watcher'ı durdurur ve her akışı kapatır, böylece `server.stop()` kendi `timeout` süresini beklemeden tamamlanır. +- **Abonelikler:** aboneliğin `onUnsubscribe` seçeneğini ya da her abonelik akışı için plugin'in `onSessionClose` hook'unu kullan. Plugin istemciyi abonelikten kendiliğinden çıkarır. + +## Proxy'ler, buffering ve boşta kalma zaman aşımları + +Production'daki SSE sorunlarının çoğu, sunucu ile tarayıcı arasındaki bir şeyin olayları bekletmesinden ya da sessiz bir bağlantıyı kapatmasından kaynaklanır. Plugin bunun bir kısmını halleder: + +| Sorun | Plugin'in yaptığı | Senin yapacağın | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Bir proxy yanıtı buffer'a alır ve olayları toplu hâlde iletir | `X-Accel-Buffering: no` ve `Cache-Control: no-cache` gönderir | bu header'lara uymayan proxy'lerde yanıt buffering'ini kapat | +| Sıkıştırma, yeterli bayt birikene kadar olayları bekletir | her yazmadan sonra gzip ve deflate çıktısını flush eder | hiçbir şey; olaylar sıkıştırılmış olsa bile tek tek gelir | +| Bir load balancer ya da proxy çok uzun süre boşta kalan bağlantıları kapatır | her 15 saniyede bir `:` yorum satırı gönderir | `keepAlive.interval` değerini yol üzerindeki en kısa boşta kalma zaman aşımının altında tut | +| Tarayıcılar alan adı başına yalnızca 6 HTTP/1.1 bağlantısına izin verir | hiçbir şey | siteyi, genellikle proxy üzerinde, HTTP/2 ile sun; böylece akışlar tek bir bağlantıyı paylaşır | + +## Kimlik Doğrulama + +Bir SSE akışı normal bir hapi route'udur, bu yüzden scope'lar dahil her zamanki [route kimlik doğrulaması](/tutorials/tr_TR/auth) geçerlidir. Abonelikler için bunu `auth` seçeneğinde ver: + +```ts +server.sse.subscription('/users/{userId}/notifications', { + auth: { strategy: 'session', access: { scope: 'user-{params.userId}' } }, +}); + +await server.sse.publish(`/users/${userId}/notifications`, notification, { + event: 'notification', + matchMode: 'literal', +}); +``` + +`user-{params.userId}` scope'uyla bir kullanıcı yalnızca kendi bildirim akışını açabilir; başka herkes `403` alır. Bkz. [Scope'lar](/tutorials/tr_TR/auth#scopes). Her bildirimin yalnızca kendi kullanıcısının akışına ulaşması için `matchMode: 'literal'` ile yayınla. + +`EventSource` istek header'ı ayarlayamaz, bu yüzden `Authorization` içinde bir bearer token kullanmak mümkün değildir. Seçenekler: + +| Yaklaşım | İyi yanı | Dikkat edilmesi gereken | +| -------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `@hapi/cookie` gibi bir cookie oturumu | tarayıcı onu yeniden bağlanırken de gönderir | cross-origin akışlar `new EventSource(url, { withCredentials: true })` ve credentials destekli CORS gerektirir | +| Query string içinde token | her istemciyle çalışır | URL'ler sunucu ve proxy erişim loglarına düşer; yalnızca bu akış için üretilmiş kısa ömürlü bir token kullan | +| Akış gövdeli `fetch()` | her header'ı gönderebilir | `EventSource`'un yeniden bağlanma ve `Last-Event-ID` desteğini kaybedersin ve bunları kendin yazmalısın | + +Kendi alan adında çalışan bir tarayıcı uygulaması için en basit seçim cookie'dir. + +## Instance'lar arasında ölçekleme + +İki sunucu instance'ı belleği paylaşmaz. Bir SSE akışı, tek bir sürecin tuttuğu açık bir bağlantıdır: A instance'ına bağlı bir istemci yalnızca A'da vardır ve B instance'ının ona yazmasının hiçbir yolu yoktur. A'da yayınlanan bir olay yalnızca A'nın istemcilerine ulaşır. Bu her SSE kütüphanesi ve her hosting sağlayıcısı için geçerlidir: her süreç yalnızca kendi sahip olduğu bağlantılara yazabilir. + +Bağlı istemciler, replay buffer'ları ve varsayılan olarak tamamlanmış akışların kaydı, bu süreç başına bellekte yaşar. Ölçeklemek için instance'lar arasında mesaj iletimi gerekir. Redis, NATS ya da Postgres `LISTEN`/`NOTIFY` gibi bir pub/sub backend'i her olayı her instance'a dağıtır ve her instance olayı kendi açık akışlarına yazar: + +```mermaid +flowchart TD + P["A instance'ında
bir olay oluşturulur"] -->|publish| R[("broker
Redis, NATS, Postgres")] + R -->|dağıtır| A["A instance'ı"] + R -->|dağıtır| B["B instance'ı"] + A --> CA["A'daki istemciler"] + B --> CB["B'deki istemciler"] +``` + +Redis ile: + +```ts +import { createClient } from 'redis'; + +interface RoomMessage { + room: string; + text: string; + at: string; +} + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('rooms', async (raw) => { + const message: RoomMessage = JSON.parse(raw); + await server.sse.publish(`/rooms/${message.room}/messages`, message, { event: 'message', matchMode: 'literal' }); +}); + +const publishToRoom = async (message: RoomMessage) => { + await publisher.publish('rooms', JSON.stringify(message)); +}; +``` + +`POST` route'u `server.sse.publish()` yerine `publishToRoom()` çağırır ve isteği işleyen de dahil her instance olayı kendi akışlarına yayınlar. + +- Broker devredeyken her instance her olayı yayınlar; bu yüzden her instance'ın replayer'ı aynı geçmişi tutar ve yeniden bağlanan bir istemci herhangi bir instance'ta kaldığı yerden devam edebilir. Eksik olan yalnızca bir instance başlamadan önceki olaylardır; bunu da [Yeniden bağlanma](#reconnect) bölümündeki tekrarlara dayanıklı id'ler zaten karşılar. +- Plugin'in `completion.cache` seçeneğini catbox-redis gibi paylaşılan bir önbelleğe yönlendir; böylece tamamlanmış bir abonelik akışı her instance'ta tanınır. +- `maxDuration` her akışı belirli bir süreden sonra (rastgele bir sapmayla) kapatır ve istemci yeniden bağlanır. Böylece yeni instance'lar eklediğinde uzun ömürlü akışlar eski instance'larda kalmak yerine yeniden dağılır. + +## Sonraki adımlar + +- [@hapi/sse API referansı](/module/sse): tüm seçenekler, `Session`, replayer'lar, yavaş istemciler için [backpressure](/module/sse#backpressure) ve [yerleşik güvenlik önlemleri](/module/sse#security). +- İstemcinin de mesaj göndermesi gerektiğinde [WebSocket'ler](/tutorials/tr_TR/websockets). +- `onPreStop` ve diğer sunucu hook'ları için [Yaşam Döngüsü Hook'ları](/tutorials/tr_TR/lifecycle-hooks). diff --git a/docs/tutorials/zh_CN/sse.md b/docs/tutorials/zh_CN/sse.md new file mode 100644 index 00000000..f65253c1 --- /dev/null +++ b/docs/tutorials/zh_CN/sse.md @@ -0,0 +1,307 @@ +--- +title: 服务器发送事件 +--- + + + +# 服务器发送事件 + +_本教程适用于 hapi v21 及更高版本_ + +## 概述 + +服务器发送事件(Server-Sent Events,SSE)让服务器通过一个普通的 HTTP 响应向客户端推送事件流。浏览器用内置的 `EventSource` API 打开它,连接断开时它也会自动重连。[@hapi/sse](/module/sse) 为 hapi 提供 SSE 支持。 + +## 何时使用 SSE + +| | SSE | [WebSocket](/tutorials/zh_CN/websockets) | 轮询 | +| ------------ | ------------------------------------------ | ---------------------------------------------------- | ---------------------- | +| 方向 | 只能从服务器到客户端 | 双向 | 客户端询问,服务器应答 | +| 传输方式 | 普通的 HTTP 响应 | 协议升级 | 普通的 HTTP 请求 | +| 重连 | `EventSource` 内置,从最后一个事件 id 续传 | nes 客户端会重连并重新订阅;离线期间发送的消息会丢失 | 不需要 | +| 能否穿过代理 | 可以,需关闭缓冲 | 代理必须支持协议升级 | 可以 | +| 延迟 | 即时 | 即时 | 最长为一个轮询间隔 | + +当服务器有新消息、而客户端只负责接收时,选择 SSE:任务进度、通知、实时仪表盘、日志尾随,以及 AI 模型逐 token 的输出。如果客户端也需要持续发送消息,请使用 WebSocket。客户端只是偶尔发送数据时,可以用 SSE 接收事件流,其余操作用普通的 `POST` 请求完成。 + +## 安装与配置 + +```sh +npm install @hapi/sse +``` + +```ts +import Hapi from '@hapi/hapi'; +import { SsePlugin } from '@hapi/sse'; + +const server = Hapi.server({ port: 3000 }); + +await server.register(SsePlugin); +``` + +插件提供两种打开事件流的方式: + +| 用途 | handler 模式:`handler: { sse: { stream } }` | 订阅:`server.sse.subscription()` | +| ------------------ | -------------------------------------------- | ------------------------------------- | +| 由谁产生事件 | 你的 `stream` 函数,只服务于这一个连接 | 任何代码,通过 `server.sse.publish()` | +| 典型的事件流 | 单个任务的进度、一次 AI 响应、日志尾随 | 通知、实时信息流、聊天室 | +| 每个连接收到的事件 | 每个客户端各不相同 | 同一个事件发给所有订阅者 | +| 重连后的续传 | 你读取 `session.lastEventId` 并接着发送 | 由 replayer 重发客户端错过的事件 | + +## handler 模式:每个请求一条事件流 + +使用 `sse` handler 的路由会为每个连接运行一次你的 `stream` 函数。你 `push()` 的每一项都会即时送达客户端: + +```ts +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + for await (const update of watchJob(String(request.params.id))) { + session.push(update, 'progress', String(update.percent)); + } + + await session.complete(); + }, + }, + }, +}); +``` + +每次 `push(data, event, id)` 都会在线路上产生一个事件。对象以 JSON 发送: + +```text +event: progress +id: 50 +data: {"percent":50} +``` + +`complete()` 发送最后一个 `complete` 事件并结束响应。仅仅结束响应并不能让浏览器停下来,因为 `EventSource` 把每一次连接关闭都当作意外断开并重新连接。客户端必须在收到 `complete` 时自己关闭连接: + +```ts +const source = new EventSource('/jobs/7/progress'); + +source.addEventListener('progress', (event) => { + const { percent } = JSON.parse(event.data); + console.log(`${percent}%`); +}); + +source.addEventListener('complete', () => source.close()); +``` + +如果没有 `close()`,浏览器会在 `retry` 延迟之后重新连接,事件流又从头开始。 + +除非用 `--experimental-eventsource` 启动,否则 Node.js 没有全局的 `EventSource`。在 Node 中,请使用这个参数或 [`eventsource`](https://www.npmjs.com/package/eventsource) 包;客户端代码与浏览器中完全相同。 + +## 订阅:一个事件发给多个客户端 + +订阅是一条路由,客户端连接到它,你的代码向它发布事件。路径可以带参数,这样每个用户或每个房间都有自己的事件流: + +```ts +import Joi from 'joi'; + +server.sse.subscription('/rooms/{room}/messages'); + +server.route<{ Params: { room: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{room}/messages', + options: { + validate: { + params: Joi.object({ room: Joi.string().alphanum().max(50).required() }), + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: async (request, h) => { + const message = { text: request.payload.text, at: new Date().toISOString() }; + const delivered = await server.sse.publish(`/rooms/${request.params.room}/messages`, message, { + event: 'message', + matchMode: 'literal', + }); + + return { delivered }; + }, +}); +``` + +- `matchMode: 'literal'` 只把事件发给连接到这个确切路径的客户端,所以发到 `/rooms/lobby/messages` 的消息只会到达 lobby,不会到达其他房间。参见 [`server.sse.publish()`](/module/sse#server-sse-publish-path-data-opts)。 +- 这条路由只发布它[验证](/tutorials/zh_CN/validation)过的字段,从不发布原始 payload。像任何写入数据的路由一样,为它配置相同的[路由身份认证](/tutorials/zh_CN/auth)。 +- `publish()` 返回事件送达的客户端数量。 + +订阅还支持 `filter`、`onSubscribe`、`refuse` 和 `maxSessions`;参见 [`server.sse.subscription()`](/module/sse#server-sse-subscription-path-config)。 + +## 重连与 `Last-Event-ID` + +连接断开后,浏览器会等待服务器发来的 `retry` 时间,然后重新连接,并在 `Last-Event-ID` 请求头中带上它收到的最后一个事件的 id: + +```mermaid +sequenceDiagram + participant B as 浏览器 + participant S as hapi 服务器 + B->>S: GET /feed + S-->>B: retry: 2000,然后是事件 a1、a2 + Note over B,S: 连接断开,期间发布了 a3 + B->>S: GET /feed,带 Last-Event-ID: a2 + Note over S: replayer 找到 a2
并重发它之后的事件 + S-->>B: 事件 a3,然后是实时事件 +``` + +浏览器会发送这个请求头,但只有你的服务器能据此行动,两种模式的做法不同: + +- **订阅:** 为订阅配置一个 replayer,它把最近的事件保存在内存中,并重发客户端最后一个 id 之后的所有事件。一个 replayer 为整个订阅只保存一份历史,由所有路径和所有客户端共享,因此只在不带路径参数的订阅上使用它,例如公开的 `/feed`。对于按用户或按房间划分的事件流,请像 handler 模式那样从数据库续传。 + + ```ts + import { FiniteReplayer } from '@hapi/sse'; + + server.sse.subscription('/feed', { + replay: new FiniteReplayer({ size: 100 }), + }); + + await server.sse.publish('/feed', item, { event: 'news', id: item.id }); + ``` + +- **handler 模式:** 在 `stream` 中读取 `session.lastEventId` 并从那里继续,例如在数据库中查询比该 id 更新的记录。 + +如果客户端的最后一个 id 已经不在缓冲区中,例如服务器重启之后,replayer 会重发它保存的全部事件,所以要给事件设置客户端可以用来去重的 id。[事件重放](/module/sse#event-replay)说明了每种 replayer 保存哪些内容。重放缓冲区位于单个进程的内存中;参见[扩展到多个实例](#scaling)。 + +## 事件流结束时的清理 + +事件流会一直保持打开,直到某一方关闭它,所以为某个连接启动的任何工作都必须在事件流结束时停止,无论是客户端离开、事件流完成,还是服务器正在关闭: + +```ts +import type { Session } from '@hapi/sse'; + +const openStreams = new Map void>(); + +server.route({ + method: 'GET', + path: '/jobs/{id}/progress', + handler: { + sse: { + stream: async (request, session) => { + const watcher = watchJob(String(request.params.id)); + openStreams.set(session, () => watcher.stop()); + request.events.once('disconnect', () => watcher.stop()); + + try { + for await (const update of watcher) { + session.push(update, 'progress', String(update.percent)); + } + + if (session.isOpen) { + await session.complete(); + } + } finally { + watcher.stop(); + openStreams.delete(session); + } + }, + }, + }, +}); + +server.ext('onPreStop', () => { + for (const [session, stop] of openStreams) { + stop(); + session.close(); + } +}); +``` + +- **客户端离开:** `request.events` 触发 `'disconnect'`,它会停止 watcher 并结束循环。 +- **事件流在服务器端结束:** `finally` 块会运行,因此 `Map` 中永远不会留下已结束的事件流。 +- **服务器关闭:** `onPreStop` [服务器钩子](/tutorials/zh_CN/lifecycle-hooks#server-hooks)停止所有 watcher 并关闭所有事件流,这样 `server.stop()` 不必等到 `timeout` 用完就能结束。 +- **订阅:** 使用订阅的 `onUnsubscribe`,或者插件的 `onSessionClose` 钩子来处理所有订阅事件流。插件会自行把客户端从订阅中移除。 + +## 代理、缓冲与空闲超时 + +生产环境中的大多数 SSE 问题,都来自服务器和浏览器之间的某个环节扣住了事件,或者关闭了一个安静的连接。插件会处理其中一部分: + +| 问题 | 插件的做法 | 你要做的 | +| ----------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | +| 代理缓冲响应,事件成批到达 | 发送 `X-Accel-Buffering: no` 和 `Cache-Control: no-cache` | 在不遵守这些响应头的代理中关闭响应缓冲 | +| 压缩会扣住事件,直到积累了足够的字节 | 每次写入后刷新 gzip 和 deflate | 无需操作;即使经过压缩,事件也会逐个到达 | +| 负载均衡器或代理关闭空闲过久的连接 | 每 15 秒发送一行 `:` 注释 | 让 `keepAlive.interval` 小于链路上最短的空闲超时 | +| 浏览器对每个域名只允许 6 个 HTTP/1.1 连接 | 无 | 通过 HTTP/2 提供站点(通常在代理上配置),让事件流共享一个连接 | + +## 身份认证 + +SSE 事件流就是普通的 hapi 路由,所以常规的[路由身份认证](/tutorials/zh_CN/auth)同样适用,包括 scope。对于订阅,通过 `auth` 选项传入: + +```ts +server.sse.subscription('/users/{userId}/notifications', { + auth: { strategy: 'session', access: { scope: 'user-{params.userId}' } }, +}); + +await server.sse.publish(`/users/${userId}/notifications`, notification, { + event: 'notification', + matchMode: 'literal', +}); +``` + +使用 `user-{params.userId}` 这个 scope,用户只能打开自己的通知事件流;其他人会收到 `403`。参见 [Scopes](/tutorials/zh_CN/auth#scopes)。发布时使用 `matchMode: 'literal'`,让每条通知只到达对应用户的事件流。 + +`EventSource` 无法设置请求头,所以不能在 `Authorization` 中携带 bearer token。可选的方案: + +| 方式 | 优点 | 注意事项 | +| -------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------- | +| cookie 会话,例如 `@hapi/cookie` | 浏览器会自动发送,重连时也会发送 | 跨域事件流需要 `new EventSource(url, { withCredentials: true })` 以及允许携带凭证的 CORS | +| 把 token 放在查询字符串中 | 适用于任何客户端 | URL 会出现在服务器和代理的访问日志中;请使用专为这条事件流签发的短期 token | +| 使用流式响应体的 `fetch()` | 可以发送任意请求头 | 失去 `EventSource` 的重连和 `Last-Event-ID` 处理,必须自己实现 | + +对于部署在你自己域名下的浏览器应用,cookie 是最简单的选择。 + +## 扩展到多个实例 + +两个服务器实例之间不共享内存。SSE 事件流是由某一个进程持有的打开的连接:连接到实例 A 的客户端只存在于 A 上,实例 B 无法向它写入。在 A 上发布的事件只会到达 A 的客户端。这一点对每个 SSE 库和每个托管服务商都成立:每个进程只能写入它自己持有的连接。 + +已连接的客户端、重放缓冲区,以及默认情况下已完成事件流的记录,都保存在这块进程内存中。横向扩展需要在实例之间传递消息。Redis、NATS 或 Postgres `LISTEN`/`NOTIFY` 这类发布/订阅后端会把每个事件分发给所有实例,再由每个实例写入它自己打开的事件流: + +```mermaid +flowchart TD + P["在实例 A 上
产生一个事件"] -->|发布| R[("消息代理
Redis、NATS、Postgres")] + R -->|分发| A[实例 A] + R -->|分发| B[实例 B] + A --> CA[A 上的客户端] + B --> CB[B 上的客户端] +``` + +使用 Redis: + +```ts +import { createClient } from 'redis'; + +interface RoomMessage { + room: string; + text: string; + at: string; +} + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('rooms', async (raw) => { + const message: RoomMessage = JSON.parse(raw); + await server.sse.publish(`/rooms/${message.room}/messages`, message, { event: 'message', matchMode: 'literal' }); +}); + +const publishToRoom = async (message: RoomMessage) => { + await publisher.publish('rooms', JSON.stringify(message)); +}; +``` + +`POST` 路由改为调用 `publishToRoom()` 而不是 `server.sse.publish()`,每个实例(包括处理该请求的那个实例)都会向自己的事件流发布。 + +- 有了消息代理之后,每个实例都会发布每一个事件,因此每个实例的 replayer 都保存着相同的历史,重连的客户端可以在任何实例上续传。只有实例启动之前的事件会缺失,而[重连](#reconnect)一节中可去重的 id 已经覆盖了这种情况。 +- 把插件的 `completion.cache` 选项指向一个共享缓存,例如 catbox-redis,这样每个实例都能识别已完成的订阅事件流。 +- `maxDuration` 会在设定的时间后(带随机抖动)关闭每条事件流,然后客户端重新连接。这样在你增加实例之后,长期存在的事件流会重新分散开,而不是一直留在旧实例上。 + +## 后续步骤 + +- [@hapi/sse API 参考](/module/sse):所有选项、`Session`、replayer、针对慢客户端的[背压](/module/sse#backpressure),以及[内置的安全防护](/module/sse#security)。 +- 当客户端也需要发送消息时,参见 [WebSocket](/tutorials/zh_CN/websockets)。 +- [生命周期钩子](/tutorials/zh_CN/lifecycle-hooks):`onPreStop` 及其他服务器钩子。 From ea1f8c46a7a2ce59da8378cd885e55fdd444f8b9 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 02:55:12 -0400 Subject: [PATCH 22/22] docs: add websockets tutorial --- .vitepress/config.ts | 1 + docs/tutorials/en_US/websockets.md | 310 +++++++++++++++++++++++++++++ docs/tutorials/es_ES/websockets.md | 310 +++++++++++++++++++++++++++++ docs/tutorials/ko_KR/websockets.md | 310 +++++++++++++++++++++++++++++ docs/tutorials/pt_BR/websockets.md | 310 +++++++++++++++++++++++++++++ docs/tutorials/tr_TR/websockets.md | 310 +++++++++++++++++++++++++++++ docs/tutorials/zh_CN/websockets.md | 310 +++++++++++++++++++++++++++++ 7 files changed, 1861 insertions(+) create mode 100644 docs/tutorials/en_US/websockets.md create mode 100644 docs/tutorials/es_ES/websockets.md create mode 100644 docs/tutorials/ko_KR/websockets.md create mode 100644 docs/tutorials/pt_BR/websockets.md create mode 100644 docs/tutorials/tr_TR/websockets.md create mode 100644 docs/tutorials/zh_CN/websockets.md diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 649dcdf0..18912504 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -69,6 +69,7 @@ const tutorialItems = [ { slug: 'testing', text: 'Testing' }, { slug: 'validation', text: 'Validation' }, { slug: 'views', text: 'Views' }, + { slug: 'websockets', text: 'WebSockets' }, { slug: 'community', text: 'Community' }, { slug: 'express-to-hapi', text: 'Express to hapi' }, ]; diff --git a/docs/tutorials/en_US/websockets.md b/docs/tutorials/en_US/websockets.md new file mode 100644 index 00000000..532d0426 --- /dev/null +++ b/docs/tutorials/en_US/websockets.md @@ -0,0 +1,310 @@ +--- +title: WebSockets +--- + + + +# WebSockets + +_This tutorial is compatible with hapi v21 and newer_ + +## Overview + +[@hapi/nes](/module/nes) adds a WebSocket endpoint to a hapi server. A connected client can do three things over that one socket: + +- **Call your routes.** `client.request()` runs a normal hapi route, with its validation, auth scopes, and handler, and returns the response over the socket. +- **Subscribe to updates.** The client subscribes to a path such as `/rooms/lobby`, and the server publishes to it. +- **Receive pushes.** The server sends a message to one socket or broadcasts to all of them. + +## When to use WebSockets + +| Approach | Direction | Good for | +| ------------------------------------------ | --------------------------- | --------------------------------------------------------------------------- | +| Polling a normal route | client asks, server answers | updates every few minutes, or when real-time adds nothing | +| [Server-Sent Events](/tutorials/en_US/sse) | server to client only | live feeds, progress, notifications; plain HTTP, works through most proxies | +| WebSockets with nes | both ways, one connection | chat, collaboration, games: clients send often and expect pushes back | + +If the client only listens, SSE is simpler to run. Pick WebSockets when the client also sends frequently, or when you want one connection for both requests and updates, which matters on mobile networks. + +## Setup + +```sh +npm install @hapi/nes +``` + +A chat room server: messages are posted through a route, every subscriber of the room receives them, and a second route returns the recent history. + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; +import Nes from '@hapi/nes'; + +interface ChatMessage { + room: string; + text: string; + at: string; +} + +const history = new Map(); + +const server = Hapi.server({ port: 4000 }); + +await server.register(Nes); + +server.subscription('/rooms/{id}'); + +const roomParams = Joi.object({ id: Joi.string().alphanum().max(50).required() }); + +server.route<{ Params: { id: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{id}/messages', + options: { + validate: { + params: roomParams, + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: (request, h) => { + const message: ChatMessage = { + room: request.params.id, + text: request.payload.text, + at: new Date().toISOString(), + }; + + const messages = history.get(message.room) ?? []; + history.set(message.room, [...messages, message].slice(-50)); + + server.publish(`/rooms/${message.room}`, message); + return h.response(message).code(201); + }, +}); + +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/rooms/{id}/messages', + options: { validate: { params: roomParams } }, + handler: (request, h) => history.get(request.params.id) ?? [], +}); + +await server.start(); +``` + +nes listens on the same port as your HTTP routes. The routes work over plain HTTP too, so other services can post to a room without opening a socket. The `POST` route publishes only the fields it [validated](/tutorials/en_US/validation), never the raw payload. + +The client in Node.js: + +```ts +import Nes from '@hapi/nes'; + +const client = new Nes.Client('ws://localhost:4000'); +await client.connect(); + +await client.subscribe('/rooms/lobby', (message) => { + console.log('lobby:', message); +}); + +await client.request({ method: 'POST', path: '/rooms/lobby/messages', payload: { text: 'hello' } }); +``` + +In the browser, import the client file on its own so your bundle does not pull in the server plugin: + +```ts +import { Client } from '@hapi/nes/lib/client.js'; + +const client = new Client(`wss://${location.host}`); +await client.connect(); +``` + +Subscription handlers receive the message as `unknown`. The server can publish anything, so check the shape before you use it, the same way you would for a `fetch()` response. + +## Calling routes over the socket + +`client.request()` runs the route through the full hapi [request lifecycle](/api#request-lifecycle): validation, `auth.access` scopes, extensions, and the handler. nes does this with [`server.inject()`](/api#await-server-inject-options), passing the credentials the socket authenticated with when it connected. The request options and the response shape are in [`client.request()`](/module/nes#await-client-request-options). + +Inside the handler, `request.socket` is the nes socket for socket requests, and `null` for normal HTTP requests, so check it before you use it. + +Use socket requests when a client already holds a connection and you want to avoid a second HTTP round trip. For a page that mostly listens, normal HTTP requests plus a subscription are easier to debug. + +## Pushing updates + +nes gives you three ways to send a message to clients: + +| Method | Who receives it | Use it for | +| ------------------------------- | ------------------------------------- | ------------------------------------------ | +| `server.publish(path, message)` | sockets subscribed to a matching path | almost everything: rooms, documents, feeds | +| `socket.send(message)` | one socket | replies and per-connection notices | +| `server.broadcast(message)` | every connected socket | rare: maintenance warnings, forced reloads | + +Subscriptions are usually what you want, because the client says what it cares about and the server only sends that. + +```mermaid +sequenceDiagram + participant C as Client + participant S as hapi server + C->>S: connect (hello, with auth) + S-->>C: connected + C->>S: subscribe /rooms/lobby + Note over S: subscription('/rooms/{id}') matches,
auth and scope checked + S-->>C: subscribed + Note over S: a route calls publish('/rooms/lobby', message) + Note over S: filter(path, message, { socket, credentials })
runs once per subscriber + S-->>C: update +``` + +A subscription `filter` decides, per socket, whether the update is sent. Pass data the filter needs but the client should not see in `internal`: + +```ts +server.subscription('/documents/{id}', { + filter: (path, message, options) => { + return options.internal === options.credentials?.id; + }, +}); + +server.publish('/documents/42', { title: 'Q3 plan' }, { internal: '2133d32a' }); +``` + +Only the document's owner receives the update, and the owner id never reaches a client. The filter compares against `credentials.id`, which the [bearer strategy](/tutorials/en_US/auth#jwt) from the authentication tutorial sets. + +The filter runs for every subscriber on every publish, so keep it to in-memory checks. To change what each user sees, the filter can return `{ override: message }` instead of `true`. + +## Authentication + +nes does not have its own authentication. It reuses your hapi [auth strategies](/tutorials/en_US/auth): the socket authenticates when it connects, and every request and subscription on that socket uses those credentials. + +The plugin's `auth.type` option decides how the client proves who it is: + +| `auth.type` | How the client authenticates | Fits | +| -------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `'direct'` (default) | sends normal HTTP auth headers inside the connect message: `client.connect({ auth: { headers: { authorization } } })` | APIs with bearer tokens or basic auth | +| `'token'` | calls `GET /nes/auth` over HTTP first, gets a sealed token, and passes it to `client.connect({ auth: token })` | clients that cannot rely on cookies, such as mobile apps | +| `'cookie'` | calls `GET /nes/auth` over HTTP first, which sets an encrypted cookie the browser sends on the WebSocket upgrade | browser apps already using cookie sessions | + +With `'direct'`, nes runs the headers through the strategy in `auth.route`, or the server's [default strategy](/tutorials/en_US/auth#default) when `auth.route` is not set. With the [bearer strategy](/tutorials/en_US/auth#jwt) from the authentication tutorial as the default: + +```ts +const client = new Nes.Client('wss://api.example.com'); +await client.connect({ auth: { headers: { authorization: `Bearer ${token}` } } }); +``` + +- **Connecting without credentials.** If the auth strategy's mode is `'required'`, a client that connects without `auth` is rejected with `Connection requires authentication`. Otherwise it connects unauthenticated, and only the routes and subscriptions that need auth refuse it. +- **Expiring tokens.** The `@hapi/jwt` strategy re-checks the token while the socket is open, at most once per `auth.minAuthVerifyInterval` (the heartbeat interval by default). Once the token expires, the server closes the socket, the client's reconnect is rejected with `Token maximum age exceeded`, and the client stops retrying. Send a fresh token before the old one expires: + + ```ts + await client.reauthenticate({ headers: { authorization: `Bearer ${freshToken}` } }); + ``` + + Strategies whose scheme has no `verify()` method keep the credentials from connect time for the life of the socket. + +- **Protecting subscriptions.** A subscription without `auth` options uses the default strategy and its mode, but not the scopes in the default's `access`. Those still protect socket requests. Give every protected subscription its own `auth.scope`, which accepts any one of the listed scopes; the `+` and `!` prefixes that [route scopes](/tutorials/en_US/auth#scopes) support are not available. +- **Set `auth.password` for `'token'` and `'cookie'`.** Without it, nes picks a random password when the process starts, so tokens and cookies stop working after a restart, and an instance cannot read what another instance sealed. +- **Set `origin` with cookie auth.** WebSocket upgrades get no CORS check, and sibling subdomains count as the same site, so the nes cookie's `SameSite=Strict` does not stop them. `origin` refuses upgrades from any other origin, including clients that send no `Origin` header, such as the Node.js client above. + +```ts +import Hapi from '@hapi/hapi'; +import Nes from '@hapi/nes'; + +import type { Nes as NesTypes } from '@hapi/nes'; + +const cookiePassword = process.env.NES_COOKIE_PASSWORD; +if (!cookiePassword || cookiePassword.length < 32) { + throw new Error('NES_COOKIE_PASSWORD must be set to at least 32 characters'); +} + +const server = Hapi.server({ port: 4000 }); + +const nesOptions = { + auth: { type: 'cookie', password: cookiePassword } satisfies NesTypes.PluginOptions['auth'], + origin: ['https://app.example.com'], +}; + +await server.register({ plugin: Nes, options: nesOptions }); +``` + +## Reconnects and missed updates + +Mobile networks drop connections often, and the client reconnects on its own: + +```mermaid +flowchart TD + A[connection lost] --> B["client waits, then reconnects
(1s, 2s, 3s... up to 5s apart)"] + B --> C{"connect message
accepted?"} + C -->|yes| D["subscriptions restored,
onConnect called"] + C -->|"no: auth failed"| E["client stops retrying,
onDisconnect called"] + D --> F["your onConnect handler
refetches current state"] +``` + +- The client keeps retrying with the delays above until it reconnects, unless you limit `retries` in `client.connect()`. +- On reconnect, the client resends its subscriptions, so your handlers keep working without any code. +- **Updates published while the client was disconnected are lost.** nes does not queue them. If missing an update matters, load the current state in `onConnect`: + +```ts +const refreshRoom = async (room: string) => { + const { payload } = await client.request(`/rooms/${room}/messages`); + render(payload); +}; + +client.onConnect = () => { + refreshRoom('lobby').catch((err) => console.error('Could not refresh the room', err)); +}; +``` + +- The server pings each client every 15 seconds and drops it if it does not answer within 5 seconds; the client disconnects and reconnects if pings stop arriving. Load balancers and proxies that close idle connections need an idle timeout longer than the ping interval, or connections will drop for no visible reason. + +## Scaling across instances + +Two server instances do not share memory. A WebSocket connection held by instance A does not exist on instance B, so `server.publish()` or `server.broadcast()` on A reaches only the clients connected to A. With two instances behind a load balancer, a message posted to A never reaches a client connected to B. This holds for every WebSocket library and hosting provider: each process can only write to the connections it owns. + +Scaling out needs message passing between the instances. A pub/sub backend such as Redis, NATS, or Postgres `LISTEN`/`NOTIFY` fans each message out to every instance, and each instance delivers it to its own connections: + +```mermaid +flowchart TD + P["a message is created
on instance A"] -->|publish| R[("broker
Redis, NATS, Postgres")] + R -->|fan out| A[instance A] + R -->|fan out| B[instance B] + A --> CA[clients on A] + B --> CB[clients on B] +``` + +With Redis: + +```ts +import { createClient } from 'redis'; + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('chat', (raw) => { + const message: ChatMessage = JSON.parse(raw); + server.publish(`/rooms/${message.room}`, message); +}); + +const publishToRoom = async (message: ChatMessage) => { + await publisher.publish('chat', JSON.stringify(message)); +}; +``` + +The `POST` route calls `publishToRoom()` instead of `server.publish()`, and every instance, including the one that handled the request, publishes to its own sockets. + +- WebSocket connections stay on the instance they connected to, so the socket itself needs no sticky sessions. For the `'token'` and `'cookie'` auth types, set the same `auth.password` on every instance, since the `/nes/auth` request may land on a different instance than the socket. +- `server.eachSocket()` is local to the process in the same way. + +## Cleaning up + +Per-connection state lives as long as the socket. Release it when the socket goes away: + +- `onDisconnection` in the plugin options runs when any socket closes. +- `onUnsubscribe` in `server.subscription()` options runs when a client unsubscribes or disconnects, which is the place to stop work done only for that subscriber, such as a polling loop. +- `server.stop()` closes every socket. Clients see a disconnect and start reconnecting, which is what you want during a rolling deploy: they come back on another instance. + +## TypeScript + +nes includes its own types for the plugin and the client. Importing `@hapi/nes` adds `server.subscription()`, `server.publish()`, `server.broadcast()`, `server.eachSocket()`, and `request.socket` to hapi's types. `client.request()` takes the payload type as a type argument, as in `client.request(path)`; it is not checked at runtime, so validate data you do not control. + +## Next steps + +- [@hapi/nes API reference](/module/nes): every plugin option, [socket auth](/module/nes#socket-auth), and the full client API. +- [Server-Sent Events](/tutorials/en_US/sse) when the client only listens. +- [Authentication](/tutorials/en_US/auth) for the strategies and scopes nes reuses. diff --git a/docs/tutorials/es_ES/websockets.md b/docs/tutorials/es_ES/websockets.md new file mode 100644 index 00000000..b284fb54 --- /dev/null +++ b/docs/tutorials/es_ES/websockets.md @@ -0,0 +1,310 @@ +--- +title: WebSockets +--- + + + +# WebSockets + +_Este tutorial es compatible con hapi v21 y versiones posteriores_ + +## Introducción + +[@hapi/nes](/module/nes) añade un endpoint WebSocket a un servidor hapi. Un cliente conectado puede hacer tres cosas por ese único socket: + +- **Llamar a tus rutas.** `client.request()` ejecuta una ruta normal de hapi, con su validación, sus scopes de autenticación y su handler, y devuelve la respuesta por el socket. +- **Suscribirse a actualizaciones.** El cliente se suscribe a un path como `/rooms/lobby`, y el servidor publica en él. +- **Recibir mensajes push.** El servidor envía un mensaje a un socket o lo difunde a todos. + +## Cuándo usar WebSockets + +| Enfoque | Dirección | Adecuado para | +| ------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Polling de una ruta normal | el cliente pregunta, el servidor responde | actualizaciones cada pocos minutos, o cuando el tiempo real no aporta nada | +| [Server-Sent Events](/tutorials/es_ES/sse) | solo del servidor al cliente | feeds en directo, progreso, notificaciones; HTTP normal, funciona a través de casi cualquier proxy | +| WebSockets con nes | en ambos sentidos, una sola conexión | chat, colaboración, juegos: los clientes envían a menudo y esperan respuestas push | + +Si el cliente solo escucha, SSE es más sencillo de operar. Elige WebSockets cuando el cliente también envía con frecuencia, o cuando quieres una sola conexión para peticiones y actualizaciones, algo que importa en redes móviles. + +## Configuración inicial + +```sh +npm install @hapi/nes +``` + +Un servidor de salas de chat: los mensajes se envían a través de una ruta, todos los suscriptores de la sala los reciben y una segunda ruta devuelve el historial reciente. + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; +import Nes from '@hapi/nes'; + +interface ChatMessage { + room: string; + text: string; + at: string; +} + +const history = new Map(); + +const server = Hapi.server({ port: 4000 }); + +await server.register(Nes); + +server.subscription('/rooms/{id}'); + +const roomParams = Joi.object({ id: Joi.string().alphanum().max(50).required() }); + +server.route<{ Params: { id: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{id}/messages', + options: { + validate: { + params: roomParams, + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: (request, h) => { + const message: ChatMessage = { + room: request.params.id, + text: request.payload.text, + at: new Date().toISOString(), + }; + + const messages = history.get(message.room) ?? []; + history.set(message.room, [...messages, message].slice(-50)); + + server.publish(`/rooms/${message.room}`, message); + return h.response(message).code(201); + }, +}); + +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/rooms/{id}/messages', + options: { validate: { params: roomParams } }, + handler: (request, h) => history.get(request.params.id) ?? [], +}); + +await server.start(); +``` + +nes escucha en el mismo puerto que tus rutas HTTP. Las rutas también funcionan por HTTP normal, así que otros servicios pueden enviar mensajes a una sala sin abrir un socket. La ruta `POST` publica solo los campos que ha [validado](/tutorials/es_ES/validation), nunca el payload tal cual. + +El cliente en Node.js: + +```ts +import Nes from '@hapi/nes'; + +const client = new Nes.Client('ws://localhost:4000'); +await client.connect(); + +await client.subscribe('/rooms/lobby', (message) => { + console.log('lobby:', message); +}); + +await client.request({ method: 'POST', path: '/rooms/lobby/messages', payload: { text: 'hello' } }); +``` + +En el navegador, importa el fichero del cliente por separado para que tu bundle no incluya el plugin del servidor: + +```ts +import { Client } from '@hapi/nes/lib/client.js'; + +const client = new Client(`wss://${location.host}`); +await client.connect(); +``` + +Los handlers de suscripción reciben el mensaje como `unknown`. El servidor puede publicar cualquier cosa, así que comprueba la forma antes de usarlo, igual que harías con una respuesta de `fetch()`. + +## Llamar a rutas por el socket + +`client.request()` hace pasar la ruta por todo el [ciclo de vida de la petición](/api#request-lifecycle) de hapi: validación, scopes de `auth.access`, extensiones y el handler. nes lo hace con [`server.inject()`](/api#await-server-inject-options), pasando las credenciales con las que se autenticó el socket al conectarse. Las opciones de la petición y la forma de la respuesta están en [`client.request()`](/module/nes#await-client-request-options). + +Dentro del handler, `request.socket` es el socket de nes en las peticiones por socket, y `null` en las peticiones HTTP normales, así que compruébalo antes de usarlo. + +Usa peticiones por socket cuando un cliente ya tiene una conexión abierta y quieres evitar un segundo viaje de ida y vuelta por HTTP. Para una página que sobre todo escucha, las peticiones HTTP normales más una suscripción son más fáciles de depurar. + +## Enviar actualizaciones + +nes te da tres formas de enviar un mensaje a los clientes: + +| Método | Quién lo recibe | Para qué usarlo | +| ------------------------------- | -------------------------------------------- | ------------------------------------------------------- | +| `server.publish(path, message)` | los sockets suscritos a un path que coincide | casi todo: salas, documentos, feeds | +| `socket.send(message)` | un socket | respuestas y avisos por conexión | +| `server.broadcast(message)` | todos los sockets conectados | casos raros: avisos de mantenimiento, recargas forzadas | + +Las suscripciones suelen ser lo que quieres, porque el cliente dice lo que le interesa y el servidor solo envía eso. + +```mermaid +sequenceDiagram + participant C as Cliente + participant S as servidor hapi + C->>S: connect (hello, con auth) + S-->>C: connected + C->>S: subscribe /rooms/lobby + Note over S: subscription('/rooms/{id}') coincide,
se comprueban auth y scope + S-->>C: subscribed + Note over S: una ruta llama a publish('/rooms/lobby', message) + Note over S: filter(path, message, { socket, credentials })
se ejecuta una vez por suscriptor + S-->>C: update +``` + +El `filter` de una suscripción decide, para cada socket, si se envía la actualización. Pasa en `internal` los datos que el filtro necesita pero que el cliente no debe ver: + +```ts +server.subscription('/documents/{id}', { + filter: (path, message, options) => { + return options.internal === options.credentials?.id; + }, +}); + +server.publish('/documents/42', { title: 'Q3 plan' }, { internal: '2133d32a' }); +``` + +Solo el propietario del documento recibe la actualización, y el id del propietario nunca llega a ningún cliente. El filtro compara con `credentials.id`, que pone la [strategy bearer](/tutorials/es_ES/auth#jwt) del tutorial de autenticación. + +El filtro se ejecuta para cada suscriptor en cada publicación, así que limítalo a comprobaciones en memoria. Para cambiar lo que ve cada usuario, el filtro puede devolver `{ override: message }` en lugar de `true`. + +## Autenticación + +nes no tiene autenticación propia. Reutiliza tus [strategies de autenticación](/tutorials/es_ES/auth) de hapi: el socket se autentica al conectarse, y todas las peticiones y suscripciones de ese socket usan esas credenciales. + +La opción `auth.type` del plugin decide cómo demuestra el cliente quién es: + +| `auth.type` | Cómo se autentica el cliente | Adecuado para | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `'direct'` (por defecto) | envía las cabeceras de autenticación HTTP normales dentro del mensaje de conexión: `client.connect({ auth: { headers: { authorization } } })` | APIs con bearer tokens o basic auth | +| `'token'` | primero llama a `GET /nes/auth` por HTTP, recibe un token sellado y lo pasa a `client.connect({ auth: token })` | clientes que no pueden depender de cookies, como las apps móviles | +| `'cookie'` | primero llama a `GET /nes/auth` por HTTP, que pone una cookie cifrada que el navegador envía en el upgrade a WebSocket | apps de navegador que ya usan sesiones con cookie | + +Con `'direct'`, nes pasa las cabeceras por la strategy de `auth.route`, o por la [strategy por defecto](/tutorials/es_ES/auth#default) del servidor cuando `auth.route` no está definido. Con la [strategy bearer](/tutorials/es_ES/auth#jwt) del tutorial de autenticación como strategy por defecto: + +```ts +const client = new Nes.Client('wss://api.example.com'); +await client.connect({ auth: { headers: { authorization: `Bearer ${token}` } } }); +``` + +- **Conectarse sin credenciales.** Si el modo de la strategy de autenticación es `'required'`, un cliente que se conecta sin `auth` se rechaza con `Connection requires authentication`. Si no, se conecta sin autenticar, y solo lo rechazan las rutas y suscripciones que necesitan autenticación. +- **Tokens que caducan.** La strategy de `@hapi/jwt` vuelve a comprobar el token mientras el socket está abierto, como mucho una vez por `auth.minAuthVerifyInterval` (por defecto, el intervalo de heartbeat). Cuando el token caduca, el servidor cierra el socket, la reconexión del cliente se rechaza con `Token maximum age exceeded` y el cliente deja de reintentar. Envía un token nuevo antes de que caduque el anterior: + + ```ts + await client.reauthenticate({ headers: { authorization: `Bearer ${freshToken}` } }); + ``` + + Las strategies cuyo scheme no tiene un método `verify()` mantienen las credenciales del momento de la conexión durante toda la vida del socket. + +- **Proteger las suscripciones.** Una suscripción sin opciones `auth` usa la strategy por defecto y su modo, pero no los scopes del `access` de la configuración por defecto. Esos scopes siguen protegiendo las peticiones por socket. Dale a cada suscripción protegida su propio `auth.scope`, que acepta cualquiera de los scopes de la lista; los prefijos `+` y `!` que admiten los [scopes de ruta](/tutorials/es_ES/auth#scopes) no están disponibles. +- **Define `auth.password` para `'token'` y `'cookie'`.** Sin él, nes elige una contraseña aleatoria al arrancar el proceso, así que los tokens y las cookies dejan de funcionar tras un reinicio, y una instancia no puede leer lo que selló otra. +- **Define `origin` con la autenticación por cookie.** Los upgrades a WebSocket no pasan ninguna comprobación CORS, y los subdominios hermanos cuentan como el mismo sitio, así que el `SameSite=Strict` de la cookie de nes no los detiene. `origin` rechaza los upgrades desde cualquier otro origen, incluidos los clientes que no envían cabecera `Origin`, como el cliente de Node.js de arriba. + +```ts +import Hapi from '@hapi/hapi'; +import Nes from '@hapi/nes'; + +import type { Nes as NesTypes } from '@hapi/nes'; + +const cookiePassword = process.env.NES_COOKIE_PASSWORD; +if (!cookiePassword || cookiePassword.length < 32) { + throw new Error('NES_COOKIE_PASSWORD must be set to at least 32 characters'); +} + +const server = Hapi.server({ port: 4000 }); + +const nesOptions = { + auth: { type: 'cookie', password: cookiePassword } satisfies NesTypes.PluginOptions['auth'], + origin: ['https://app.example.com'], +}; + +await server.register({ plugin: Nes, options: nesOptions }); +``` + +## Reconexiones y actualizaciones perdidas + +Las redes móviles cortan las conexiones a menudo, y el cliente se reconecta por sí solo: + +```mermaid +flowchart TD + A[conexión perdida] --> B["el cliente espera y se reconecta
(1s, 2s, 3s... hasta 5s entre intentos)"] + B --> C{"¿se acepta el
mensaje de conexión?"} + C -->|sí| D["suscripciones restauradas,
se llama a onConnect"] + C -->|"no: falla la autenticación"| E["el cliente deja de reintentar,
se llama a onDisconnect"] + D --> F["tu handler onConnect
vuelve a cargar el estado actual"] +``` + +- El cliente sigue reintentando con los intervalos de arriba hasta que se reconecta, salvo que limites `retries` en `client.connect()`. +- Al reconectarse, el cliente vuelve a enviar sus suscripciones, así que tus handlers siguen funcionando sin código adicional. +- **Las actualizaciones publicadas mientras el cliente estaba desconectado se pierden.** nes no las encola. Si perder una actualización importa, carga el estado actual en `onConnect`: + +```ts +const refreshRoom = async (room: string) => { + const { payload } = await client.request(`/rooms/${room}/messages`); + render(payload); +}; + +client.onConnect = () => { + refreshRoom('lobby').catch((err) => console.error('Could not refresh the room', err)); +}; +``` + +- El servidor hace ping a cada cliente cada 15 segundos y lo desconecta si no responde en 5 segundos; el cliente se desconecta y se reconecta si dejan de llegar pings. Los balanceadores de carga y los proxies que cierran las conexiones inactivas necesitan un timeout de inactividad mayor que el intervalo de ping, o las conexiones se cortarán sin motivo aparente. + +## Escalar a varias instancias + +Dos instancias del servidor no comparten memoria. Una conexión WebSocket que mantiene la instancia A no existe en la instancia B, así que `server.publish()` o `server.broadcast()` en A solo llega a los clientes conectados a A. Con dos instancias detrás de un balanceador de carga, un mensaje enviado a A nunca llega a un cliente conectado a B. Esto vale para cualquier librería de WebSocket y cualquier proveedor de hosting: cada proceso solo puede escribir en las conexiones que tiene. + +Escalar a varias instancias requiere pasar mensajes entre ellas. Un backend de pub/sub como Redis, NATS o `LISTEN`/`NOTIFY` de Postgres reparte cada mensaje a todas las instancias, y cada instancia lo entrega a sus propias conexiones: + +```mermaid +flowchart TD + P["se crea un mensaje
en la instancia A"] -->|publica| R[("broker
Redis, NATS, Postgres")] + R -->|reparte| A[instancia A] + R -->|reparte| B[instancia B] + A --> CA[clientes en A] + B --> CB[clientes en B] +``` + +Con Redis: + +```ts +import { createClient } from 'redis'; + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('chat', (raw) => { + const message: ChatMessage = JSON.parse(raw); + server.publish(`/rooms/${message.room}`, message); +}); + +const publishToRoom = async (message: ChatMessage) => { + await publisher.publish('chat', JSON.stringify(message)); +}; +``` + +La ruta `POST` llama a `publishToRoom()` en lugar de a `server.publish()`, y todas las instancias, incluida la que atendió la petición, publican en sus propios sockets. + +- Las conexiones WebSocket se quedan en la instancia a la que se conectaron, así que el socket en sí no necesita sticky sessions. Para los tipos de autenticación `'token'` y `'cookie'`, define el mismo `auth.password` en todas las instancias, ya que la petición a `/nes/auth` puede llegar a una instancia distinta de la del socket. +- `server.eachSocket()` también es local al proceso. + +## Limpieza + +El estado de cada conexión vive tanto como el socket. Libéralo cuando el socket desaparece: + +- `onDisconnection` en las opciones del plugin se ejecuta cuando se cierra cualquier socket. +- `onUnsubscribe` en las opciones de `server.subscription()` se ejecuta cuando un cliente cancela la suscripción o se desconecta, y es el sitio para detener el trabajo que se hace solo para ese suscriptor, como un bucle de polling. +- `server.stop()` cierra todos los sockets. Los clientes ven una desconexión y empiezan a reconectarse, que es lo que quieres durante un despliegue gradual: vuelven en otra instancia. + +## TypeScript + +nes incluye sus propios tipos para el plugin y el cliente. Importar `@hapi/nes` añade `server.subscription()`, `server.publish()`, `server.broadcast()`, `server.eachSocket()` y `request.socket` a los tipos de hapi. `client.request()` recibe el tipo del payload como argumento de tipo, como en `client.request(path)`; no se comprueba en tiempo de ejecución, así que valida los datos que no controlas. + +## Siguientes pasos + +- [Referencia de la API de @hapi/nes](/module/nes): todas las opciones del plugin, la [autenticación del socket](/module/nes#socket-auth) y la API completa del cliente. +- [Server-Sent Events](/tutorials/es_ES/sse) cuando el cliente solo escucha. +- [Autenticación](/tutorials/es_ES/auth) para las strategies y los scopes que reutiliza nes. diff --git a/docs/tutorials/ko_KR/websockets.md b/docs/tutorials/ko_KR/websockets.md new file mode 100644 index 00000000..4c9eea5e --- /dev/null +++ b/docs/tutorials/ko_KR/websockets.md @@ -0,0 +1,310 @@ +--- +title: WebSocket +--- + + + +# WebSocket + +_이 튜토리얼은 hapi v21 이상과 호환됩니다_ + +## 개요 + +[@hapi/nes](/module/nes)는 hapi 서버에 WebSocket 엔드포인트를 추가합니다. 연결된 클라이언트는 소켓 하나로 세 가지 일을 할 수 있습니다. + +- **라우트 호출.** `client.request()`는 검증, 인증 스코프, 핸들러를 갖춘 일반 hapi 라우트를 실행하고 응답을 소켓으로 돌려줍니다. +- **업데이트 구독.** 클라이언트가 `/rooms/lobby` 같은 경로를 구독하면 서버가 그 경로로 발행합니다. +- **푸시 수신.** 서버가 소켓 하나에 메시지를 보내거나 모든 소켓에 브로드캐스트합니다. + +## WebSocket을 언제 쓸까 + +| 방법 | 방향 | 적합한 용도 | +| ------------------------------------------ | ----------------------------- | ------------------------------------------------------------------- | +| 일반 라우트 폴링 | 클라이언트가 묻고 서버가 답함 | 몇 분마다 바뀌는 데이터, 또는 실시간이어도 얻는 것이 없을 때 | +| [Server-Sent Events](/tutorials/ko_KR/sse) | 서버에서 클라이언트로만 | 실시간 피드, 진행률, 알림. 평범한 HTTP라서 대부분의 프록시를 통과함 | +| nes를 사용한 WebSocket | 양방향, 연결 하나 | 채팅, 협업, 게임: 클라이언트가 자주 보내고 푸시를 돌려받기를 기대함 | + +클라이언트가 듣기만 한다면 SSE가 운영하기 더 간단합니다. 클라이언트도 자주 보내거나, 요청과 업데이트를 연결 하나로 처리하고 싶다면 WebSocket을 고르세요. 모바일 네트워크에서는 이 차이가 중요합니다. + +## 설정 + +```sh +npm install @hapi/nes +``` + +채팅방 서버입니다. 메시지는 라우트로 올리고, 그 방의 모든 구독자가 메시지를 받으며, 두 번째 라우트는 최근 이력을 반환합니다. + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; +import Nes from '@hapi/nes'; + +interface ChatMessage { + room: string; + text: string; + at: string; +} + +const history = new Map(); + +const server = Hapi.server({ port: 4000 }); + +await server.register(Nes); + +server.subscription('/rooms/{id}'); + +const roomParams = Joi.object({ id: Joi.string().alphanum().max(50).required() }); + +server.route<{ Params: { id: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{id}/messages', + options: { + validate: { + params: roomParams, + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: (request, h) => { + const message: ChatMessage = { + room: request.params.id, + text: request.payload.text, + at: new Date().toISOString(), + }; + + const messages = history.get(message.room) ?? []; + history.set(message.room, [...messages, message].slice(-50)); + + server.publish(`/rooms/${message.room}`, message); + return h.response(message).code(201); + }, +}); + +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/rooms/{id}/messages', + options: { validate: { params: roomParams } }, + handler: (request, h) => history.get(request.params.id) ?? [], +}); + +await server.start(); +``` + +nes는 HTTP 라우트와 같은 포트에서 수신합니다. 라우트는 평범한 HTTP로도 동작하므로, 다른 서비스는 소켓을 열지 않고도 방에 메시지를 올릴 수 있습니다. `POST` 라우트는 원본 페이로드가 아니라 [검증](/tutorials/ko_KR/validation)한 필드만 발행합니다. + +Node.js 클라이언트는 다음과 같습니다. + +```ts +import Nes from '@hapi/nes'; + +const client = new Nes.Client('ws://localhost:4000'); +await client.connect(); + +await client.subscribe('/rooms/lobby', (message) => { + console.log('lobby:', message); +}); + +await client.request({ method: 'POST', path: '/rooms/lobby/messages', payload: { text: 'hello' } }); +``` + +브라우저에서는 번들이 서버 플러그인까지 끌어오지 않도록 클라이언트 파일만 따로 import하세요. + +```ts +import { Client } from '@hapi/nes/lib/client.js'; + +const client = new Client(`wss://${location.host}`); +await client.connect(); +``` + +구독 핸들러는 메시지를 `unknown`으로 받습니다. 서버는 무엇이든 발행할 수 있으므로, `fetch()` 응답을 다룰 때처럼 사용하기 전에 형태를 확인하세요. + +## 소켓으로 라우트 호출하기 + +`client.request()`는 검증, `auth.access` 스코프, 확장, 핸들러로 이어지는 hapi [요청 라이프사이클](/api#request-lifecycle) 전체를 거쳐 라우트를 실행합니다. nes는 소켓이 연결할 때 인증한 자격 증명을 넘겨 [`server.inject()`](/api#await-server-inject-options)로 이를 처리합니다. 요청 옵션과 응답 형태는 [`client.request()`](/module/nes#await-client-request-options)에 있습니다. + +핸들러 안에서 `request.socket`은 소켓 요청이면 nes 소켓이고 일반 HTTP 요청이면 `null`이므로, 사용하기 전에 확인하세요. + +클라이언트가 이미 연결을 가지고 있고 HTTP 왕복을 한 번 더 하고 싶지 않을 때 소켓 요청을 사용하세요. 주로 듣기만 하는 페이지라면 일반 HTTP 요청과 구독을 함께 쓰는 편이 디버깅하기 쉽습니다. + +## 업데이트 푸시하기 + +nes는 클라이언트에 메시지를 보내는 방법을 세 가지 제공합니다. + +| 메서드 | 받는 쪽 | 용도 | +| ------------------------------- | --------------------------- | ---------------------------------- | +| `server.publish(path, message)` | 일치하는 경로를 구독한 소켓 | 거의 모든 경우: 채팅방, 문서, 피드 | +| `socket.send(message)` | 소켓 하나 | 답장과 연결별 알림 | +| `server.broadcast(message)` | 연결된 모든 소켓 | 드묾: 점검 경고, 강제 새로고침 | + +보통은 구독이 원하는 방법입니다. 클라이언트가 관심 있는 것을 말하고, 서버는 그것만 보내기 때문입니다. + +```mermaid +sequenceDiagram + participant C as 클라이언트 + participant S as hapi 서버 + C->>S: connect (hello, 인증 포함) + S-->>C: 연결됨 + C->>S: subscribe /rooms/lobby + Note over S: subscription('/rooms/{id}')가 일치,
인증과 스코프 검사 + S-->>C: 구독됨 + Note over S: 라우트가 publish('/rooms/lobby', message) 호출 + Note over S: filter(path, message, { socket, credentials })
구독자마다 한 번 실행 + S-->>C: update +``` + +구독의 `filter`는 소켓마다 업데이트를 보낼지 정합니다. 필터에는 필요하지만 클라이언트에게는 보이면 안 되는 데이터는 `internal`로 전달하세요. + +```ts +server.subscription('/documents/{id}', { + filter: (path, message, options) => { + return options.internal === options.credentials?.id; + }, +}); + +server.publish('/documents/42', { title: 'Q3 plan' }, { internal: '2133d32a' }); +``` + +문서 소유자만 업데이트를 받고, 소유자 id는 어떤 클라이언트에게도 전달되지 않습니다. 필터는 `credentials.id`와 비교하는데, 이 값은 인증 튜토리얼의 [bearer 전략](/tutorials/ko_KR/auth#jwt)이 설정합니다. + +필터는 발행할 때마다 모든 구독자에 대해 실행되므로, 메모리 안에서 끝나는 검사만 하세요. 사용자마다 보이는 내용을 바꾸려면 필터가 `true` 대신 `{ override: message }`를 반환하면 됩니다. + +## 인증 + +nes에는 자체 인증이 없습니다. hapi의 [인증 전략](/tutorials/ko_KR/auth)을 재사용합니다. 소켓은 연결할 때 인증하고, 그 소켓의 모든 요청과 구독은 그 자격 증명을 사용합니다. + +플러그인의 `auth.type` 옵션은 클라이언트가 자신을 증명하는 방식을 정합니다. + +| `auth.type` | 클라이언트의 인증 방식 | 적합한 경우 | +| ------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| `'direct'` (기본값) | connect 메시지 안에 일반 HTTP 인증 헤더를 담아 보냄: `client.connect({ auth: { headers: { authorization } } })` | bearer 토큰이나 basic 인증을 쓰는 API | +| `'token'` | 먼저 HTTP로 `GET /nes/auth`를 호출해 봉인된 토큰을 받고, 이를 `client.connect({ auth: token })`에 전달 | 모바일 앱처럼 쿠키에 의존할 수 없는 클라이언트 | +| `'cookie'` | 먼저 HTTP로 `GET /nes/auth`를 호출하면 암호화된 쿠키가 설정되고, 브라우저가 WebSocket 업그레이드 때 이 쿠키를 보냄 | 이미 쿠키 세션을 쓰는 브라우저 앱 | + +`'direct'`에서 nes는 헤더를 `auth.route`의 전략으로 처리하고, `auth.route`가 없으면 서버의 [기본 전략](/tutorials/ko_KR/auth#default)으로 처리합니다. 인증 튜토리얼의 [bearer 전략](/tutorials/ko_KR/auth#jwt)을 기본값으로 쓰면 다음과 같습니다. + +```ts +const client = new Nes.Client('wss://api.example.com'); +await client.connect({ auth: { headers: { authorization: `Bearer ${token}` } } }); +``` + +- **자격 증명 없이 연결하기.** 인증 전략의 모드가 `'required'`이면 `auth` 없이 연결한 클라이언트는 `Connection requires authentication`으로 거부됩니다. 그렇지 않으면 인증되지 않은 상태로 연결되고, 인증이 필요한 라우트와 구독만 이를 거부합니다. +- **만료되는 토큰.** `@hapi/jwt` 전략은 소켓이 열려 있는 동안 `auth.minAuthVerifyInterval`(기본값은 heartbeat 간격)마다 최대 한 번 토큰을 다시 검사합니다. 토큰이 만료되면 서버가 소켓을 닫고, 클라이언트의 재연결은 `Token maximum age exceeded`로 거부되며, 클라이언트는 재시도를 멈춥니다. 기존 토큰이 만료되기 전에 새 토큰을 보내세요. + + ```ts + await client.reauthenticate({ headers: { authorization: `Bearer ${freshToken}` } }); + ``` + + 스킴에 `verify()` 메서드가 없는 전략은 소켓이 살아 있는 동안 연결 시점의 자격 증명을 그대로 유지합니다. + +- **구독 보호하기.** `auth` 옵션이 없는 구독은 기본 전략과 그 모드를 사용하지만, 기본값의 `access`에 있는 스코프는 사용하지 않습니다. 그 스코프는 여전히 소켓 요청을 보호합니다. 보호해야 하는 구독마다 `auth.scope`를 따로 지정하세요. 나열한 스코프 중 하나만 있으면 통과하며, [라우트 스코프](/tutorials/ko_KR/auth#scopes)가 지원하는 `+`와 `!` 접두사는 쓸 수 없습니다. +- **`'token'`과 `'cookie'`에는 `auth.password`를 설정하세요.** 설정하지 않으면 nes가 프로세스가 시작될 때 무작위 비밀번호를 고르므로, 재시작하면 토큰과 쿠키가 더 이상 동작하지 않고 한 인스턴스가 봉인한 것을 다른 인스턴스가 읽을 수 없습니다. +- **쿠키 인증에는 `origin`을 설정하세요.** WebSocket 업그레이드는 CORS 검사를 받지 않고, 형제 서브도메인은 같은 사이트로 취급되므로 nes 쿠키의 `SameSite=Strict`로는 이를 막을 수 없습니다. `origin`은 다른 출처의 업그레이드를 거부하며, 위의 Node.js 클라이언트처럼 `Origin` 헤더를 보내지 않는 클라이언트도 거부합니다. + +```ts +import Hapi from '@hapi/hapi'; +import Nes from '@hapi/nes'; + +import type { Nes as NesTypes } from '@hapi/nes'; + +const cookiePassword = process.env.NES_COOKIE_PASSWORD; +if (!cookiePassword || cookiePassword.length < 32) { + throw new Error('NES_COOKIE_PASSWORD must be set to at least 32 characters'); +} + +const server = Hapi.server({ port: 4000 }); + +const nesOptions = { + auth: { type: 'cookie', password: cookiePassword } satisfies NesTypes.PluginOptions['auth'], + origin: ['https://app.example.com'], +}; + +await server.register({ plugin: Nes, options: nesOptions }); +``` + +## 재연결과 놓친 업데이트 + +모바일 네트워크는 연결이 자주 끊기며, 클라이언트는 알아서 다시 연결합니다. + +```mermaid +flowchart TD + A[연결 끊김] --> B["클라이언트가 기다린 뒤 재연결
(1초, 2초, 3초... 최대 5초 간격)"] + B --> C{"connect 메시지가
수락되었나?"} + C -->|예| D["구독 복원,
onConnect 호출"] + C -->|"아니요: 인증 실패"| E["클라이언트가 재시도 중단,
onDisconnect 호출"] + D --> F["여러분의 onConnect 핸들러가
현재 상태를 다시 불러옴"] +``` + +- 클라이언트는 다시 연결될 때까지 위의 간격으로 계속 재시도합니다. `client.connect()`에서 `retries`로 횟수를 제한할 수 있습니다. +- 재연결하면 클라이언트가 구독을 다시 보내므로, 코드를 추가하지 않아도 핸들러가 계속 동작합니다. +- **클라이언트의 연결이 끊긴 동안 발행된 업데이트는 사라집니다.** nes는 이를 큐에 쌓아 두지 않습니다. 업데이트를 놓치면 안 된다면 `onConnect`에서 현재 상태를 불러오세요. + +```ts +const refreshRoom = async (room: string) => { + const { payload } = await client.request(`/rooms/${room}/messages`); + render(payload); +}; + +client.onConnect = () => { + refreshRoom('lobby').catch((err) => console.error('Could not refresh the room', err)); +}; +``` + +- 서버는 15초마다 각 클라이언트에 ping을 보내고, 5초 안에 응답하지 않으면 연결을 끊습니다. 클라이언트도 ping이 오지 않으면 연결을 끊고 다시 연결합니다. 유휴 연결을 닫는 로드 밸런서와 프록시는 유휴 타임아웃을 ping 간격보다 길게 잡아야 합니다. 그렇지 않으면 눈에 보이는 이유 없이 연결이 끊깁니다. + +## 여러 인스턴스로 확장하기 + +두 서버 인스턴스는 메모리를 공유하지 않습니다. 인스턴스 A가 가진 WebSocket 연결은 인스턴스 B에 존재하지 않으므로, A에서 호출한 `server.publish()`나 `server.broadcast()`는 A에 연결된 클라이언트에게만 갑니다. 로드 밸런서 뒤에 인스턴스가 두 개 있으면, A에 올린 메시지는 B에 연결된 클라이언트에게 절대 도착하지 않습니다. 이는 모든 WebSocket 라이브러리와 호스팅 업체에 해당합니다. 각 프로세스는 자기가 가진 연결에만 쓸 수 있습니다. + +여러 인스턴스로 확장하려면 인스턴스 사이에 메시지를 전달해야 합니다. Redis, NATS, Postgres `LISTEN`/`NOTIFY` 같은 pub/sub 백엔드가 각 메시지를 모든 인스턴스에 퍼뜨리고, 각 인스턴스는 이를 자기 연결에 전달합니다. + +```mermaid +flowchart TD + P["인스턴스 A에서
메시지가 생성됨"] -->|발행| R[("브로커
Redis, NATS, Postgres")] + R -->|팬아웃| A[인스턴스 A] + R -->|팬아웃| B[인스턴스 B] + A --> CA[A의 클라이언트] + B --> CB[B의 클라이언트] +``` + +Redis를 사용하면 다음과 같습니다. + +```ts +import { createClient } from 'redis'; + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('chat', (raw) => { + const message: ChatMessage = JSON.parse(raw); + server.publish(`/rooms/${message.room}`, message); +}); + +const publishToRoom = async (message: ChatMessage) => { + await publisher.publish('chat', JSON.stringify(message)); +}; +``` + +`POST` 라우트는 `server.publish()` 대신 `publishToRoom()`을 호출하고, 요청을 처리한 인스턴스를 포함해 모든 인스턴스가 각자의 소켓에 발행합니다. + +- WebSocket 연결은 처음 연결한 인스턴스에 머무르므로, 소켓 자체에는 sticky 세션이 필요 없습니다. `'token'`과 `'cookie'` 인증 방식에서는 `/nes/auth` 요청이 소켓과 다른 인스턴스로 갈 수 있으므로, 모든 인스턴스에 같은 `auth.password`를 설정하세요. +- `server.eachSocket()`도 같은 방식으로 프로세스 안에서만 동작합니다. + +## 정리하기 + +연결별 상태는 소켓이 살아 있는 동안 유지됩니다. 소켓이 사라지면 해제하세요. + +- 플러그인 옵션의 `onDisconnection`은 소켓이 닫힐 때마다 실행됩니다. +- `server.subscription()` 옵션의 `onUnsubscribe`는 클라이언트가 구독을 취소하거나 연결이 끊길 때 실행됩니다. 폴링 루프처럼 그 구독자만을 위해 하던 작업을 멈추기 좋은 곳입니다. +- `server.stop()`은 모든 소켓을 닫습니다. 클라이언트는 연결이 끊긴 것을 보고 재연결을 시작하는데, 롤링 배포 중에는 이것이 바로 원하는 동작입니다. 클라이언트가 다른 인스턴스로 돌아오기 때문입니다. + +## TypeScript + +nes는 플러그인과 클라이언트의 타입을 자체적으로 제공합니다. `@hapi/nes`를 import하면 hapi의 타입에 `server.subscription()`, `server.publish()`, `server.broadcast()`, `server.eachSocket()`, `request.socket`이 추가됩니다. `client.request()`는 `client.request(path)`처럼 페이로드 타입을 타입 인자로 받습니다. 이 타입은 런타임에 검사되지 않으므로, 여러분이 통제하지 않는 데이터는 검증하세요. + +## 다음 단계 + +- [@hapi/nes API 레퍼런스](/module/nes): 모든 플러그인 옵션, [소켓 인증](/module/nes#socket-auth), 클라이언트 API 전체. +- 클라이언트가 듣기만 할 때는 [Server-Sent Events](/tutorials/ko_KR/sse). +- nes가 재사용하는 전략과 스코프는 [인증](/tutorials/ko_KR/auth). diff --git a/docs/tutorials/pt_BR/websockets.md b/docs/tutorials/pt_BR/websockets.md new file mode 100644 index 00000000..7bdce6a6 --- /dev/null +++ b/docs/tutorials/pt_BR/websockets.md @@ -0,0 +1,310 @@ +--- +title: WebSockets +--- + + + +# WebSockets + +_Este tutorial é compatível com o hapi v21 e versões mais recentes_ + +## Visão geral + +O [@hapi/nes](/module/nes) adiciona um endpoint WebSocket a um servidor hapi. Um cliente conectado pode fazer três coisas por esse único socket: + +- **Chamar suas rotas.** `client.request()` executa uma rota normal do hapi, com sua validação, scopes de autenticação e handler, e devolve a resposta pelo socket. +- **Assinar atualizações.** O cliente assina um caminho como `/rooms/lobby`, e o servidor publica nele. +- **Receber envios do servidor.** O servidor manda uma mensagem para um socket ou faz broadcast para todos eles. + +## Quando usar WebSockets + +| Abordagem | Direção | Bom para | +| ------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------- | +| Polling de uma rota normal | o cliente pergunta, o servidor responde | atualizações a cada poucos minutos, ou quando tempo real não acrescenta nada | +| [Server-Sent Events](/tutorials/pt_BR/sse) | só do servidor para o cliente | feeds ao vivo, progresso, notificações; HTTP comum, funciona através da maioria dos proxies | +| WebSockets com nes | nos dois sentidos, uma conexão | chat, colaboração, jogos: os clientes enviam com frequência e esperam respostas do servidor | + +Se o cliente só escuta, SSE é mais simples de operar. Escolha WebSockets quando o cliente também envia com frequência, ou quando você quer uma única conexão para requisições e atualizações, o que faz diferença em redes móveis. + +## Configuração + +```sh +npm install @hapi/nes +``` + +Um servidor de sala de chat: as mensagens são postadas por uma rota, todos os assinantes da sala as recebem, e uma segunda rota retorna o histórico recente. + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; +import Nes from '@hapi/nes'; + +interface ChatMessage { + room: string; + text: string; + at: string; +} + +const history = new Map(); + +const server = Hapi.server({ port: 4000 }); + +await server.register(Nes); + +server.subscription('/rooms/{id}'); + +const roomParams = Joi.object({ id: Joi.string().alphanum().max(50).required() }); + +server.route<{ Params: { id: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{id}/messages', + options: { + validate: { + params: roomParams, + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: (request, h) => { + const message: ChatMessage = { + room: request.params.id, + text: request.payload.text, + at: new Date().toISOString(), + }; + + const messages = history.get(message.room) ?? []; + history.set(message.room, [...messages, message].slice(-50)); + + server.publish(`/rooms/${message.room}`, message); + return h.response(message).code(201); + }, +}); + +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/rooms/{id}/messages', + options: { validate: { params: roomParams } }, + handler: (request, h) => history.get(request.params.id) ?? [], +}); + +await server.start(); +``` + +O nes escuta na mesma porta das suas rotas HTTP. As rotas também funcionam por HTTP comum, então outros serviços podem postar em uma sala sem abrir um socket. A rota `POST` publica só os campos que ela [validou](/tutorials/pt_BR/validation), nunca o payload bruto. + +O cliente no Node.js: + +```ts +import Nes from '@hapi/nes'; + +const client = new Nes.Client('ws://localhost:4000'); +await client.connect(); + +await client.subscribe('/rooms/lobby', (message) => { + console.log('lobby:', message); +}); + +await client.request({ method: 'POST', path: '/rooms/lobby/messages', payload: { text: 'hello' } }); +``` + +No navegador, importe o arquivo do cliente sozinho para que o seu bundle não traga junto o plugin do servidor: + +```ts +import { Client } from '@hapi/nes/lib/client.js'; + +const client = new Client(`wss://${location.host}`); +await client.connect(); +``` + +Os handlers de assinatura recebem a mensagem como `unknown`. O servidor pode publicar qualquer coisa, então verifique o formato antes de usá-la, do mesmo jeito que faria com a resposta de um `fetch()`. + +## Chamando rotas pelo socket + +`client.request()` passa a rota por todo o [ciclo de vida da requisição](/api#request-lifecycle) do hapi: validação, scopes de `auth.access`, extensões e o handler. O nes faz isso com [`server.inject()`](/api#await-server-inject-options), passando as credenciais com que o socket se autenticou ao conectar. As opções da requisição e o formato da resposta estão em [`client.request()`](/module/nes#await-client-request-options). + +Dentro do handler, `request.socket` é o socket do nes nas requisições feitas pelo socket, e `null` nas requisições HTTP normais, então verifique-o antes de usar. + +Use requisições pelo socket quando o cliente já tem uma conexão aberta e você quer evitar uma segunda ida e volta HTTP. Para uma página que na maior parte do tempo só escuta, requisições HTTP normais mais uma assinatura são mais fáceis de depurar. + +## Enviando atualizações + +O nes oferece três formas de mandar uma mensagem para os clientes: + +| Método | Quem recebe | Use para | +| ------------------------------- | --------------------------------------------- | --------------------------------------------------------- | +| `server.publish(path, message)` | sockets que assinam um caminho correspondente | quase tudo: salas, documentos, feeds | +| `socket.send(message)` | um socket | respostas e avisos por conexão | +| `server.broadcast(message)` | todos os sockets conectados | casos raros: avisos de manutenção, recarregamento forçado | + +Assinaturas costumam ser o que você quer, porque o cliente diz o que lhe interessa e o servidor envia só isso. + +```mermaid +sequenceDiagram + participant C as Cliente + participant S as servidor hapi + C->>S: connect (hello, com auth) + S-->>C: conectado + C->>S: subscribe /rooms/lobby + Note over S: subscription('/rooms/{id}') corresponde,
auth e scope verificados + S-->>C: assinado + Note over S: uma rota chama publish('/rooms/lobby', message) + Note over S: filter(path, message, { socket, credentials })
roda uma vez por assinante + S-->>C: update +``` + +Um `filter` de assinatura decide, por socket, se a atualização é enviada. Passe em `internal` os dados de que o filtro precisa mas que o cliente não deve ver: + +```ts +server.subscription('/documents/{id}', { + filter: (path, message, options) => { + return options.internal === options.credentials?.id; + }, +}); + +server.publish('/documents/42', { title: 'Q3 plan' }, { internal: '2133d32a' }); +``` + +Só o dono do documento recebe a atualização, e o id do dono nunca chega a um cliente. O filtro compara com `credentials.id`, que a [strategy bearer](/tutorials/pt_BR/auth#jwt) do tutorial [Autenticação](/tutorials/pt_BR/auth) define. + +O filtro roda para cada assinante a cada publicação, então limite-o a verificações em memória. Para mudar o que cada usuário vê, o filtro pode retornar `{ override: message }` em vez de `true`. + +## Autenticação + +O nes não tem autenticação própria. Ele reaproveita as suas [strategies de autenticação](/tutorials/pt_BR/auth) do hapi: o socket se autentica ao conectar, e toda requisição e assinatura feita nesse socket usa essas credenciais. + +A opção `auth.type` do plugin decide como o cliente prova quem é: + +| `auth.type` | Como o cliente se autentica | Indicado para | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | +| `'direct'` (padrão) | envia headers de autenticação HTTP normais dentro da mensagem de conexão: `client.connect({ auth: { headers: { authorization } } })` | APIs com bearer tokens ou basic auth | +| `'token'` | chama `GET /nes/auth` por HTTP primeiro, recebe um token selado e o passa para `client.connect({ auth: token })` | clientes que não podem depender de cookies, como apps mobile | +| `'cookie'` | chama `GET /nes/auth` por HTTP primeiro, o que define um cookie criptografado que o navegador envia no upgrade do WebSocket | aplicações de navegador que já usam sessões por cookie | + +Com `'direct'`, o nes passa os headers pela strategy em `auth.route`, ou pela [strategy padrão](/tutorials/pt_BR/auth#default) do servidor quando `auth.route` não está definido. Com a [strategy bearer](/tutorials/pt_BR/auth#jwt) do tutorial [Autenticação](/tutorials/pt_BR/auth) como padrão: + +```ts +const client = new Nes.Client('wss://api.example.com'); +await client.connect({ auth: { headers: { authorization: `Bearer ${token}` } } }); +``` + +- **Conectar sem credenciais.** Se o mode da strategy de autenticação é `'required'`, um cliente que conecta sem `auth` é rejeitado com `Connection requires authentication`. Caso contrário, ele conecta sem autenticação, e só as rotas e assinaturas que exigem autenticação o recusam. +- **Tokens que expiram.** A strategy do `@hapi/jwt` verifica o token de novo enquanto o socket está aberto, no máximo uma vez a cada `auth.minAuthVerifyInterval` (por padrão, o intervalo de heartbeat). Quando o token expira, o servidor fecha o socket, a reconexão do cliente é rejeitada com `Token maximum age exceeded` e o cliente para de tentar. Envie um token novo antes que o antigo expire: + + ```ts + await client.reauthenticate({ headers: { authorization: `Bearer ${freshToken}` } }); + ``` + + Strategies cujo scheme não tem um método `verify()` mantêm as credenciais do momento da conexão durante toda a vida do socket. + +- **Protegendo assinaturas.** Uma assinatura sem opções `auth` usa a strategy padrão e o mode dela, mas não os scopes do `access` padrão. Esses continuam protegendo as requisições pelo socket. Dê a cada assinatura protegida o seu próprio `auth.scope`, que aceita qualquer um dos scopes listados; os prefixos `+` e `!` que os [scopes de rota](/tutorials/pt_BR/auth#scopes) suportam não estão disponíveis. +- **Defina `auth.password` para `'token'` e `'cookie'`.** Sem ele, o nes escolhe uma senha aleatória quando o processo inicia, então tokens e cookies param de funcionar depois de um restart, e uma instância não consegue ler o que outra instância selou. +- **Defina `origin` com autenticação por cookie.** Upgrades de WebSocket não passam por verificação de CORS, e subdomínios irmãos contam como o mesmo site, então o `SameSite=Strict` do cookie do nes não os impede. `origin` recusa upgrades vindos de qualquer outra origem, inclusive de clientes que não enviam o header `Origin`, como o cliente Node.js acima. + +```ts +import Hapi from '@hapi/hapi'; +import Nes from '@hapi/nes'; + +import type { Nes as NesTypes } from '@hapi/nes'; + +const cookiePassword = process.env.NES_COOKIE_PASSWORD; +if (!cookiePassword || cookiePassword.length < 32) { + throw new Error('NES_COOKIE_PASSWORD must be set to at least 32 characters'); +} + +const server = Hapi.server({ port: 4000 }); + +const nesOptions = { + auth: { type: 'cookie', password: cookiePassword } satisfies NesTypes.PluginOptions['auth'], + origin: ['https://app.example.com'], +}; + +await server.register({ plugin: Nes, options: nesOptions }); +``` + +## Reconexões e atualizações perdidas + +Redes móveis derrubam conexões com frequência, e o cliente reconecta sozinho: + +```mermaid +flowchart TD + A[conexão perdida] --> B["o cliente espera e reconecta
(1s, 2s, 3s... até 5s de intervalo)"] + B --> C{"mensagem de conexão
aceita?"} + C -->|sim| D["assinaturas restauradas,
onConnect chamado"] + C -->|"não: falha de auth"| E["o cliente para de tentar,
onDisconnect chamado"] + D --> F["seu handler onConnect
busca o estado atual de novo"] +``` + +- O cliente continua tentando com os intervalos acima até reconectar, a menos que você limite `retries` em `client.connect()`. +- Ao reconectar, o cliente reenvia suas assinaturas, então seus handlers continuam funcionando sem nenhum código extra. +- **As atualizações publicadas enquanto o cliente estava desconectado se perdem.** O nes não as enfileira. Se perder uma atualização importa, carregue o estado atual em `onConnect`: + +```ts +const refreshRoom = async (room: string) => { + const { payload } = await client.request(`/rooms/${room}/messages`); + render(payload); +}; + +client.onConnect = () => { + refreshRoom('lobby').catch((err) => console.error('Could not refresh the room', err)); +}; +``` + +- O servidor envia um ping para cada cliente a cada 15 segundos e o derruba se ele não responder em 5 segundos; o cliente desconecta e reconecta se os pings param de chegar. Balanceadores de carga e proxies que fecham conexões inativas precisam de um timeout de inatividade maior que o intervalo de ping, ou as conexões vão cair sem motivo aparente. + +## Escalando entre instâncias + +Duas instâncias do servidor não compartilham memória. Uma conexão WebSocket mantida pela instância A não existe na instância B, então `server.publish()` ou `server.broadcast()` em A chega só aos clientes conectados a A. Com duas instâncias atrás de um balanceador de carga, uma mensagem postada em A nunca chega a um cliente conectado a B. Isso vale para toda biblioteca de WebSocket e todo provedor de hospedagem: cada processo só consegue escrever nas conexões que ele mesmo mantém. + +Escalar horizontalmente exige troca de mensagens entre as instâncias. Um backend de pub/sub como Redis, NATS ou o `LISTEN`/`NOTIFY` do Postgres distribui cada mensagem para todas as instâncias, e cada instância a entrega às suas próprias conexões: + +```mermaid +flowchart TD + P["uma mensagem é criada
na instância A"] -->|publica| R[("broker
Redis, NATS, Postgres")] + R -->|distribui| A[instância A] + R -->|distribui| B[instância B] + A --> CA[clientes em A] + B --> CB[clientes em B] +``` + +Com Redis: + +```ts +import { createClient } from 'redis'; + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('chat', (raw) => { + const message: ChatMessage = JSON.parse(raw); + server.publish(`/rooms/${message.room}`, message); +}); + +const publishToRoom = async (message: ChatMessage) => { + await publisher.publish('chat', JSON.stringify(message)); +}; +``` + +A rota `POST` chama `publishToRoom()` em vez de `server.publish()`, e todas as instâncias, inclusive a que tratou a requisição, publicam nos seus próprios sockets. + +- As conexões WebSocket ficam na instância à qual se conectaram, então o socket em si não precisa de sticky sessions. Para os tipos de autenticação `'token'` e `'cookie'`, defina o mesmo `auth.password` em todas as instâncias, já que a requisição a `/nes/auth` pode cair em uma instância diferente da do socket. +- `server.eachSocket()` também é local ao processo. + +## Limpeza + +O estado por conexão vive enquanto o socket vive. Libere-o quando o socket for embora: + +- `onDisconnection` nas opções do plugin roda quando qualquer socket fecha. +- `onUnsubscribe` nas opções de `server.subscription()` roda quando um cliente cancela a assinatura ou desconecta, e é o lugar para parar o trabalho feito só para aquele assinante, como um loop de polling. +- `server.stop()` fecha todos os sockets. Os clientes veem uma desconexão e começam a reconectar, que é o que você quer durante um rolling deploy: eles voltam em outra instância. + +## TypeScript + +O nes traz seus próprios tipos para o plugin e para o cliente. Importar `@hapi/nes` adiciona `server.subscription()`, `server.publish()`, `server.broadcast()`, `server.eachSocket()` e `request.socket` aos tipos do hapi. `client.request()` recebe o tipo do payload como argumento de tipo, como em `client.request(path)`; ele não é verificado em tempo de execução, então valide os dados que você não controla. + +## Próximos passos + +- [Referência da API do @hapi/nes](/module/nes): todas as opções do plugin, [autenticação do socket](/module/nes#socket-auth) e a API completa do cliente. +- [Server-Sent Events](/tutorials/pt_BR/sse) quando o cliente só escuta. +- [Autenticação](/tutorials/pt_BR/auth) para as strategies e scopes que o nes reaproveita. diff --git a/docs/tutorials/tr_TR/websockets.md b/docs/tutorials/tr_TR/websockets.md new file mode 100644 index 00000000..813ba204 --- /dev/null +++ b/docs/tutorials/tr_TR/websockets.md @@ -0,0 +1,310 @@ +--- +title: WebSocket'ler +--- + + + +# WebSocket'ler + +_Bu eğitim hapi v21 ve sonraki sürümlerle uyumludur_ + +## Genel Bakış + +[@hapi/nes](/module/nes), bir hapi sunucusuna WebSocket endpoint'i ekler. Bağlı bir istemci bu tek soket üzerinden üç şey yapabilir: + +- **Route'larını çağırmak.** `client.request()`, doğrulaması, auth scope'ları ve handler'ıyla normal bir hapi route'unu çalıştırır ve yanıtı soket üzerinden döndürür. +- **Güncellemelere abone olmak.** İstemci `/rooms/lobby` gibi bir path'e abone olur, sunucu da o path'e yayın yapar. +- **Push mesajları almak.** Sunucu tek bir sokete mesaj gönderir ya da tüm soketlere broadcast yapar. + +## WebSocket ne zaman kullanılır + +| Yaklaşım | Yön | Uygun olduğu yer | +| ------------------------------------------ | ------------------------------ | -------------------------------------------------------------------------------------------- | +| Normal bir route'u polling ile yoklamak | istemci sorar, sunucu yanıtlar | birkaç dakikada bir gelen güncellemeler ya da gerçek zamanlılığın bir şey katmadığı durumlar | +| [Server-Sent Events](/tutorials/tr_TR/sse) | yalnızca sunucudan istemciye | canlı akışlar, ilerleme, bildirimler; düz HTTP, çoğu proxy'nin arkasında çalışır | +| nes ile WebSocket'ler | iki yönlü, tek bağlantı | sohbet, ortak çalışma, oyunlar: istemciler sık gönderir ve karşılığında push bekler | + +İstemci yalnızca dinliyorsa SSE'yi işletmek daha basittir. İstemci de sık mesaj gönderiyorsa ya da hem istekler hem güncellemeler için tek bir bağlantı istiyorsan WebSocket'i seç; bu, mobil ağlarda önemlidir. + +## Kurulum + +```sh +npm install @hapi/nes +``` + +Bir sohbet odası sunucusu: mesajlar bir route üzerinden gönderilir, odanın her abonesi onları alır ve ikinci bir route son mesaj geçmişini döndürür. + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; +import Nes from '@hapi/nes'; + +interface ChatMessage { + room: string; + text: string; + at: string; +} + +const history = new Map(); + +const server = Hapi.server({ port: 4000 }); + +await server.register(Nes); + +server.subscription('/rooms/{id}'); + +const roomParams = Joi.object({ id: Joi.string().alphanum().max(50).required() }); + +server.route<{ Params: { id: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{id}/messages', + options: { + validate: { + params: roomParams, + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: (request, h) => { + const message: ChatMessage = { + room: request.params.id, + text: request.payload.text, + at: new Date().toISOString(), + }; + + const messages = history.get(message.room) ?? []; + history.set(message.room, [...messages, message].slice(-50)); + + server.publish(`/rooms/${message.room}`, message); + return h.response(message).code(201); + }, +}); + +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/rooms/{id}/messages', + options: { validate: { params: roomParams } }, + handler: (request, h) => history.get(request.params.id) ?? [], +}); + +await server.start(); +``` + +nes, HTTP route'larınla aynı portu dinler. Route'lar düz HTTP üzerinden de çalışır, böylece diğer servisler soket açmadan bir odaya mesaj gönderebilir. `POST` route'u yalnızca [doğruladığı](/tutorials/tr_TR/validation) alanları yayınlar, ham payload'u asla yayınlamaz. + +Node.js'teki istemci: + +```ts +import Nes from '@hapi/nes'; + +const client = new Nes.Client('ws://localhost:4000'); +await client.connect(); + +await client.subscribe('/rooms/lobby', (message) => { + console.log('lobby:', message); +}); + +await client.request({ method: 'POST', path: '/rooms/lobby/messages', payload: { text: 'hello' } }); +``` + +Tarayıcıda, bundle'ın sunucu plugin'ini de içine almasın diye istemci dosyasını tek başına import et: + +```ts +import { Client } from '@hapi/nes/lib/client.js'; + +const client = new Client(`wss://${location.host}`); +await client.connect(); +``` + +Abonelik handler'ları mesajı `unknown` olarak alır. Sunucu her şeyi yayınlayabilir, bu yüzden bir `fetch()` yanıtında yapacağın gibi kullanmadan önce yapısını denetle. + +## Route'ları soket üzerinden çağırmak + +`client.request()`, route'u hapi'nin tüm [istek yaşam döngüsünden](/api#request-lifecycle) geçirir: doğrulama, `auth.access` scope'ları, extension'lar ve handler. nes bunu, soketin bağlanırken kimlik doğruladığı kimlik bilgilerini vererek [`server.inject()`](/api#await-server-inject-options) ile yapar. İstek seçenekleri ve yanıtın yapısı [`client.request()`](/module/nes#await-client-request-options) sayfasında. + +Handler içinde `request.socket`, soket istekleri için nes soketidir, normal HTTP istekleri için ise `null` olur; bu yüzden kullanmadan önce denetle. + +Bir istemcinin zaten açık bir bağlantısı varsa ve ikinci bir HTTP gidiş dönüşünden kaçınmak istiyorsan soket isteklerini kullan. Çoğunlukla dinleyen bir sayfada normal HTTP istekleri ile bir aboneliğin birlikte kullanımında hata ayıklamak daha kolaydır. + +## Güncellemeleri göndermek + +nes istemcilere mesaj göndermenin üç yolunu sunar: + +| Metot | Kim alır | Ne için kullanılır | +| ------------------------------- | -------------------------------------- | ---------------------------------------------------- | +| `server.publish(path, message)` | eşleşen bir path'e abone olan soketler | neredeyse her şey: odalar, belgeler, akışlar | +| `socket.send(message)` | tek bir soket | yanıtlar ve bağlantıya özel bildirimler | +| `server.broadcast(message)` | bağlı her soket | nadiren: bakım uyarıları, zorunlu yeniden yüklemeler | + +Genellikle istediğin şey aboneliklerdir, çünkü istemci neyle ilgilendiğini söyler ve sunucu yalnızca onu gönderir. + +```mermaid +sequenceDiagram + participant C as İstemci + participant S as hapi sunucusu + C->>S: connect (hello, auth ile) + S-->>C: bağlandı + C->>S: subscribe /rooms/lobby + Note over S: subscription('/rooms/{id}') eşleşir,
auth ve scope denetlenir + S-->>C: abone olundu + Note over S: bir route publish('/rooms/lobby', message) çağırır + Note over S: filter(path, message, { socket, credentials })
her abone için bir kez çalışır + S-->>C: güncelleme +``` + +Bir abonelik `filter` fonksiyonu, güncellemenin gönderilip gönderilmeyeceğine soket başına karar verir. Filtrenin ihtiyaç duyduğu ama istemcinin görmemesi gereken veriyi `internal` içinde ver: + +```ts +server.subscription('/documents/{id}', { + filter: (path, message, options) => { + return options.internal === options.credentials?.id; + }, +}); + +server.publish('/documents/42', { title: 'Q3 plan' }, { internal: '2133d32a' }); +``` + +Güncellemeyi yalnızca belgenin sahibi alır ve sahip id'si hiçbir istemciye ulaşmaz. Filtre, Kimlik Doğrulama eğitimindeki [bearer stratejisinin](/tutorials/tr_TR/auth#jwt) ayarladığı `credentials.id` ile karşılaştırma yapar. + +Filtre her yayında her abone için çalışır, bu yüzden onu bellek içi denetimlerle sınırlı tut. Her kullanıcının gördüğünü değiştirmek için filtre `true` yerine `{ override: message }` döndürebilir. + +## Kimlik Doğrulama + +nes'in kendi kimlik doğrulaması yoktur. hapi [auth stratejilerini](/tutorials/tr_TR/auth) yeniden kullanır: soket bağlanırken kimliğini doğrular ve o soket üzerindeki her istek ve abonelik bu kimlik bilgilerini kullanır. + +Plugin'in `auth.type` seçeneği, istemcinin kimliğini nasıl kanıtlayacağını belirler: + +| `auth.type` | İstemci kimliğini nasıl doğrular | Uygun olduğu yer | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `'direct'` (varsayılan) | normal HTTP auth header'larını connect mesajının içinde gönderir: `client.connect({ auth: { headers: { authorization } } })` | bearer token ya da basic auth kullanan API'ler | +| `'token'` | önce HTTP üzerinden `GET /nes/auth` çağırır, mühürlü bir token alır ve onu `client.connect({ auth: token })` içine verir | mobil uygulamalar gibi cookie'lere güvenemeyen istemciler | +| `'cookie'` | önce HTTP üzerinden `GET /nes/auth` çağırır; bu, tarayıcının WebSocket upgrade isteğinde gönderdiği şifrelenmiş bir cookie ayarlar | zaten cookie oturumları kullanan tarayıcı uygulamaları | + +`'direct'` ile nes header'ları `auth.route` içindeki stratejiden, `auth.route` ayarlanmamışsa sunucunun [varsayılan stratejisinden](/tutorials/tr_TR/auth#default) geçirir. Kimlik Doğrulama eğitimindeki [bearer stratejisi](/tutorials/tr_TR/auth#jwt) varsayılan olduğunda: + +```ts +const client = new Nes.Client('wss://api.example.com'); +await client.connect({ auth: { headers: { authorization: `Bearer ${token}` } } }); +``` + +- **Kimlik bilgisi olmadan bağlanmak.** Auth stratejisinin modu `'required'` ise `auth` olmadan bağlanan bir istemci `Connection requires authentication` hatasıyla reddedilir. Aksi halde kimliği doğrulanmadan bağlanır ve onu yalnızca auth gerektiren route'lar ve abonelikler reddeder. +- **Süresi dolan token'lar.** `@hapi/jwt` stratejisi soket açıkken token'ı yeniden denetler; bunu en fazla `auth.minAuthVerifyInterval` (varsayılan olarak heartbeat aralığı) başına bir kez yapar. Token'ın süresi dolduğunda sunucu soketi kapatır, istemcinin yeniden bağlanma denemesi `Token maximum age exceeded` ile reddedilir ve istemci denemeyi bırakır. Eski token'ın süresi dolmadan yenisini gönder: + + ```ts + await client.reauthenticate({ headers: { authorization: `Bearer ${freshToken}` } }); + ``` + + Şemasında `verify()` metodu olmayan stratejiler, bağlantı anındaki kimlik bilgilerini soket açık kaldığı sürece korur. + +- **Abonelikleri korumak.** `auth` seçenekleri olmayan bir abonelik varsayılan stratejiyi ve onun modunu kullanır, ama varsayılanın `access` içindeki scope'larını kullanmaz. Bu scope'lar soket isteklerini yine korur. Korunan her aboneliğe kendi `auth.scope` değerini ver; bu değer listelenen scope'lardan herhangi birini kabul eder. [Route scope'larının](/tutorials/tr_TR/auth#scopes) desteklediği `+` ve `!` önekleri burada kullanılamaz. +- **`'token'` ve `'cookie'` için `auth.password` ayarla.** Ayarlanmazsa nes süreç başlarken rastgele bir parola seçer; böylece token'lar ve cookie'ler yeniden başlatmadan sonra çalışmaz ve bir instance, başka bir instance'ın mühürlediğini okuyamaz. +- **Cookie auth ile `origin` ayarla.** WebSocket upgrade istekleri CORS denetiminden geçmez ve kardeş alt alan adları aynı site sayılır; bu yüzden nes cookie'sinin `SameSite=Strict` özelliği onları durdurmaz. `origin`, yukarıdaki Node.js istemcisi gibi hiç `Origin` header'ı göndermeyen istemciler dahil, başka herhangi bir origin'den gelen upgrade isteklerini reddeder. + +```ts +import Hapi from '@hapi/hapi'; +import Nes from '@hapi/nes'; + +import type { Nes as NesTypes } from '@hapi/nes'; + +const cookiePassword = process.env.NES_COOKIE_PASSWORD; +if (!cookiePassword || cookiePassword.length < 32) { + throw new Error('NES_COOKIE_PASSWORD must be set to at least 32 characters'); +} + +const server = Hapi.server({ port: 4000 }); + +const nesOptions = { + auth: { type: 'cookie', password: cookiePassword } satisfies NesTypes.PluginOptions['auth'], + origin: ['https://app.example.com'], +}; + +await server.register({ plugin: Nes, options: nesOptions }); +``` + +## Yeniden bağlanma ve kaçırılan güncellemeler + +Mobil ağlar bağlantıları sık sık düşürür ve istemci kendiliğinden yeniden bağlanır: + +```mermaid +flowchart TD + A[bağlantı koptu] --> B["istemci bekler, sonra yeniden bağlanır
(1s, 2s, 3s... en fazla 5s arayla)"] + B --> C{"connect mesajı
kabul edildi mi?"} + C -->|evet| D["abonelikler geri yüklenir,
onConnect çağrılır"] + C -->|"hayır: auth başarısız"| E["istemci denemeyi bırakır,
onDisconnect çağrılır"] + D --> F["onConnect handler'ın
güncel durumu yeniden çeker"] +``` + +- `client.connect()` içinde `retries` ile sınırlamadıkça istemci, yukarıdaki gecikmelerle yeniden bağlanana kadar denemeye devam eder. +- Yeniden bağlandığında istemci aboneliklerini yeniden gönderir, böylece handler'ların hiç kod yazmadan çalışmaya devam eder. +- **İstemcinin bağlantısı kopukken yayınlanan güncellemeler kaybolur.** nes bunları kuyruğa almaz. Bir güncellemeyi kaçırmak önemliyse güncel durumu `onConnect` içinde yükle: + +```ts +const refreshRoom = async (room: string) => { + const { payload } = await client.request(`/rooms/${room}/messages`); + render(payload); +}; + +client.onConnect = () => { + refreshRoom('lobby').catch((err) => console.error('Could not refresh the room', err)); +}; +``` + +- Sunucu her istemciye 15 saniyede bir ping atar ve 5 saniye içinde yanıt vermeyen istemciyi düşürür; ping'ler gelmeyi bırakırsa istemci bağlantıyı keser ve yeniden bağlanır. Boşta kalan bağlantıları kapatan load balancer'ların ve proxy'lerin boşta kalma zaman aşımı ping aralığından uzun olmalıdır; yoksa bağlantılar görünür bir neden olmadan düşer. + +## Instance'lar arasında ölçekleme + +İki sunucu instance'ı belleği paylaşmaz. A instance'ının tuttuğu bir WebSocket bağlantısı B instance'ında yoktur; bu yüzden A'daki `server.publish()` ya da `server.broadcast()` yalnızca A'ya bağlı istemcilere ulaşır. Bir load balancer arkasındaki iki instance'la, A'ya gönderilen bir mesaj B'ye bağlı bir istemciye asla ulaşmaz. Bu her WebSocket kütüphanesi ve her hosting sağlayıcısı için geçerlidir: her süreç yalnızca kendi sahip olduğu bağlantılara yazabilir. + +Ölçeklemek için instance'lar arasında mesaj iletimi gerekir. Redis, NATS ya da Postgres `LISTEN`/`NOTIFY` gibi bir pub/sub backend'i her mesajı her instance'a dağıtır ve her instance mesajı kendi bağlantılarına iletir: + +```mermaid +flowchart TD + P["A instance'ında
bir mesaj oluşturulur"] -->|publish| R[("broker
Redis, NATS, Postgres")] + R -->|dağıtır| A["A instance'ı"] + R -->|dağıtır| B["B instance'ı"] + A --> CA["A'daki istemciler"] + B --> CB["B'deki istemciler"] +``` + +Redis ile: + +```ts +import { createClient } from 'redis'; + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('chat', (raw) => { + const message: ChatMessage = JSON.parse(raw); + server.publish(`/rooms/${message.room}`, message); +}); + +const publishToRoom = async (message: ChatMessage) => { + await publisher.publish('chat', JSON.stringify(message)); +}; +``` + +`POST` route'u `server.publish()` yerine `publishToRoom()` çağırır ve isteği işleyen de dahil her instance mesajı kendi soketlerine yayınlar. + +- WebSocket bağlantıları bağlandıkları instance'ta kalır, bu yüzden soketin kendisi sticky session gerektirmez. `'token'` ve `'cookie'` auth tiplerinde her instance'ta aynı `auth.password` değerini ayarla, çünkü `/nes/auth` isteği soketten farklı bir instance'a düşebilir. +- `server.eachSocket()` da aynı şekilde sürece özeldir. + +## Temizlik + +Bağlantı başına durum soket yaşadığı sürece yaşar. Soket gittiğinde onu serbest bırak: + +- Plugin seçeneklerindeki `onDisconnection`, herhangi bir soket kapandığında çalışır. +- `server.subscription()` seçeneklerindeki `onUnsubscribe`, bir istemci abonelikten çıktığında ya da bağlantısı koptuğunda çalışır; yalnızca o abone için yapılan işi, örneğin bir polling döngüsünü, durdurmanın yeri burasıdır. +- `server.stop()` her soketi kapatır. İstemciler bağlantının koptuğunu görür ve yeniden bağlanmaya başlar; rolling deploy sırasında istediğin de budur: başka bir instance'a geri bağlanırlar. + +## TypeScript + +nes, plugin ve istemci için kendi tipleriyle gelir. `@hapi/nes` import etmek hapi'nin tiplerine `server.subscription()`, `server.publish()`, `server.broadcast()`, `server.eachSocket()` ve `request.socket` ekler. `client.request()`, payload tipini `client.request(path)` örneğindeki gibi bir tip argümanı olarak alır; bu çalışma zamanında denetlenmez, bu yüzden kontrol etmediğin verileri doğrula. + +## Sonraki adımlar + +- [@hapi/nes API referansı](/module/nes): tüm plugin seçenekleri, [soket kimlik doğrulaması](/module/nes#socket-auth) ve istemci API'sinin tamamı. +- İstemci yalnızca dinliyorsa [Server-Sent Events](/tutorials/tr_TR/sse). +- nes'in yeniden kullandığı stratejiler ve scope'lar için [Kimlik Doğrulama](/tutorials/tr_TR/auth). diff --git a/docs/tutorials/zh_CN/websockets.md b/docs/tutorials/zh_CN/websockets.md new file mode 100644 index 00000000..e455ffc0 --- /dev/null +++ b/docs/tutorials/zh_CN/websockets.md @@ -0,0 +1,310 @@ +--- +title: WebSocket +--- + + + +# WebSocket + +_本教程适用于 hapi v21 及更高版本_ + +## 概述 + +[@hapi/nes](/module/nes) 为 hapi 服务器添加一个 WebSocket 端点。已连接的客户端可以通过这一个 socket 做三件事: + +- **调用你的路由。** `client.request()` 运行一条普通的 hapi 路由,包括它的验证、身份认证 scope 和 handler,并通过 socket 返回响应。 +- **订阅更新。** 客户端订阅 `/rooms/lobby` 这样的路径,服务器向该路径发布消息。 +- **接收推送。** 服务器向单个 socket 发送消息,或者向所有 socket 广播。 + +## 何时使用 WebSocket + +| 方式 | 方向 | 适用场景 | +| -------------------------------------- | ---------------------- | ---------------------------------------------------------- | +| 轮询普通路由 | 客户端询问,服务器应答 | 每隔几分钟更新一次,或者实时性没有额外价值的场景 | +| [服务器发送事件](/tutorials/zh_CN/sse) | 只能从服务器到客户端 | 实时信息流、进度、通知;使用普通 HTTP,能穿过大多数代理 | +| 使用 nes 的 WebSocket | 双向,一个连接 | 聊天、协作、游戏:客户端频繁发送消息,并期待服务器推送回来 | + +如果客户端只负责接收,SSE 运维起来更简单。当客户端也会频繁发送消息,或者你希望请求和更新共用一个连接(这在移动网络上很重要)时,选择 WebSocket。 + +## 安装与配置 + +```sh +npm install @hapi/nes +``` + +一个聊天室服务器:消息通过一条路由发布,房间的每个订阅者都会收到,另一条路由返回最近的历史消息。 + +```ts +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; +import Nes from '@hapi/nes'; + +interface ChatMessage { + room: string; + text: string; + at: string; +} + +const history = new Map(); + +const server = Hapi.server({ port: 4000 }); + +await server.register(Nes); + +server.subscription('/rooms/{id}'); + +const roomParams = Joi.object({ id: Joi.string().alphanum().max(50).required() }); + +server.route<{ Params: { id: string }; Payload: { text: string } }>({ + method: 'POST', + path: '/rooms/{id}/messages', + options: { + validate: { + params: roomParams, + payload: Joi.object({ text: Joi.string().max(2000).required() }), + }, + }, + handler: (request, h) => { + const message: ChatMessage = { + room: request.params.id, + text: request.payload.text, + at: new Date().toISOString(), + }; + + const messages = history.get(message.room) ?? []; + history.set(message.room, [...messages, message].slice(-50)); + + server.publish(`/rooms/${message.room}`, message); + return h.response(message).code(201); + }, +}); + +server.route<{ Params: { id: string } }>({ + method: 'GET', + path: '/rooms/{id}/messages', + options: { validate: { params: roomParams } }, + handler: (request, h) => history.get(request.params.id) ?? [], +}); + +await server.start(); +``` + +nes 与你的 HTTP 路由监听同一个端口。这些路由在普通 HTTP 下也能使用,因此其他服务无需打开 socket 就能向房间发布消息。`POST` 路由只发布它[验证](/tutorials/zh_CN/validation)过的字段,从不发布原始 payload。 + +Node.js 中的客户端: + +```ts +import Nes from '@hapi/nes'; + +const client = new Nes.Client('ws://localhost:4000'); +await client.connect(); + +await client.subscribe('/rooms/lobby', (message) => { + console.log('lobby:', message); +}); + +await client.request({ method: 'POST', path: '/rooms/lobby/messages', payload: { text: 'hello' } }); +``` + +在浏览器中,单独导入客户端文件,这样你的打包结果就不会带上服务器插件: + +```ts +import { Client } from '@hapi/nes/lib/client.js'; + +const client = new Client(`wss://${location.host}`); +await client.connect(); +``` + +订阅 handler收到的消息类型是 `unknown`。服务器可以发布任何内容,所以在使用之前要检查它的结构,就像处理 `fetch()` 的响应一样。 + +## 通过 socket 调用路由 + +`client.request()` 让路由走完完整的 hapi [请求生命周期](/api#request-lifecycle):验证、`auth.access` scope、扩展点和 handler。nes 通过 [`server.inject()`](/api#await-server-inject-options) 实现这一点,并传入该 socket 在连接时认证得到的凭证。请求选项和响应结构参见 [`client.request()`](/module/nes#await-client-request-options)。 + +在 handler 中,对于 socket 请求,`request.socket` 是 nes 的 socket;对于普通 HTTP 请求,它是 `null`,所以使用前要先检查。 + +当客户端已经持有一个连接、而你想省掉一次额外的 HTTP 往返时,使用 socket 请求。对于以接收为主的页面,普通 HTTP 请求加一个订阅更容易调试。 + +## 推送更新 + +nes 提供三种向客户端发送消息的方式: + +| 方法 | 谁会收到 | 适用场景 | +| ------------------------------- | ----------------------- | -------------------------------- | +| `server.publish(path, message)` | 订阅了匹配路径的 socket | 几乎所有情况:房间、文档、信息流 | +| `socket.send(message)` | 单个 socket | 回复以及针对单个连接的通知 | +| `server.broadcast(message)` | 所有已连接的 socket | 很少用:维护警告、强制刷新 | + +通常你需要的是订阅,因为由客户端说明它关心什么,服务器只发送这些内容。 + +```mermaid +sequenceDiagram + participant C as 客户端 + participant S as hapi 服务器 + C->>S: connect(hello,带身份认证信息) + S-->>C: 已连接 + C->>S: 订阅 /rooms/lobby + Note over S: subscription('/rooms/{id}') 匹配,
检查身份认证和 scope + S-->>C: 已订阅 + Note over S: 某条路由调用 publish('/rooms/lobby', message) + Note over S: filter(path, message, { socket, credentials })
对每个订阅者运行一次 + S-->>C: 更新 +``` + +订阅的 `filter` 针对每个 socket 决定是否发送这条更新。filter 需要、但客户端不应看到的数据,通过 `internal` 传入: + +```ts +server.subscription('/documents/{id}', { + filter: (path, message, options) => { + return options.internal === options.credentials?.id; + }, +}); + +server.publish('/documents/42', { title: 'Q3 plan' }, { internal: '2133d32a' }); +``` + +只有文档的所有者会收到这条更新,而所有者 id 永远不会到达任何客户端。filter 比较的是 `credentials.id`,它由身份认证教程中的 [bearer strategy](/tutorials/zh_CN/auth#jwt) 设置。 + +每次发布时,filter 都会为每个订阅者运行一次,所以只在其中做内存中的检查。如果要改变每个用户看到的内容,filter 可以返回 `{ override: message }` 而不是 `true`。 + +## 身份认证 + +nes 没有自己的身份认证机制。它复用你的 hapi [身份认证 strategy](/tutorials/zh_CN/auth):socket 在连接时进行认证,该 socket 上的每个请求和订阅都使用这些凭证。 + +插件的 `auth.type` 选项决定客户端如何证明自己的身份: + +| `auth.type` | 客户端如何认证 | 适用于 | +| ------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| `'direct'`(默认) | 在 connect 消息中发送普通的 HTTP 认证请求头:`client.connect({ auth: { headers: { authorization } } })` | 使用 bearer token 或 basic 认证的 API | +| `'token'` | 先通过 HTTP 调用 `GET /nes/auth`,拿到一个密封的 token,再把它传给 `client.connect({ auth: token })` | 无法依赖 cookie 的客户端,例如移动应用 | +| `'cookie'` | 先通过 HTTP 调用 `GET /nes/auth`,它会设置一个加密的 cookie,浏览器在 WebSocket 升级时发送这个 cookie | 已经在使用 cookie 会话的浏览器应用 | + +使用 `'direct'` 时,nes 会用 `auth.route` 中的 strategy 处理这些请求头;如果没有设置 `auth.route`,则使用服务器的[默认 strategy](/tutorials/zh_CN/auth#default)。以身份认证教程中的 [bearer strategy](/tutorials/zh_CN/auth#jwt) 作为默认 strategy: + +```ts +const client = new Nes.Client('wss://api.example.com'); +await client.connect({ auth: { headers: { authorization: `Bearer ${token}` } } }); +``` + +- **不带凭证连接。** 如果身份认证 strategy 的 mode 是 `'required'`,不带 `auth` 连接的客户端会被拒绝,并返回 `Connection requires authentication`。否则它会以未认证状态连接,只有需要身份认证的路由和订阅会拒绝它。 +- **会过期的 token。** `@hapi/jwt` strategy 会在 socket 打开期间重新检查 token,频率最多为每个 `auth.minAuthVerifyInterval` 一次(默认为心跳间隔)。token 过期后,服务器关闭 socket,客户端的重连会被拒绝并返回 `Token maximum age exceeded`,客户端随即停止重试。请在旧 token 过期之前发送新的 token: + + ```ts + await client.reauthenticate({ headers: { authorization: `Bearer ${freshToken}` } }); + ``` + + 如果 strategy 的 scheme 没有 `verify()` 方法,socket 在整个生命周期内都会沿用连接时的凭证。 + +- **保护订阅。** 没有 `auth` 选项的订阅会使用默认 strategy 及其 mode,但不会使用默认 strategy 的 `access` 中的 scope。这些 scope 仍然保护着 socket 请求。为每个受保护的订阅设置它自己的 `auth.scope`,它接受所列 scope 中的任意一个;[路由 scope](/tutorials/zh_CN/auth#scopes) 支持的 `+` 和 `!` 前缀在这里不可用。 +- **为 `'token'` 和 `'cookie'` 设置 `auth.password`。** 如果不设置,nes 会在进程启动时随机选一个密码,于是 token 和 cookie 在重启后失效,一个实例也无法读取另一个实例密封的内容。 +- **使用 cookie 认证时设置 `origin`。** WebSocket 升级不经过 CORS 检查,而且同级子域名算作同一站点,因此 nes cookie 的 `SameSite=Strict` 拦不住它们。`origin` 会拒绝来自其他任何源的升级请求,包括不发送 `Origin` 请求头的客户端,例如上面的 Node.js 客户端。 + +```ts +import Hapi from '@hapi/hapi'; +import Nes from '@hapi/nes'; + +import type { Nes as NesTypes } from '@hapi/nes'; + +const cookiePassword = process.env.NES_COOKIE_PASSWORD; +if (!cookiePassword || cookiePassword.length < 32) { + throw new Error('NES_COOKIE_PASSWORD must be set to at least 32 characters'); +} + +const server = Hapi.server({ port: 4000 }); + +const nesOptions = { + auth: { type: 'cookie', password: cookiePassword } satisfies NesTypes.PluginOptions['auth'], + origin: ['https://app.example.com'], +}; + +await server.register({ plugin: Nes, options: nesOptions }); +``` + +## 重连与错过的更新 + +移动网络经常断开连接,客户端会自动重连: + +```mermaid +flowchart TD + A[连接断开] --> B["客户端等待后重连
(间隔 1s、2s、3s……最长 5s)"] + B --> C{"connect 消息
被接受?"} + C -->|是| D["订阅已恢复,
调用 onConnect"] + C -->|"否:身份认证失败"| E["客户端停止重试,
调用 onDisconnect"] + D --> F["你的 onConnect handler
重新获取当前状态"] +``` + +- 客户端会按上面的间隔不断重试,直到重新连上,除非你在 `client.connect()` 中限制了 `retries`。 +- 重连时,客户端会重新发送它的订阅,所以你的 handler无需任何代码就能继续工作。 +- **客户端断开期间发布的更新会丢失。** nes 不会为它们排队。如果错过更新会造成问题,请在 `onConnect` 中加载当前状态: + +```ts +const refreshRoom = async (room: string) => { + const { payload } = await client.request(`/rooms/${room}/messages`); + render(payload); +}; + +client.onConnect = () => { + refreshRoom('lobby').catch((err) => console.error('Could not refresh the room', err)); +}; +``` + +- 服务器每 15 秒 ping 一次每个客户端,如果客户端在 5 秒内没有应答就断开它;如果 ping 不再到达,客户端会断开并重新连接。会关闭空闲连接的负载均衡器和代理,其空闲超时需要长于 ping 间隔,否则连接会莫名其妙地断开。 + +## 扩展到多个实例 + +两个服务器实例之间不共享内存。实例 A 持有的 WebSocket 连接在实例 B 上并不存在,所以在 A 上调用 `server.publish()` 或 `server.broadcast()` 只会到达连接到 A 的客户端。在负载均衡器后面运行两个实例时,发到 A 的消息永远不会到达连接到 B 的客户端。这一点对每个 WebSocket 库和每个托管服务商都成立:每个进程只能写入它自己持有的连接。 + +横向扩展需要在实例之间传递消息。Redis、NATS 或 Postgres `LISTEN`/`NOTIFY` 这类发布/订阅后端会把每条消息分发给所有实例,再由每个实例投递给它自己的连接: + +```mermaid +flowchart TD + P["在实例 A 上
产生一条消息"] -->|发布| R[("消息代理
Redis、NATS、Postgres")] + R -->|分发| A[实例 A] + R -->|分发| B[实例 B] + A --> CA[A 上的客户端] + B --> CB[B 上的客户端] +``` + +使用 Redis: + +```ts +import { createClient } from 'redis'; + +const publisher = createClient({ url: process.env.REDIS_URL }); +const subscriber = publisher.duplicate(); +publisher.on('error', (err) => server.log(['redis', 'error'], err)); +subscriber.on('error', (err) => server.log(['redis', 'error'], err)); +await Promise.all([publisher.connect(), subscriber.connect()]); + +await subscriber.subscribe('chat', (raw) => { + const message: ChatMessage = JSON.parse(raw); + server.publish(`/rooms/${message.room}`, message); +}); + +const publishToRoom = async (message: ChatMessage) => { + await publisher.publish('chat', JSON.stringify(message)); +}; +``` + +`POST` 路由改为调用 `publishToRoom()` 而不是 `server.publish()`,每个实例(包括处理该请求的那个实例)都会向自己的 socket 发布。 + +- WebSocket 连接会一直留在它所连接的实例上,因此 socket 本身不需要会话粘滞。对于 `'token'` 和 `'cookie'` 认证类型,要在每个实例上设置相同的 `auth.password`,因为 `/nes/auth` 请求可能落在与 socket 不同的实例上。 +- `server.eachSocket()` 同样只作用于本进程。 + +## 清理 + +每个连接的状态与 socket 同生共死。socket 消失时要释放它: + +- 插件选项中的 `onDisconnection` 会在任意 socket 关闭时运行。 +- `server.subscription()` 选项中的 `onUnsubscribe` 会在客户端取消订阅或断开连接时运行,这里适合停止只为该订阅者做的工作,例如轮询循环。 +- `server.stop()` 会关闭所有 socket。客户端会看到连接断开并开始重连,这正是滚动部署时你想要的:它们会连到另一个实例上。 + +## TypeScript + +nes 自带插件和客户端的类型。导入 `@hapi/nes` 会把 `server.subscription()`、`server.publish()`、`server.broadcast()`、`server.eachSocket()` 和 `request.socket` 添加到 hapi 的类型中。`client.request()` 通过类型参数接收 payload 的类型,例如 `client.request(path)`;它不会在运行时检查,所以对不受你控制的数据要进行验证。 + +## 后续步骤 + +- [@hapi/nes API 参考](/module/nes):所有插件选项、[socket 身份认证](/module/nes#socket-auth)以及完整的客户端 API。 +- 当客户端只负责接收时,参见[服务器发送事件](/tutorials/zh_CN/sse)。 +- [身份认证](/tutorials/zh_CN/auth):nes 复用的 strategy 和 scope。