npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dokebi/web

v0.4.3

Published

Fastify 5 HTTP server owned by a Dokebi app: 503 before ready, health routes, drain and cluster handover.

Readme

@dokebi/web 0.4.3

An optional package for Node >=24.0.0 and core ^0.4.2 (public on npm). It owns an HTTP server built on Fastify 5.12.7; DBs, routers, controllers and views are not required.

Public contract

It follows plugin contract v2 (D-0028): createPlugin({ app, options?, entry? }) → app.register(plugin) → app.start() → app.get(plugin). app (the Application's app.view) is required. Without options it uses app.config['dokebi-web'], or {} when that is missing too. Create a new plugin instance per Application. The old health, requires and dependencies inputs are gone.

import { Application } from '@dokebi/core';
import { createPlugin as createWeb } from '@dokebi/web';

const app = new Application({ config: { 'dokebi-web': { port: 3000 } } });
const web = createWeb({ app: app.view, entry: { requires: [sessionStore] } });
app.register(web);
  • Dependencies: dokebi-health is an optional dependency (optionalDependency). When health is registered in the same app it is connected automatically; otherwise only the app state counts. To choose the instance, register it under the same name first, as in app.register(createHealth({ app: app.view })).

  • entry.requires: the Dokebi resources that Fastify onClose hooks use. Entries are plugin instances (converted with core dependencyOf) or PluginDependency descriptors. See "Start, shutdown and ownership" below. A dokebi-health instance there is used as a required dependency instead of the optional one.

  • Descriptor for other plugins: import { dependency } from '@dokebi/web/dependency' (also exported from the root). When no provider is registered, web is created from this package.

  • route<T>(options): keeps Fastify RouteOptions and the Params/Body/Reply types. Register during setup.

  • register(plugin, options): passes a Fastify async or callback plugin and its options ({} when none). Native plugins keep their scope, decorators, schemas and hooks.

  • address: the real address after listen, undefined before. In a node:cluster worker it is set only after core ready (D-0034); other plugins read it in ready, not start. After shutdown the last address remains, so do not take it as a sign of availability.

  • track(() => work): puts explicit work whose lifetime differs from the HTTP response into drain and returns a Promise. The caller handles its failure and stops accepting new work.

  • routeConfig({ key, plugin, check }) (setup only, D-0076 §5): declares a route config key another plugin reads (auth, csrf). Every route's value is checked at start, and near-miss keys (csfr) are reported, so a typo fails the start (INVALID_ROUTE_CONFIG) instead of leaving a route unprotected. An optional routes(list) callback then receives every route that sets the key, so the plugin can report them (@dokebi/csrf logs its exempt routes).

  • routeHook({ plugin, route }) (setup only, D-0076 §3.8): asks once per route (earlier routes too) whether to add preValidation/onResponse hooks for that route alone and config values under symbol keys; routes it leaves alone cost nothing per request. dokebi-auth uses it to protect only the routes that need it.

  • Route ids (0.4.3, D-0077): config: { id: 'API-12' } names a route (1 to 64 letters, digits, ., _ or -, starting with a letter). A wrong id fails the start with INVALID_ROUTE_CONFIG, the same id on two routes of one app with DUPLICATE_ROUTE_ID. The handler reads request.routeOptions.config.id.

  • routes() (0.4.3, D-0077): every route added so far as a frozen list [{ method, url, id?, constraints, configKeys }] (the HEAD twin of a GET left out), complete once the server is ready. Other plugins and tools read the app's routes from it.

  • Files from a folder (update feeds, installers) are @dokebi/static (D-0147), not an option of web.

  • defineErrors({ CODE: { status, message, hint? } }) (0.4.3, D-0077): the app's error codes in one place (src/shared/errors.ts); errors.CODE({ data }) makes the DokebiError web answers as { error, message, hint, data }, and errorCatalog(module) reads the frozen list back (AI.md §6).

  • @dokebi/web/points viewData (dokebi-web:view-data): a plugin contributes { key, value(request) } and the value appears on every rendered page — the EJS data of @dokebi/app apps and the shared props of template-inertia. Details in AI.md §2.

web becomes ready and listens in its start, after every Dokebi setup. In a node:cluster worker it listens in the core ready hook instead (D-0034): the primary hands connections to a worker as soon as it listens, so only workers whose whole app is ready should take them. After that, route and register are FROZEN. Inside a Fastify registration function, register routes on the scope you were given. Adding plugins while running is not supported.

YAML options

options:
  dokebi-web:
    host: 127.0.0.1
    port: 3000
    maxBodyBytes: 1048576
    requestTimeoutMs: 30000
    healthRoutes:            # false registers no health routes
      live: /health/live
      ready: /health/ready

The defaults are host 127.0.0.1, port 0 (an ephemeral port), maxBodyBytes 1 MiB and requestTimeoutMs 30 seconds. Allowed are port 0..65535, maxBodyBytes 1..64 MiB and requestTimeoutMs 1..2147483647 ms. requestTimeoutMs limits receiving a request (a body that has not arrived that long after its headers gets 408 REQUEST_TIMEOUT; an upload route sets its own config.requestTimeoutMs); it is not a limit on running the work and does not cancel DB calls. Before 0.4.0 the keys were bodyLimit and requestTimeout; those names are still accepted with an OPTION_DEPRECATED warning, and setting old and new together is an error (D-0071). Fastify construction options such as HTTPS, HTTP/2 and serverFactory go through the extension hook fastifyOptions (AI.md §8).

healthRoutes defaults to { live: '/health/live', ready: '/health/ready' }. Keys missing from the object use the default path. Paths start with /, contain no ?, # or spaces, and live and ready must differ. false registers neither route (when an external health server is used). The health routes have the route ids web.healthLive and web.healthReady; routeIds: { healthLive, healthReady } renames them. Every route a plugin adds has such an id (mcp.endpoint, oauth.token, …, D-0142, AI.md §2.2). The options are one defineOptions declaration in src/options.ts, which is the parser, and the build writes options.schema.json from it (D-0063 §3). Invalid options fail in the factory with CoreError('INVALID_PLUGIN_OPTIONS') (cause: core INVALID_OPTIONS, with paths in details.issues).

Other options: trustProxy (below), sealDriftMs and sealExclude (seal hosts), formBody and formMaxFields (HTML form bodies, off by default), developmentErrors (error details outside production), transientLogWindowMs, transientLogMaxKeys and transientLogLevel (the log of retryable 503s under a burst: the first of each code and route per window in full, the rest counted into one WEB_TRANSIENT_REPEATED line, D-0113). The AI guide has the full table.

Response compression (0.4.3, D-0125) is on by default: text, JSON (Inertia visits included), JavaScript, CSS, SVG and XML bodies of at least compression.thresholdBytes (1024) go out as br (quality 4) or gzip (level 6), whichever the client's Accept-Encoding prefers, with Vary: Accept-Encoding. Measured: a 146 KB Inertia page of i18n-like English text leaves as 48 KB (br 4, about 1.6 ms of thread-pool CPU). 206, 304, SSE, sealed bodies, images, archives, fonts, Cache-Control: no-transform and bodies that already have a Content-Encoding are never compressed; HEAD gets the headers only; streams are compressed chunk by chunk. The zlib work runs on the thread pool, and while draining or with compression.maxPending (64) compressions under way answers go out uncompressed. A route opts out with config: { compress: false }, one response with skipCompression(reply) (pages that show a secret next to reflected input: BREACH, AI.md §7). Set compression.enabled: false when a front proxy compresses. The options and the rules are in AI.md §1.

Security headers (0.4.3, D-0149) are on by default: X-Content-Type-Options: nosniff on every answer, Strict-Transport-Security on HTTPS answers only (TLS, or X-Forwarded-Proto from trustProxy), and on HTML documents a Content-Security-Policy (default-src 'self', object-src 'none', frame-ancestors 'self', inline scripts and styles allowed, images from https), X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin and Cross-Origin-Resource-Policy: same-site (over HTTPS also Cross-Origin-Opener-Policy). A header a route or plugin already set is kept (storage and image downloads, static files, the mail dev inbox, the OAuth consent page); plugins add CSP sources with web.contentSecurityPolicy (seal adds 'wasm-unsafe-eval', template-inertia the Vite dev socket). Change them under securityHeaders in dokebi.yaml, per route with config: { securityHeaders: { … } }, or turn them off for a route with config: { securityHeaders: false }. Measured: 0.99 of Fastify on hello JSON (@fastify/helmet: 0.71).

Overload protection (0.4.3, D-0150) is on by default through @fastify/under-pressure: while the mean event loop delay of the last one-second sample is above overload.maxEventLoopDelayMs (200), requests get 503 OVERLOADED with Retry-After (2 s) before any handler runs; health routes and routes with config: { overload: false } are served. Event loop utilization, heap and RSS limits are off until set. The first refusal of each route per window is logged (D-0113). Details in AI.md §2.5.

CORS is @dokebi/cors (npx dokebi add @dokebi/cors); do not register @fastify/cors yourself.

Start, shutdown and ownership

Until the whole Application has started and the optional health is ready, business requests get 503 with the body { error: 'SERVICE_UNAVAILABLE', reason }, where reason is one of DRAINING, STARTING and NOT_READY. A browser page gets the same as a small HTML page (D-0074). The same reason goes to the debug log HTTP request refused. The health routes (default /health/live, /health/ready) are reserved paths and answer 200/503. In a @dokebi/cluster worker the ready route answers for every worker of the cluster: 200 only when each is ready, with the slots that are and are not in workers (D-0164); request refusals stay per worker.

In a node:cluster worker (D-0036), drain first closes the listen socket so the primary sends new connections to other workers, and tells HTTP/2 sessions GOAWAY. Open HTTP/1 keep-alive connections stay open up to drainIdleMs (2 s): a request arriving on one is served and answered with Connection: close, so the client's next request goes to another worker instead of being lost to a closed socket; the wait ends as soon as no connection is left. Then the connections still idle are closed once, and a response still under way closes its own connection when it ends (D-0107 §1). The worker stays up at least 100 ms after closing listen so connections the primary was handing over are not dropped. Only a client that stays idle longer than drainIdleMs and sends at the very moment its connection closes can still get ECONNRESET (inherent to HTTP/1). A single process keeps listening and answers 503, as below (the load balancer takes it out through health).

In a single process, drain refuses new requests with 503 and then waits for responses, handler Promises, registered request lifecycle hooks and track work. When the core drain deadline (shutdown.drainTimeoutMs, default 20 seconds) passes or stop({ force: true }) aborts context.drainSignal, it stops waiting, cuts the remaining connections with server.closeAllConnections() and returns; core then records DRAIN_TIMEOUT. A client disconnect never marks handler or hook work as done. Fastify.close runs in the deferred cleanup only after every plugin's drain succeeded, and native onClose hooks run then. Before it, cleanup ends every connection that answers no request (idle, never used — a browser's preconnect or the spare connection a client opens after aborting a request — with a request still arriving, a TLS connection before its handshake, an HTTP/2 session without streams or without a client preface), so such a connection no longer holds stop until headersTimeout; responses under way finish and their connection then ends, HTTP/2 sessions with streams get GOAWAY, and upgraded (WebSocket) connections are left to their plugin. HTTP/2 (TLS or h2c) is set through fastifyOptions. Shutdown failures and delays are never hidden, and the DB is never closed before unfinished work. A final forced kill in a cluster is a process policy, not a successful drain.

