@astroscope/node
v2.1.1
Published
Opinionated, cloud-friendly Node adapter for Astro: boot lifecycle, health probes, request logging, telemetry, CSRF and static serving run as plain code around server.listen()
Maintainers
Readme
@astroscope/node
Opinionated, cloud-friendly Node adapter for Astro: boot lifecycle, health probes, request logging, telemetry, CSRF and static serving run as plain code around server.listen().
What it does
- Boot lifecycle —
onStartupruns before the port opens,onShutdownafter in-flight requests drain (src/boot.ts) - Warmup — pages, middleware and actions are loaded in parallel with startup and awaited before
listen() - Health probes — Kubernetes-style liveness/readiness on a separate port, with registrable checks
- Request logging — pino at the native handler: real status code, response size, ttfb, aborted connections, static responses included
- Telemetry — OpenTelemetry NodeSDK, undici fetch instrumentation, runtime + host metrics, Prometheus reader
- CSRF protection — origin check for unsafe methods, with path exclusions
- Platform entry files — env loading →
src/config.ts→src/instrumentation.ts→src/log.ts→ boot, each picked up automatically when the file exists - Island preloading — each island's JS is preloaded in parallel instead of being discovered module by module, removing the hydration request waterfall (Island preloading)
- Pre-compressed static serving — build-time brotli/gzip variants, negotiated per
Accept-Encoding - Native mounts — http-native handlers (
oidc-provider, ACME) mounted on the adapter's server - Build tweaks — SSR sourcemaps, SSR effect stripping
- Image processing off unless configured — without an explicit
image.service, anyastro:assetsuse fails loudly instead of opening astro's on-demand sharp endpoint (Image processing) - Dev restart machinery — changes to the boot file or entry seams restart the dev server behind a holding page
- Dev island warmup —
.astrosources are scanned forclient:*components; their deps are pre-optimized and their module graphs warmed at server start, preventing "504 Outdated Optimize Dep" hydration failures from vite's lazy dep discovery - HTTPS for development —
SERVER_CERT_PATH/SERVER_KEY_PATHserve TLS directly for local runs of the built server (HTTPS); in production, terminate TLS at the ingress
What it does NOT do — beware
The adapter assumes a container behind a load balancer / reverse proxy (Kubernetes, Docker + ingress). Outside that setup, several defaults are wrong for you:
- Opens
0.0.0.0:9090in production — the health probe server. Meant for the kubelet; do not expose it publicly. - Opens
0.0.0.0:9464in production — the Prometheus metrics reader. Same: cluster-internal only. - Trusts any
Host/X-Forwarded-Host— setssecurity.allowedDomains: [{}](unless you set it yourself), because the reverse proxy is expected to control these headers. Without one, host header injection is possible. - Overrides Astro security and config defaults —
security.checkOrigin: false(the embedded CSRF middleware replaces it;csrf: falserestores Astro's check),build.redirects: false(redirects handled at runtime),trailingSlash: 'never'(only when yours is at the default'ignore'),astro:assetsdisabled whenimage.serviceis at its default (Image processing). - Standalone only. No middleware mode — the adapter always owns the server.
- No session driver. Astro sessions are unsupported unless you configure
session.driveryourself. - No health probes in dev. The health server only exists in production and
astro preview.
Usage
// astro.config.ts
import node from '@astroscope/node';
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: node(),
});// src/boot.ts — picked up automatically (or src/boot/index.ts)
import type { BootContext } from '@astroscope/node';
export async function onStartup(context: BootContext) {
// connect to databases, warm caches, schedule timers
}
export async function onShutdown() {
// close connections, clear timers
}astro preview runs the full production path (boot, warmup, health, static serving).
In dev, the boot file and the entry files below run through the adapter's own watch/restart machinery: changes to the boot file, its dependency graph, or the config/log files restart the dev server behind a holding page that releases when the new generation is ready.
Boot context access
Anywhere in server code (including libraries), getBootContext() returns the running server's BootContext — or undefined when no @astroscope/node server has booted in the process (unit tests, one-off scripts). Libraries can use it to adapt to dev mode without making consumers thread the flag through:
import { getBootContext } from '@astroscope/node/boot';
const dev = getBootContext()?.dev ?? false;It is stamped before the platform entry files and the boot lifecycle run, in both prod and dev (re-stamped per dev restart generation).
Platform entry files
Besides src/boot.ts, three more files are picked up automatically when they exist. In production they run in this order inside startServer(), before anything else; in dev they re-run per restart generation (instrumentation only once per process):
env loading → src/config.ts → src/instrumentation.ts → src/log.ts → boot → warmup → listenEnv loading (platform, position −1): CONFIG_PATH env var → ./.env → none. Existing process env vars win; each outcome is logged.
src/config.ts — schema + load (e.g. @entwico/zod-conf), nothing else; validation runs at import. On failure: in production the buffered logs are dumped to the console with the error and the process exits 1; in dev the boot gate keeps the holding page up with the error.
// src/config.ts
import { conf } from '@entwico/zod-conf';
import { z } from 'zod';
export const config = conf(z.object({ MONGO_URL: z.string() }));src/instrumentation.ts — extra instrumentation only (the standard bundle is a platform default, see Telemetry). Loaded once per process — dev restarts are in-process, so changes here need a full dev-server restart.
// src/instrumentation.ts
import type { InstrumentationContext } from '@astroscope/node';
export function register(ctx: InstrumentationContext) {
// register additional instrumentations, processors, exporters
}src/log.ts — pino logger options (a static object or a factory), never a logger instance. The platform constructs the logger itself, after instrumentation, and adds a mixin that stamps trace_id/span_id/trace_flags onto every entry when a span is active. This file runs after env loading and src/config.ts, so the options may safely read config.
// src/log.ts
import type { LoggerOptions } from '@astroscope/node/log';
export default {
base: { app: 'my-app' },
} satisfies LoggerOptions;Use the factory form when the options depend on the runtime context:
// src/log.ts
import type { LoggerOptionsFactory } from '@astroscope/node/log';
const factory: LoggerOptionsFactory = ({ dev }) => ({
level: dev ? 'debug' : 'info',
});
export default factory;Logging
The log proxy is request-aware: inside a request its entries carry the request bindings (reqId, req), outside they go to the root logger.
import { log } from '@astroscope/node/log';
log.info('handling request');
log.info({ userId: 123 }, 'user logged in');
log.error(err, 'operation failed');
const dbLog = log.child({ component: 'db' });Entries logged before the logger is constructed (env loading, config, instrumentation) are buffered (cap 100) and replayed once construction completes — the original timestamp is kept as a bufferedTime field. If startup dies before the logger exists, the buffer is dumped to the console together with the error.
Request logging happens at the native handler on finish/close: real status code, response size, ttfb, aborted-vs-completed, and the route pattern (fed back by an internal astro middleware). An incoming x-request-id header is passed through (and echoed on the response); otherwise a short id is generated.
Reporting the route yourself
The route comes from the route astro matched, which is wrong for a middleware that serves a request astro has no page for — one that rewrites via next(url), or answers itself. Astro matches /404 there, so the request is logged as /404 despite its 200, and every such request collapses into one /404 bucket in the request duration metric. overrideRequestRoute lets the middleware report what it actually served:
import { overrideRequestRoute } from '@astroscope/node/log';
export const onRequest: MiddlewareHandler = (ctx, next) => {
const page = lookupPage(ctx.url.pathname);
if (!page) return next();
overrideRequestRoute('/cms/pages/[id]');
return next(`/cms/pages/${page.id}`);
};This fixes the log line, the metric and the server span name together. Pass a templated label, not a concrete path, so metric cardinality stays bounded. An overridden route always wins over the one astro matched, regardless of middleware order.
The adapter itself emits exactly one info line on startup — server ready { host, port, health, bootMs, warmupMs, totalMs } — and draining / shutdown complete { drainMs } on the way down. Everything else is debug or error level.
Telemetry
node({ telemetry }) ships the standard bundle: NodeSDK, fetch instrumentation via undici (diagnostics-channel based — no --import needed), Node runtime + host metrics, and a Prometheus reader on 0.0.0.0:9464. Prod: on by default. Dev: off by default (opt in with telemetry: { dev: true }).
- server spans start at the native handler with W3C context extraction; the route pattern and span name are enriched by the internal middleware
- request metrics:
http.server.request.duration,http.server.active_requests,astro.action.duration; fetch metrics come from the undici instrumentation - the boot lifecycle gets
startup(withboot/warmup/listenchildren) andshutdown(withdrain/onShutdown) spans - trace exporters are driven by the standard
OTEL_*env vars; without a configured OTLP endpoint, trace exporting defaults tonone(no failing localhost exports) OTEL_SDK_DISABLED=trueturns the SDK off entirely
There is no helper for tracing server-render sections: measuring a subtree's render would require buffering it, which disables streaming. Wrap frontmatter awaits with tracer.startActiveSpan() instead — that's where the time lives, and it nests under the request span without touching the stream.
Health checks
Register checks from anywhere in server code — typically onStartup:
import { registerHealthCheck } from '@astroscope/node/health';
export async function onStartup() {
const db = await connectMongo();
registerHealthCheck({ name: 'mongo', check: () => db.ping() });
}- probes flip in order: live during startup, ready only after
listen(), draining begins the moment a shutdown signal arrives - a check fails by throwing or returning
{ status: 'unhealthy' }; failing required checks turnreadyzunhealthy (optional: truechecks don't affect it,timeoutdefaults to 5s) registerHealthCheckreturns an unregister function, but cleanup is optional: checks still registered afteronShutdownare removed automatically- when no health runtime is active (dev mode,
health: false), registration is a no-op — no dev/prod branching needed in boot files
Native mounts
For Node libraries that need the real (req, res) — oidc-provider's callback(), ACME challenge handlers — mount them on the adapter's server instead of faking Node objects inside astro middleware:
// src/boot.ts
import { mountNativeHandler } from '@astroscope/node/native';
export function onStartup() {
mountNativeHandler({ prefix: '/oidc', name: 'oidc' }, getOidcProvider().callback());
}- the handler owns the response completely: matched requests are dispatched before static serving and never reach astro middleware or rendering — machine endpoints (
/token,/introspection) need no csrf exclusions because they never meet the csrf middleware - the handler gets the real request and response: streaming bodies,
socket.remoteAddress, set-cookie arrays - requests stay inside request logging and tracing;
namebecomes the route label for logs, metrics and span names - identical in dev — the same registry is dispatched at the connect level, so there is no fake-host-header or dev/prod drift
- matching:
prefix(segment-aware, longest wins) or amatch(req)predicate consulted after prefixes, in registration order - returns an unregister function; mounts are cleared automatically after
onShutdown(and between dev restart generations) - handler errors are logged and answered with a 500 (unless the handler already sent headers)
Exclude patterns
@astroscope/node/excludes ships the shared exclude-pattern vocabulary for middlewares. Matching itself comes from @entwico/dash/match; ExcludePattern is the serializable subset of dash's StringPattern (exact / prefix / suffix / includes / pattern — no matcher functions, since adapter options cross a virtual-module boundary).
import { RECOMMENDED_EXCLUDES, shouldExclude, withExcluded } from '@astroscope/node/excludes';
// pre-defined sets: DEV_EXCLUDES (vite dev paths), ASTRO_STATIC_EXCLUDES (/_astro/, /_image),
// STATIC_EXCLUDES (favicon, robots.txt, ...), RECOMMENDED_EXCLUDES (dev + astro internals)
// in your own middleware
if (shouldExclude(ctx, [...RECOMMENDED_EXCLUDES, { exact: '/health' }])) return next();
// or wrap a third-party middleware without built-in exclude support
export const onRequest = withExcluded(someExternalMiddleware(), RECOMMENDED_EXCLUDES);For hot paths, compile the set once with createMatcher from @entwico/dash/match instead of scanning per request (the adapter's own request instrumentation does exactly that).
Island preloading
Without it, an island's JS loads as a chain: the browser fetches the component module, parses it, discovers its imports, fetches those, and so on. In production the adapter knows each island's chunks from the build and emits preload tags next to the island, so the browser fetches everything in parallel. Deferred islands (client:visible, client:idle, client:media) are preloaded just ahead of their hydration.
Always on in production; islands: false disables it.
Pre-compressed static serving
At build time, every compressible file in dist/client gets max-quality .br (brotli 11) and .gz (gzip 9) variants written next to it — variants that don't shrink the file are skipped. At request time the static handler negotiates Accept-Encoding and serves the best variant with the original's content-type, content-encoding, vary: accept-encoding and per-variant etags (304s included). Behind a caching proxy with per-encoding cache keys, the origin serves each asset once per encoding.
Embedded build tweaks
Always on, no configuration:
- SSR sourcemaps — the server bundle gets sourcemaps for readable stack traces; client bundles stay unmapped so browsers can't fetch source
- SSR effect stripping —
useEffect/useLayoutEffect/useInsertionEffectcallbacks are emptied in the SSR bundle (effects never run on the server), letting the bundler drop client-only dynamic imports (maplibre-gl, hls.js, …) from the server build and the docker image
Image processing
In SSR, astro's default sharp service makes /_image an on-demand decode+encode endpoint whose transform space is attacker-controlled: every distinct query string (w, h, q, fit, position, background) costs a full sharp run and is a distinct cache key, so no fronting cache can absorb it. Most SSR apps serve pre-generated variants from a CMS/CDN anyway, so the adapter keeps processing off unless you ask for it — governed by one option:
node({
imageService: 'auto', // 'on' | 'off' | 'auto'
});'auto'(default) — on when the astro config setsimage.serviceitself, off otherwise'on'— astro's default sharp service, no further config needed'off'— off even over an explicitimage.service
Off is loud, not silent: the adapter installs a service whose every method throws with an explanation, so any astro:assets use — <Image>, <Picture>, getImage(), markdown or content-collection images — fails at render/build time instead of quietly serving unoptimized bytes. The /_image endpoint is replaced with a 404 responder — to clients the endpoint simply doesn't exist. Apps without images never load any of it.
Options
node({
// boot lifecycle; false disables it. Skipped automatically when no boot file exists.
boot: {
entry: 'src/boot.ts', // default: src/boot.ts or src/boot/index.ts
watch: true, // dev: restart the dev server on boot-dependency changes
},
// Kubernetes-style probes on a separate port. Enabled by default in
// production (never active in dev); false disables.
health: {
host: '0.0.0.0', // falls back to HEALTH_HOST env, then 0.0.0.0 (kubelet probes hit the pod IP)
port: 9090, // falls back to HEALTH_PORT env, then 9090
},
// CSRF protection (origin check for POST/PUT/PATCH/DELETE with exclusions).
// Enabled by default; false keeps astro's built-in checkOrigin instead.
csrf: {
exclude: [{ exact: '/api/auth/backchannel-logout' }],
},
// request logging at the native handler. Enabled by default in production;
// false disables it (the log proxy keeps working).
logging: {
exclude: [{ prefix: '/internal/' }], // replaces RECOMMENDED_EXCLUDES
extended: false, // query/headers/client address (may capture sensitive data)
dev: false, // also log requests in dev (astro narrates them already)
},
// platform telemetry. Enabled by default in production, off in dev; false disables.
telemetry: {
exclude: [{ prefix: '/internal/' }], // replaces RECOMMENDED_EXCLUDES
prometheus: { host: '0.0.0.0', port: 9464 }, // false disables the reader
dev: false, // start the SDK in dev too (once per process)
},
// island dependency preloading (production-only); false disables it
islands: false,
// on-demand image processing: 'auto' = on only when image.service is set
// in the astro config (see the Image processing section)
imageService: 'auto',
bodySizeLimit: 1024 * 1024 * 1024, // request body limit in bytes
shutdownTimeout: 10_000, // ms to wait for in-flight requests on shutdown
});HTTPS
The built server serves plain HTTP — in production, TLS is expected to terminate at the ingress. For local runs of the built server (e.g. auth flows that require a secure origin), set SERVER_CERT_PATH and SERVER_KEY_PATH to serve HTTPS directly — the same contract as @astrojs/node:
SERVER_CERT_PATH=./cert/tls.crt
SERVER_KEY_PATH=./cert/tls.keyBoth must be set — startup fails if only one is present. Since env files load before the server starts listening, the variables may live in .env (or the file pointed to by CONFIG_PATH) instead of the shell environment.
This only affects the built server. The dev server is Vite's — configure vite.server.https in astro.config for HTTPS in dev.
Environment variables
| Variable | Effect |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| HOST / PORT | Override the listen address at runtime |
| SERVER_CERT_PATH / SERVER_KEY_PATH | Serve HTTPS with the given certificate/key (see HTTPS) |
| HEALTH_HOST / HEALTH_PORT | Override the health probe address (when not set in options) |
| CONFIG_PATH | Env file to load at startup (falls back to ./.env) |
| OTEL_EXPORTER_PROMETHEUS_HOST / OTEL_EXPORTER_PROMETHEUS_PORT | Override the Prometheus reader address |
| OTEL_SDK_DISABLED=true | Disable the telemetry SDK entirely |
| standard OTEL_* | Exporter/resource configuration (e.g. OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME) |
| ASTROSCOPE_NODE_AUTOSTART=disabled | Build the entry without starting the server (exports startServer()) |
Shutdown sequence
On SIGTERM/SIGINT:
- readiness probe starts failing,
drainingis logged - the server stops accepting connections and waits up to
shutdownTimeoutfor in-flight requests onShutdownrunsshutdown complete { drainMs }is logged, the health server stops, telemetry flushes, the process exits with code 0
License
MIT
