@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
- Path redirects — duplicate slashes (
//about) and a trailing slash that contradictstrailingSlash(/about/) redirect to the canonical URL, in dev as in prod (astro's dev server answers the latter with a 404 page instead) - 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
- 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. - Trusts
X-Forwarded-For— anything that keys on the client address uses the header as received, because the reverse proxy is expected to set it. Exposed directly, a client can send its own. - 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). - Limits request bodies to 1 MiB — every request astro handles, pages and endpoints included, not only actions as astro does. An upload route needs
bodySizeLimitraised (native mounts are not covered). - 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).
Lifecycle events
A library that needs to run at boot or shutdown without a line in the site's src/boot.ts listens on @astroscope/node/events: on(event, handler) and off(event, handler) for beforeOnStartup, afterOnStartup, beforeOnShutdown and afterOnShutdown. Handlers get the BootContext, run in registration order and are awaited one by one; afterOnShutdown runs even when onShutdown threw.
import { on } from '@astroscope/node/events';
on('beforeOnShutdown', async () => {
await flushPendingWork();
});
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 → listen
Env loading: 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 zc from '@entwico/zod-conf';
export const config = zc.define({ MONGO_URL: zc.env('MONGO_URL').string() }).load({ env: process.env });
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. An incoming x-request-id header is passed through (and echoed on the response); otherwise a short id is generated.
Responses the adapter sends itself get a route label of their own: static for a file served from the client dir (public assets, prerendered pages, 200 and 304 alike), duplicate-slashes and trailing-slash for the path redirects, body-limit for a refused body, and a native mount's name for observability.
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 and measured as /404. overrideRequestRoute lets the middleware report what it actually served:
import { overrideRequestRoute } from '@astroscope/node/log';
import type { MiddlewareHandler } from 'astro';
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
url.querycarries the query string with the values ofredactedQueryParamsreplaced byREDACTED, by default the signed-url credentials of OpenTelemetry's semantic conventions (exported asOTEL_REDACTED_QUERY_PARAMS); an application secret in a query string (an oauthcode, a resettoken) needs adding. The same applies to thequeryof extended request logs- request metrics:
http.server.request.duration,http.server.active_requests,astro.action.duration(astro.action.nameis the registry name, any other/_actions/path counts asunknown); fetch metrics come from the undici instrumentation astro.render.failures{http.route} counts renders that failed after the status went out; the response is truncated on the wire, the request log line readsrequest truncatedat error level and the server span carriesastro.response.truncated- 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- the other astroscope packages record through the same SDK and are no-ops without it — their instruments are documented with them:
@astroscope/react,@astroscope/wormhole,@astroscope/i18n
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() from @opentelemetry/api 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, so machine endpoints need no csrf exclusions
- 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
- 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)
Guards
Astro has no route guards: middleware is global and sees only the route pattern, and a page's exports are not loaded before it runs. @astroscope/node/guards puts protection where the request lands, with the types to match: a guard either denies or proves something, and what it proves is required on locals for the guarded code only. App.Locals keeps the honest shape (user?: User).
// src/guards.ts
import { defineGuard, deny } from '@astroscope/node/guards';
export const authenticated = defineGuard({
name: 'authenticated',
check: (ctx) => ({ user: ctx.locals.user ?? deny('UNAUTHORIZED') }),
page: (ctx) => ctx.redirect('/login'),
});
// runs after `authenticated`, with `ctx.locals.user` already required
export const admin = authenticated.extend({
name: 'admin',
check: (ctx) => {
if (!ctx.locals.user.roles.admin) deny('FORBIDDEN');
},
page: (ctx) => ctx.rewrite('/404'),
});
deny(code, message?) throws an ActionError; the returned object is the proof. A guard may also load something once and hand it over typed (check: async (ctx, { projectId }) => ({ project: await loadProject(...) })), declaring the request fields it needs in input (a zod shape).
Actions take guards in place of Astro's defineAction (the no-astro-define-action lint rule enforces the import). Guards run after input validation; fields they declare are merged into the schema and stripped again before the handler:
import { defineAction } from '@astroscope/node/guards';
import { z } from 'astro/zod';
import { admin } from '../guards';
export const server = {
rename: defineAction({
input: z.object({ projectId: z.string(), name: z.string() }),
guards: [admin],
handler: async (input, ctx) => {
ctx.locals.user; // User, not User | undefined
},
}),
};
Endpoints wrap the same way. The body is read only when a guard in the list declares input, then its fields come from a json or form body:
import { defineRoute } from '@astroscope/node/guards';
import { admin } from '../../guards';
export const POST = defineRoute({
guards: [admin],
handler: async (ctx) => Response.json({ by: ctx.locals.user.id }),
});
A page's frontmatter is the handler, so it asks and returns:
---
import { guard } from '@astroscope/node/guards';
import { admin } from '../guards';
const { denied, user } = await guard(Astro, [admin]);
if (denied) return denied;
---
After the check user is required, before it optional.
A denial answers per the request, on every surface: a browser navigation (text/html in Accept) gets the denying guard's page response, the login redirect above or a 404 rewrite, or the status with the message when the guard has none; any other caller gets { error: { code, message } } with the status. A denial raised by a parent in an extend() chain keeps the parent's page handler.
Whole areas are guarded from the middleware, with the same guards and the same denial responses:
// src/middleware.ts
import { createGuardMiddleware } from '@astroscope/node/guards';
import { sequence } from 'astro:middleware';
import { admin, authenticated } from './guards';
import { sessionMiddleware } from './session';
export const onRequest = sequence(
sessionMiddleware,
createGuardMiddleware([
{ match: [{ prefix: '/app' }], guards: [authenticated] },
{ match: [{ prefix: '/admin' }], guards: [admin] },
]),
);
Patterns are the exclude-pattern vocabulary (prefix, exact, suffix, includes, pattern). It is defence in depth, not a replacement for the page-level call: a middleware cannot narrow locals, so pages inside the area still call guard(Astro, [...]) for the typed proofs; a guard that passed runs once per request.
A server island renders through astro's own endpoint, outside a prefix rule for the page's path: a component that must not render for everyone calls guard(Astro, [...]) in its own frontmatter.
Available guards
The package ships the guards that depend on the request alone; the ones that depend on your session shape (authenticated, admin) stay in the app, and a package ships the guard for its own concern (captcha() from @astroscope/cap/server).
rateLimit({ max, window, key?, name? })
A fixed window per key, in memory and per process: the max-th request within window ms still passes, the next one is denied with TOO_MANY_REQUESTS (429) until the window ends. The key is the client address by default; key(ctx) picks another subject (a user id, a target email) or returns undefined to exempt a request. Per process means per replica.
Put it first in a list, so a flood is refused before a guard that costs something runs:
import { captcha } from '@astroscope/cap/server';
import { rateLimit } from '@astroscope/node/guards';
guards: [rateLimit({ max: 5, window: 60_000 }), captcha()],
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).
import { RECOMMENDED_EXCLUDES, shouldExclude, withExcluded } from '@astroscope/node/excludes';
// pre-defined sets: DEV_EXCLUDES (vite dev paths), ASTRO_STATIC_EXCLUDES (/_astro/, /_image, /_i18n/),
// 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.
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 the preloading only.
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).
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 for React islands lives in @astroscope/react.
Image processing
Astro's default sharp service makes /_image an on-demand endpoint anyone can drive, so the adapter keeps image processing off unless you ask for it:
node({
imageService: 'auto', // 'on' | 'off' | 'auto'
});
'auto'(default) — on when the astro config sets animage.serviceother than astro's default sharp, off otherwise. An explicitsharpImageService()needs'on''on'— astro's default sharp service, no further config needed'off'— off even over an explicitimage.service
Off is loud: any astro:assets use (<Image>, <Picture>, getImage(), markdown images) throws with an explanation, and /_image answers 404.
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 (redacted per redactedQueryParams)/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',
// query parameters redacted in server spans and extended request logs; replaces
// OTEL_REDACTED_QUERY_PARAMS (the signed-url credentials of the OpenTelemetry conventions)
redactedQueryParams: [...OTEL_REDACTED_QUERY_PARAMS, 'code', 'token'],
bodySizeLimit: 1024 * 1024, // request body limit in bytes: 413 when announced larger, cut off when chunked
shutdownTimeout: 10_000, // ms to wait for in-flight requests on shutdown; also bounds onShutdown and the telemetry flush, each
keepAliveTimeout: 120_000, // ms an idle keep-alive connection stays open; must exceed the reverse proxy's idle timeout
});
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.key
Both 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 - the
beforeOnShutdownlisteners run, thenonShutdown, then theafterOnShutdownlisteners — bounded byshutdownTimeout; a hanging phase is logged and left behind shutdown complete { drainMs }is logged, the health server stops, telemetry flushes (bounded the same way), the process exits with code 0
License
MIT