Rule: put every Dokebi resource a Fastify onClose hook uses into entry.requires. For example createWeb({ app: app.view, entry: { requires: [dbPlugin] } }). Cleanup runs in reverse initialisation order, so web must depend on the resource for the resource to be cleaned up after Fastify.close. If the resource depends on web instead, the resource closes first. Business modules declare the web, router, controller and DB they use in their own requires. Never close a borrowed pool on your own.

For native callback hooks, done or the normal end of the response marks the end of the work; async hooks are awaited until their returned Promise settles. Work that continues after the response, and async work in user raw streams, parsers, custom error handlers, timers or outside events, is not tracked automatically: wait for it with track or the owning plugin's drain. Do not mix async and done in a hook. listen/close, overwriting root hooks and bypassing the reserved dokebiHealth config are outside the extension contract.

Error responses follow the D-0063 §1 mapping: a Dokebi error answers with its status and { error: code, message, hint?, data? } (plus fix and details in development); an unknown error answers 500 { error: 'INTERNAL_SERVER_ERROR' }. In production (NODE_ENV=production or developmentErrors: false) fix and details are left out and 5xx messages become generic sentences. One rule picks the format (D-0074): Inertia requests, XHR, Bearer API clients, JSON request bodies, sealed requests and fetch() get JSON; only a browser page (a navigation, an HTML form post) gets the same content as a small HTML page. A refused body or query string schema is 422 { error: 'VALIDATION_FAILED', message, hint, fields: [{ field, message }], errors: { field: message } }, and throw validationError({ name: 'Already taken' }) from a handler gives the same answer; an unknown route is 404 NOT_FOUND in the same shape. Error responders (web.errorResponder) let @dokebi/app draw an app's error views and send a refused HTML form back to its page, and @dokebi/template-inertia answer Inertia requests (validation redirect, error page). The pieces of that redirect are exported for your own responders (0.4.1): isFormSubmission, formFieldErrors, formOldInput and formBackTarget (AI.md §6). Any 4xx error can ask for the same redirect with data.formErrors: { field: message } (0.4.3, D-0076 §2.5; _form for the whole form): @dokebi/csrf does it for an expired token. An error that would answer 500 is first offered to the contributions of dokebi-web:error-translator (@dokebi/web/points, D-0093): @dokebi/db contributes one, so a unique violation answers 409 DB_CONFLICT and a statement timeout 503 DB_TIMEOUT with Retry-After, in the same JSON or HTML format, logged at error as WEB_ERROR_TRANSLATED with the driver error as the cause. A 500 leaves the error log WEB_REQUEST_FAILED (with the full error text). The table is in AI.md §6. If you register your own error handler, its security is your responsibility. HTTP authentication, rate limiting, CORS and so on are Fastify plugins the app chooses.

