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

@ti-engine/web-framework

v1.41.0

Published

A web-framework based on the ti-engine. It provides a customizable ready-to-use web-server microservice and a set of tools for creating web applications. NOTICE: This is still a work in progress and the full architecture, design, and functionality are not

Readme

ti-engine web framework

Logo

Flexible framework for the creation of microservices with node.js.

Information

This is a customizable web framework based on the ti-engine framework. Currently under development.

Environment variables

The web server configuration (host, port, TLS, cookies, etc.) is normally provided via the service configuration file merged in the TiWebServer constructor. The following environment variables can override individual values at runtime — useful for container/12-factor deployments where the same image is configured per environment:

  • TI_WEB_HOST overrides the bind address (e.g. 0.0.0.0 in a container). Defaults to the value in the web server config.
  • TI_WEB_PORT overrides the listen port.
  • TI_WEB_USE_TLS (true/false) toggles in-app TLS. Set false when a reverse proxy / ingress terminates TLS.
  • TI_WEB_TLS_CERT_PATH / TI_WEB_TLS_KEY_PATH override the TLS certificate/key paths (only used when TLS is enabled).
  • TI_WEB_COOKIE_SECRET sets the session cookie signing secret. Set a stable, private value for durable sessions and multi-replica deployments (otherwise a random per-process value is used).
  • TI_WEB_TLS_CERT_PATH is also read by the container liveness probe (below), which verifies against that certificate.
  • TI_WEB_SESSION_IDLE_TIMEOUT (whole minutes) sets how long a signed-in session survives without activity, overriding cookies.maxAge. The window is rolling: every response re-stamps the cookie, so a session ends only after that long with no request at all. Note that a user typing into a form makes no requests, so set this comfortably longer than the longest form a user fills in one sitting. Defaults to 480 (eight hours).
  • TI_WEB_AUTH_METHODS (comma-separated) replaces the enabled authentication methods (auth.enabledMethods), e.g. openid-google or local,openid-google.
  • TI_WEB_AUTH_LOCAL_USERS_PATH overrides the local user directory's file path (auth.local.usersPath), which backs local sign-in. An explicitly empty value means no directory, so every local sign-in is refused. See Local (username/password) authentication.
  • TI_WEB_AUTH_ADMINS (comma-separated) replaces the admin allowlist (auth.admins). Entries are matched against the session user's user ID, username or email, so an OpenID deployment lists emails. An explicitly empty value means no admins.
  • TI_WEB_TRUSTED_ORIGINS (comma-separated) replaces the trusted request origins (trustedOrigins) — needed behind proxies that do not present the real external origin.
  • TI_WEB_STATIC_MAX_AGE (whole seconds) overrides staticCache.maxAge. See Static asset caching.
  • TI_WEB_STATIC_IMMUTABLE (true/false) overrides staticCache.immutable.
  • TI_WEB_STATIC_IMMUTABLE_PATHS (comma-separated) replaces staticCache.immutablePaths. An explicitly empty value means no long-lived paths.
  • TI_WEB_SERVER_TIMING (true/false) overrides serverTiming: whether every response says, in a Server-Timing header, where its time went in the process. Off by default. See Timing a response.