Install and run

dokebi add creates the entry file src/infra/web/extension.ts (export const requires = [] as const; and an empty export default class AppWeb extends WebExtension {}) through the install module (dist/install.js). In projects created before 0.4.0, src/infra/web/requires.ts stays the entry after upgrade. The generated code passes this module as entry. When you compose the app in your own main, do not edit generated files or register the same ID twice. The repository's runnable example is examples/22-web/main.ts; the real DB and cluster example is examples/21-db/main.ts.

Plugins with an entry file are left out of the generated code's manifest dependency check, so the entry file's requires works on the CLI path too (the same generated-code rule was run with controller and module entry files on 2026-10-14, ISSUE-0005 PG-04). Dependencies added through an entry file are invisible to the CLI's remove and doctor decisions; a missing one shows up at run time as MISSING_DEPENDENCY.

HTTP on seal hosts (D-0062, 0.2.0)

web owns the slot that seals HTTP data of seal hosts (@dokebi/app domains.<host>.seal: true) and of seal: true routes. @dokebi/seal fills in the algorithm with sealCodec(provider) (this package never imports seal). See D-0062.

  • The decision (once per request): health → plain, route seal: false → plain, seal: true or seal(request) → sealed, a sealExclude prefix (/ boundary) → plain, any sealRequests(predicate) true → sealed. seal becomes config.dokebiSeal whether given to route(), a Fastify register scope or a router mount.
  • Bodies of sealed requests: when the content type is the provider's contentType (application/vnd.dokebi-seal), the parser opens it and puts the JSON into request.body (drift, replay and tampering are 400, with the reason only in warn SEAL_BODY_REFUSED; a full replay nonce store is a retryable 503 SEAL_BUSY, D-0075). A body that is neither sealed nor multipart/form-data gets 415 { error: 'SEAL_REQUIRED', fix } before the parser (warn SEAL_REQUIRED, once per route every 10 seconds).
  • JSON responses of sealed requests (application/json, +json, 4xx and 5xx included) are sealed in onSend and get Content-Type: application/vnd.dokebi-seal and x-dokebi-sealed-path (the path used for sealing; the final path after redirects). HTML, file and hijacked responses stay as they are. Streamed JSON cannot be sealed and becomes a 500 (plain text is never sent).
  • Code that writes its own body (the Inertia first page, SSE) gets { path, encode } from sealOf(request).
  • When something wants sealing (a registered decision or a sealed route) but no provider exists, start fails with SEAL_PROVIDER_MISSING.
options:
  dokebi-web:
    sealDriftMs: 60000          # allowed ts drift of sealed bodies (1000..600000); nonces are kept twice as long
    sealExclude: [/webhooks]    # path prefixes that stay plain on seal hosts (up to 64)

| API | Meaning | |---|---| | sealCodec(provider) | fills the HTTP seal slot (setup, once). dokebi-seal calls it | | sealRequests(predicate) | seals the requests this predicate accepts (setup). dokebi-app calls it for seal domains | | sealOf(request) | { path, encode(text) } for a sealed request, otherwise undefined | | sealing | whether a provider is registered |

Route constraint strategies (D-0030)

constraint(strategy) adds a Fastify route constraint strategy (addConstraintStrategy) during setup. Call it before the routes that use the constraint. @dokebi/app uses it to pick the app by Host (dokebiApp), so several routes can share one path, split by constraint value. A call after start is FROZEN.

Routes bound to apps (D-0132)

A route with app: 'mcp' (or app: ['studio', 'admin']) answers only on the hosts of those @dokebi/app apps; every other host gets the 404 of an unknown route. It also works on routes of register() scopes, and is bound at boot whatever the plugin order. Plugins that add routes take the same option, <plugin>.app in dokebi.yaml (dokebi-mcp.app, dokebi-oauth.app), declared with appOption() of @dokebi/web/points. @dokebi/app fills the routing (appRouting); without it a bound route fails the start (ROUTE_APP_UNAVAILABLE), and an unknown app name too (ROUTE_APP_UNKNOWN). Details in AI.md §2.1.

Trusted proxies (2026-10-14)

Only requests from trustProxy: [address...] (exact IPv4 and IPv6 addresses, up to 64) have their X-Forwarded-Host (and For and Proto) trusted. The same list goes to Fastify's trustProxy. requestHost(request) returns the host the request asked for under that rule (Host, or :authority over HTTP/2), in lower case with the port kept; of several X-Forwarded-Host entries it takes the last, the one the trusted proxy wrote (earlier entries can come from the client), as Fastify's request.host does. @dokebi/app's domain decision and @dokebi/websocket's Origin check use it. Example: a Cloudflare tunnel on the same machine is [127.0.0.1, ::1] (ISSUE-0005 PG-20).

routeHook can return onResponse(request, reply, done): a callback hook appended after existing response hooks, including on routes registered earlier. Call done once. The response has completed and cannot be changed; reply.elapsedTime is milliseconds. @dokebi/metrics uses this hook for bounded response collection. Routes left untouched receive no hook.