OpenID Connect providers are configured with their own variables — TI_AZURE_AUTH_CLIENT_ID / TI_AZURE_AUTH_CLIENT_SECRET / TI_AZURE_AUTH_CALLBACK_URL / TI_AZURE_AUTH_DISCOVERY_URL, and the TI_GCLOUD_AUTH_* equivalents. A callback URL may be given either as the full absolute URL registered with the provider (https://your-host/login/azure-callback) or as a path (/login/azure-callback): the server always listens on the path, while the redirect_uri sent to the provider is the absolute value verbatim if one was configured, and otherwise assembled from the request's forwarded protocol/host.

Authentication and authorization

TiWebServer#augmentSession is the hook through which an application derives its own session roles — from an identity store, the org chart, or wherever a deployment keeps that mapping — once per login, before the framework's own additive admin role (auth.admins, see Environment variables) is applied on top; the default is a no-op that returns the session unchanged. Throwing from the hook refuses the sign-in rather than admitting a session the application could not map to a principal: the framework destroys the freshly regenerated session so nothing usable survives the refusal, the login handler responds 401, and the error handler sends the browser back to the login page with the exception code in the ?error= query parameter — the same path a failed OpenID callback takes, regardless of which auth method was used.

The identity an OpenID sign-in puts on the session

A sign-in is resolved from the validated ID token claims and the userinfo response together, not from userinfo alone. Within a single claim userinfo wins — it is the fresher of the two, and openid-client has already verified that both describe the same subject — and the ID token fills the gaps.

| Session field | Resolved from, in order | |---------------|----------------------------------------------------------------------------------| | userID | oauth2: + the subject (sub) | | username | preferred_username, then upn, then the e-mail, then name, then sub:<sub> | | email | the email claim from either source — and nothing else | | name | the name claim from either source |

Both sources are needed because of what the Microsoft identity platform returns. Its userinfo endpoint answers with sub, name, family_name, given_name, picture and — only when the optional claim is configured — an email taken from the directory's mail attribute. It never returns preferred_username: on Entra that claim lives in the ID token, and it holds the UPN. The UPN is the address an operator actually knows and lists in auth.admins, so reading userinfo alone dropped the one identifier such a deployment is configured around, and an allowlisted administrator had nothing on their session for isAdminIdentity to match.

username is the one field choosing between two different claims, and there the claim outranks the source: preferred_username beats upn whichever response carried it. preferred_username is the standard OIDC claim for a human-readable identifier and upn a Microsoft extension, and "fresher" earns nothing between two stable identifiers that do not differ across the two responses. No ordering can rescue a deployment whose allowlist names the claim that lost, since only one string can be the username — what covers that is the allowlist matching user ID, username or e-mail, and a consumer naming all three when it refuses a sign-in.

Each resolved value also carries its provenance (claims.preferred_username, userinfo.email, …), so a deployment can report how its provider was understood without writing anybody's identifiers to a log — which matters because auditing.logMinLevel ships at 0 with console logging on.

The UPN is offered as the username, never as the email. It is e-mail-shaped and usually routable, but it is a sign-in name rather than a mailbox, and email is what a consuming application resolves its own directory by — widening that would change which principal an identity maps to. The admin allowlist matches user ID, username or e-mail, so listing a UPN there works either way.

An address the provider reports as unverified (email_verified: false, in either source) is refused. An absent claim is not a rejection: Google emits the claim, Entra does not emit it at all, and treating "absent" as "unverified" would refuse every sign-in on an Azure deployment.

Local (username/password) authentication

local is one of the configurable auth.enabledMethods sign-in methods (see Environment variables, TI_WEB_AUTH_METHODS). It is backed by a JSON file of user records — there is no built-in account of any kind.

The users file

auth.local.usersPath (override: TI_WEB_AUTH_LOCAL_USERS_PATH) points at a JSON file holding an array of records:

[
  {
    "username": "jdoe",
    "email": "[email protected]",
    "name": "Jane Doe",
    "passwordHash": "scrypt$16384$8$1$<salt-base64>$<hash-base64>"
  }
]
  • username, email, name and passwordHash are required. email is required because a consuming application resolves the signed-in identity by it, the same way it would for an OpenID identity — a record with no email cannot reach an application at all.
  • userID is optional. When omitted, one is derived from the username, so it stays stable across restarts and logins; supply it explicitly only when something else needs to match a specific value (e.g. auth.admins).
  • disabled: true keeps the record (and its username) in the file while refusing every sign-in for it.

Generate passwordHash with the bundled CLI. It reads the password from stdin, never an argument, so it never lands in shell history or a process listing (ps), and it never echoes the password back:

npm run hash-password -w @ti-engine/web-framework

Type or pipe the password, then EOF; only the resulting hash is written to stdout.

That npm run form only works inside this monorepo (it is a workspace script). A consumer of the published @ti-engine/web-framework package has no bin entry to run it by name — the script ships under bin/build/ regardless, so invoke it by its path inside node_modules instead:

node ./node_modules/@ti-engine/web-framework/bin/build/hash-password.js

The file is the source of truth

On every boot, the file is read and reconciled into the running directory: an added record starts working, a changed passwordHash takes effect, and — this is the point — a record removed from the file is removed from the directory, revoking that user's access on the next restart. Editing the file and restarting is the whole revocation mechanism; there is no separate delete action.

Every failure refuses rather than admits

local enabled with no auth.local.usersPath configured, a file that cannot be read, a file that is not valid JSON, or a file that yields zero valid records after validation — each of these logs a startup WARNING and refuses every local sign-in, rather than admitting one or falling back to a default. A failed read deliberately does not reconcile, so a temporarily broken volume mount leaves previously stored records untouched instead of wiping them; those records stay inert (unused) while the load keeps failing, because sign-ins are refused anyway.

There is no rate limiting, no lockout after repeated failures, and no password policy. Treat local on an internet-facing deployment as a deliberate risk until those exist.

The login screen

The login screen renders before sign-in, so none of an application's own fragments are in play. It has two slots an application fills by shipping a file of the same relative path in its own static directory; the static-path search is reverse-order, so the application's copy wins:

| Slot | Where | Framework default | |---|---|---| | fragments/components/component-login-brand.html | above the sign-in card | the framework's mark, "Welcome back" and "Sign in to continue" | | fragments/components/component-login-extra.html | below the sign-in card | empty |

Use the brand slot to name the application and say what it is for. Use the extra slot for anything a first-time visitor should read after the sign-in controls. Neither slot can change the sign-in controls themselves; those stay the framework's, gated to the enabled methods.

Every string on the screen goes through x-text-label under interface.default.login.*: welcome, sign-in-prompt, username, username-placeholder, password, password-placeholder, sign-in, or-continue-with, sign-in-google, sign-in-azure, no-sign-in-method and error-sign-in-failed. An application normally points TI_LOCALIZATION_LABELS_PATH at its own catalogue alone, so the framework's translations never reach it. It adds the keys it wants translated to its own catalogue, and a key it leaves out renders the English text written in the fragment.

Static asset caching

Everything under /static is served with a Cache-Control policy configured by the staticCache block:

{
  "staticCache": {
    "maxAge": 0,
    "immutable": false,
    "immutablePaths": [ "/fonts/" ]
  }
}
  • maxAge — the max-age in whole seconds (not an express-style "1y" duration string; one is rejected with a warning rather than reinterpreted as milliseconds). 0, the default, emits public, max-age=0, must-revalidate.
  • immutable — adds the immutable directive. Defaults to false, and is ignored with a warning when maxAge is 0, since a response that is stale on arrival cannot also promise never to change.
  • immutablePaths — path prefixes under /static (matched case-sensitively, on a path-segment boundary) served public, max-age=31536000, immutable regardless of the two settings above. Defaults to [ "/fonts/" ]. This replaces rather than merges, so [] means no long-lived paths.

The default revalidates, and that is deliberate. immutable tells a browser the bytes behind a URL will never change, and browsers honour it so completely that not even a manual reload revalidates. On a stable filename — which is what the framework's own assets use (/static/scripts/ti-framework.js, the theme sheets) — the promise is false, and a deployed CSS or JS fix simply never reaches anyone who has already visited, for up to a year, with no way to tell them otherwise. Revalidating costs a conditional request per asset, answered with a 304 from the ETag/Last-Modified that express.static still attaches — headers, no body.

Opt back into immutable once your filenames are content-addressed. If your build emits app.a1b2c3.css, or your application appends a content hash to each asset URL, the promise becomes true and there is real value in making it:

{
  "staticCache": { "maxAge": 31536000, "immutable": true }
}

Fonts are the default exception because a released .woff2 is an artifact rather than something edited in place, and its filename already carries the family, weight and style. If that is not how a given deployment manages its fonts, clear the list.

Since 1.39.0 the framework content-addresses its own references. Every src or href pointing at /static/… in a fragment the framework serves — the shell included — is written with ?v=<content hash> of the file that would be served for it (an application's override, where one exists). A request whose v matches the file's current bytes is answered public, max-age=31536000, immutable whatever staticCache says; a bare URL, or one with a stale hash, gets the policy above. A reference the application writes into its own fragments is fingerprinted the same way; one built at run time in JavaScript is not, and keeps revalidating.

What the browser downloads

Since 1.39.0:

  • Responses are compressed (brotli, else gzip), except a view that embeds a CSRF token.
  • The label catalogue is not in /app/config. The configuration carries labelsBundle: { hash, url }, and GET /app/labels/<hash> serves the catalogue immutable — the browser downloads it once per release and language. Override getClientLabels( language ) in your TiWebAppManager to leave out what the browser never reads; the result must not change for the life of the process, since it is hashed once.
  • Screen fragments requested by HTMX revalidate (private, no-cache), so a repeat visit to a screen is a 304. Full pages stay no-store.
  • /static is served before the session middleware, so an asset never reads or writes a session.

Screens that never change

A screen fragment is served at an address that names the screen, not a version of it, so by default it revalidates: a repeat visit costs a request answered 304. That saves the bytes, but not the round trip. On a hosted deployment the round trip is the whole cost: a user guide chapter took 4–7 ms in the process and 0.6–0.9 s in the browser.

A fragment whose markup is the same for every viewer until the next deployment can be declared immutable (since 1.41.0):

this.addFragment( "help-overview", { title: "User Guide", path: "fragments/frame-help-overview.html", immutable: true } );

How it behaves:

  • Every reference carries an address. Every hx-get="/app/help-overview" in every fragment the framework serves, the shell included, is written hx-get="/app/help-overview?v=<version>". A hx-push-url="true" (or hx-replace-url) on the same element becomes hx-push-url="/app/help-overview", so the address bar shows the plain path.
  • A request for the current version is kept for good. It is answered private, max-age=31536000, immutable, and a revisit makes no request at all. A stale or missing v gets the normal revalidating answer.
  • One version covers every immutable fragment together. Chapters link to one another, so any change to any of them moves every address. A deployment that changes none of them keeps every cached copy.

The declaration is a promise the framework also checks:

  • Each response is compared with the markup its address was computed from. A fragment whose output depends on the request (the nonce placeholder, a CSRF token, anything transformHtml adds per request) never matches. It is served revalidating, and a warning is logged once.
  • A fragment with roles cannot be immutable. addFragment throws. The browser keeps the copy, not the session, so it would be served to whoever next uses that browser, without a role check.
  • Nothing is addressed while TI_WEB_APP_STATIC_CACHE_DISABLED=true, a development setting. A file edited under a running process would change its bytes without changing its address.

Only declare content that anyone signed in may see and that carries nothing about the viewer, such as help, documentation or a static reference page. A screen whose data arrives by a separate request gains nothing: its 304 is already cheap in bytes, and its template changes with releases just the same.

Timing a response

With serverTiming: true (or TI_WEB_SERVER_TIMING=true), every response carries a Server-Timing header (since 1.41.0), which the browser's developer tools show under Timing:

Server-Timing: app;dur=4.1;desc="Frankfurt am Main (WEUR, DE)", session;dur=2.4
  • app: from the request reaching the process to its headers being written.
  • session: how long reading the session took, which is the state-store round trip. It is absent for /static, which is served before the session.

A header already set, for example by a proxy that adds its own metrics, is appended to. Override describeInstance() in your TiWebServer subclass to give app a description, such as where a container platform placed the instance. It is read once, at start, reduced to printable ASCII, and quoted.

It is off by default: response timings are a small disclosure of how the server works, and a deployment chooses to make it.

Configure HTTPS for development

Use the mkcert tool to create a certificate for development.

Step 1: Install the tool:

choco install mkcert

Step 2: Install certificate authority:

mkcert -install

Step 3: Generate certificate files for localhost:

mkcert localhost 127.0.0.1 ::1

License

Apache-2.0 © Boris Kostadinov. See LICENSE.

Container liveness probe

bin/healthcheck.js asks a running server whether it is still serving and exits 0 only if it says yes. Point a Dockerfile HEALTHCHECK at it:

HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD ["node", "/app/node_modules/@ti-engine/web-framework/bin/healthcheck.js"]

It reads TI_WEB_USE_TLS through the same tools.toBool the server uses, so the probe's transport cannot drift from the server's, and calls /health — the unprotected route webHandlers.healthHandler serves. Writing this inline in a Dockerfile is the mistake it exists to prevent: an http:// URL hardcoded there reports a TLS-enabled container unhealthy forever, and Docker restarts a server that is answering correctly.

With TLS on it verifies the certificate rather than skipping verification, anchoring trust to the server's own certificate at TI_WEB_TLS_CERT_PATH and taking the name to check from that certificate — so a certificate issued for a public hostname still passes while the probe connects to 127.0.0.1. Set that variable when you terminate TLS inside the container. Without it there is nothing to anchor to and the probe falls back to establishing that the port accepts connections, which is weaker but neither disables verification nor restarts a healthy container.