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

@enixcode/light-run

v0.7.0

Published

Run a Docker container from an HTTP request. Thin wrapper around light-runner.

Downloads

72

Readme

Experimental - do not use in production. APIs, defaults and on-disk layout can still change without notice. Pin a commit SHA if you depend on it today.


Ecosystem

light-run is the HTTP layer in a family of small, composable tools.

| Project | Role | Status | | --------------- | ------------------------------------------------------------ | ------------- | | light-runner | Docker execution SDK - one container, exit code, files | released | | light-run | HTTP wrapper around light-runner | this repo | | light-process | DAG orchestration on top of light-run | released |

Use light-runner when you already have a folder on disk. Use light-run when you want to post files + an image + a command over HTTP.


Install

npm install -g @enixcode/light-run
# or
npm install @enixcode/light-run      # use as a library

Published under the @enixcode npm scope because the unscoped name collides with an unrelated existing package. The CLI binary stays light-run and the GitHub repo stays enixCode/light-run.

Requirements

  • Node.js >= 24
  • A running Docker daemon on the host (Docker Desktop, dockerd, Lima, OrbStack, ...)

Quick start

1. Start the server

light-run serve --token $(openssl rand -hex 32)

Or as a library:

import { createServer } from '@enixcode/light-run';

const server = await createServer({
  token: process.env.LIGHT_RUN_TOKEN,
  logger: true,
});
await server.listen({ port: 3000, host: '0.0.0.0' });

Or with Docker Compose (dev):

cp .env.example .env           # set LIGHT_RUN_TOKEN if you want auth
npm run dev                    # = docker compose up --build
# -> server on http://localhost:3001

The compose file mounts the host Docker socket so light-runner can spawn workload containers as siblings on the host daemon, and bind-mounts ./.artifacts so extracted files are inspectable from the host.

2. Post a run

curl -X POST http://localhost:3000/run \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "alpine:3.19",
    "entrypoint": "sh main.sh",
    "files": { "main.sh": "echo hello > /app/out.txt" },
    "extract": ["/app/out.txt"],
    "networks": ["none"],
    "timeout": 30000
  }'

You get back the final run state once the container exits:

{
  "id": "light-runner-3f9c2a1b4d5e",
  "status": "succeeded",
  "startedAt": "2026-04-20T10:00:00.000Z",
  "finishedAt": "2026-04-20T10:00:03.421Z",
  "exitCode": 0,
  "durationMs": 3421,
  "artifacts": [
    { "path": "out.txt", "bytes": 6, "type": "file" }
  ]
}

Pass "detached": true to get 202 Accepted with an id immediately, then poll GET /runs/:id or receive a signed callback on callbackUrl.

3. Download artifacts

curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/runs/$ID/artifacts/out.txt

API

All endpoints except /health require Authorization: Bearer <token> when the server is started with a token. Without a token, every route is open (the CLI prints a warning at startup).

| Method | Path | Description | | ------ | ------------------------------- | ---------------------------------------------------------- | | GET | /health | Liveness + Docker daemon reachability (no auth). 503 if Docker is down. | | POST | /run | Start a run. Sync by default, detached: true returns 202.| | GET | /runs | List tracked runs | | GET | /runs/:id | Full state of one run | | POST | /runs/:id/cancel | Cancel a running execution (SIGKILL) | | POST | /runs/:id/stop | Graceful stop: SIGTERM then SIGKILL. Body { signal?, grace? } | | POST | /runs/:id/pause | Pause (freeze) a running container | | POST | /runs/:id/resume | Resume a paused container | | DELETE | /runs/:id | Remove a terminal run + its artifact folder | | GET | /runs/:id/artifacts | List files extracted from the run | | GET | /runs/:id/artifacts/* | Download a file (or list a subdirectory) | | POST | /networks | Create a Docker network. Body { name, driver?, iccEnabled?, exclusive?, labels?, ipam? }. 201 { name, created } | | GET | /networks/:name | Network existence. 200 { name, exists } | | DELETE | /networks/:name | Delete a network. 204, or 409 if it still has active endpoints | | POST | /networks/cleanup | Remove orphan light-runner-* networks. Body { prefix?, maxAgeMs? }. 200 { removed } |


Request body

POST /run accepts a JSON body validated by Zod (src/schemas.ts).

{
  image: string;                      // Docker image reference (required)
  files: Record<string, string>;      // relative path -> text content (required, >= 1 entry)
  entrypoint?: string;                // shell command, executed via "sh -c"
  run?: string[];                     // build-time RUN steps baked into a cached image
  input?: unknown;                    // JSON piped to stdin (sync runs only)
  timeout?: number;                   // ms, max 60 * 60 * 1000
  networks?: string[];                // first is primary (NetworkMode), rest connected after; ["none"], ["my-net"], ...
  workdir?: string;                   // working directory inside the container
  env?: Record<string, string>;       // env vars (name must match [A-Za-z_][A-Za-z0-9_]*)
  extract?: string[];                 // container paths to pull back after exit
  detached?: boolean;                 // if true, respond 202 and run in background
  callbackUrl?: string;               // detached only: POSTed final RunState
  callbackSecret?: string;            // detached only: HMAC-SHA256 signs callback body
}

File paths in files:

  • must be relative (no leading /)
  • cannot contain .. segments
  • max 1024 characters

extract paths are container-absolute (e.g. /app/out.txt). Extracted files land in an internal artifact directory and are served via GET /runs/:id/artifacts/* - clients never specify a host destination.

Detached + callback

curl -X POST http://localhost:3000/run \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "image": "alpine:3.19",
    "entrypoint": "echo done",
    "files": { "x": "" },
    "detached": true,
    "callbackUrl": "https://my-app.example.com/hook",
    "callbackSecret": "a-secret-of-16-chars-or-more"
  }'

When the run finishes, light-run POSTs the final RunState as JSON to callbackUrl with header X-Light-Run-Signature: sha256=<hex>, where <hex> = HMAC_SHA256(secret, rawBody).


CLI

light-run - HTTP wrapper around light-runner

Usage:
  light-run serve [options]

Options:
  --port <n>         Listen port (default 3000, env LIGHT_RUN_PORT)
  --host <h>         Listen host (default 127.0.0.1, env LIGHT_RUN_HOST)
  --token <t>        Bearer token required on every route except /health
                     (env LIGHT_RUN_TOKEN; omit to leave open)
  --body-limit <n>   Max POST body size in bytes (default 10485760 = 10 MiB,
                     env LIGHT_RUN_BODY_LIMIT). Each request is parsed in
                     memory, so a big cap is a memory-per-request cost.
  --version, -v      Print the version and exit
  --help, -h         Show this message

Shared types with light-runner

light-run is a thin HTTP boundary over light-runner - the two packages share several field shapes (image, timeout, networks, env, workdir, input, extract semantics). Rather than redefine everything, light-run re-exports the 1:1 types directly from light-runner:

import type { Runtime, RunnerOptions, ExtractResult } from '@enixcode/light-run';
// identical to `import type { ... } from 'light-runner'`

The Zod schema for RunRequest cannot literally inherit from a TypeScript interface (Zod lives at runtime, interfaces at compile time), so the shared fields are duplicated structurally. A compile-time alignment check in src/schemas.ts fails the build if light-runner ever widens or tightens any of those shared shapes - drift is caught, never silent.


Security

light-run sits on top of light-runner, which means every run inherits the hardened defaults of that SDK (dropped capabilities, no-new-privileges, pids / memory / CPU caps, isolated network). See the light-runner security model.

On top of that, the HTTP layer adds:

  • Bearer token with timing-safe comparison on every route except /health. Set via --token or LIGHT_RUN_TOKEN. Starting without a token prints a warning.
  • Body validation via Zod on every request - no free-form fields reach the runner.
  • File-map validation: relative paths only, no .. segments, max 1024 chars each.
  • Path-traversal guard on GET /runs/:id/artifacts/*: literal .. rejected, and the resolved host path is asserted to stay inside the run's artifact directory.
  • Body limit: 10 MiB default. Configurable three ways: createServer({ bodyLimit }) for library use, --body-limit <bytes> on the CLI, or LIGHT_RUN_BODY_LIMIT env var. This is a light-run cap - light-runner reads from disk and does not see the HTTP body.

Terminate TLS at a reverse proxy

Do not expose light-run directly on the public internet. Run it behind Caddy, nginx, Traefik, or a managed TLS terminator.

What it does not cover

No rate limiting, no concurrency cap, no repo fetch. Kernel exploits, runc CVEs, side-channel attacks are out of scope - for genuinely hostile code, configure light-runner with a safer runtime (e.g. gVisor).

No request/result caching, no content-addressable file store, no memoization. light-run is stateless past the live artifact directory - deduplication and workflow memory live in light-process, one layer up.


Storage

Artifacts are kept under ~/.light-run/artifacts/<run-id>/ on the host, where <run-id> is the light-runner container name (e.g. light-runner-3f9c2a1b4d5e). Temporary working directories under os.tmpdir() are cleaned as soon as the container exits.

Run state is persisted by light-runner in its state directory (LIGHT_RUNNER_STATE_DIR, default ~/.light-runner/state, one JSON per run). light-run is a stateless projection over it: GET /runs and GET /runs/:id read listStates / readState, so a server restart no longer forgets runs. Fields like status, exitCode, durationMs, and artifacts are recovered from disk. The logs array is in-memory only and is lost on restart (use the run's artifacts for persistent output). A periodic maintenance sweep (see below) reconciles runs left running by a crashed process to failed and garbage-collects old terminal state files. Lifecycle controls (cancel / stop / pause / resume) on a still-running detached run survive a restart by re-attaching to the container (DockerRunner.attach).

Auto-eviction

After every finished run, light-run scans the artifact root. If the total size exceeds the cap, the oldest run directories (by creation time) are removed until the total is back under the cap. Running runs and the run that just finished are never evicted - the client may still be about to download them. When a directory is evicted, the matching run state file is also dropped (subsequent GET /runs/:id returns 404).

Periodic maintenance

Per-run resources are freed the instant a run ends (the container and its volume by light-runner, the tmpdir by light-run). A periodic sweep reclaims what crashes, TTLs and size caps leave behind. light-run serve runs it on boot, hourly, and at shutdown; library embedders call runMaintenance() on their own schedule. Every step is best-effort and isolated, so one daemon hiccup never stops the others, and the reclaim policies (ages, TTLs, byte budgets) live in light-runner:

  • States - runs left running by a crashed process are reconciled to failed; terminal state files past the size budget are evicted.
  • Containers + volumes - orphans left by a crashed host are reaped (reapOrphans, default older than 5 min).
  • Cache images - run[]-derived build-cache images are evicted (default 7 days since last use).
  • Networks - run-scoped light-process networks (lp-*) with no attached container are removed (default older than 30 min).
  • Dangling images - untagged <none> image layers left by re-pulls and rebuilds are pruned. Tagged base images are never touched.

Environment variables

| Variable | Default | Purpose | | ---------------------------------- | -------------------------------- | ---------------------------------------------------------------- | | LIGHT_RUN_ARTIFACTS_DIR | ~/.light-run/artifacts | Override where artifact directories are stored. | | LIGHT_RUNNER_STATE_DIR | ~/.light-runner/state | light-runner's run-state directory. light-run reads it as its source of truth for GET /runs; restart recovery and GC operate on it. | | LIGHT_RUN_MAX_ARTIFACTS_BYTES | 21474836480 (20 GiB) | Total bytes across all run artifact dirs before auto-eviction kicks in. | | LIGHT_RUN_BODY_LIMIT | 10485760 (10 MiB) | Max POST body size (CLI). Each request is parsed in memory. | | LIGHT_RUN_TOKEN | unset | Bearer token required on every route except /health. | | LIGHT_RUN_PORT | 3000 | Listen port (CLI). | | LIGHT_RUN_HOST | 127.0.0.1 | Listen host (CLI). | | OTEL_EXPORTER_OTLP_ENDPOINT | unset | If set, enables OpenTelemetry. Sends traces + metrics via OTLP/HTTP to this URL (e.g. http://localhost:4318). | | OTEL_SERVICE_NAME | light-run | Service name attached to every emitted span/metric. | | LIGHT_RUN_OTEL_DEBUG | unset | Set to 1 to enable OpenTelemetry with a ConsoleSpanExporter instead of OTLP. Spans are printed to stdout (no metrics exported). Useful for local smoke tests without a Collector. | | LIGHT_RUN_VERSION | package.json version | Overrides the service.version attached to every span/metric. Defaults to the build's package.json version. |

Unset LIGHT_RUN_TOKEN leaves the server open (the CLI prints a warning). Unset or invalid LIGHT_RUN_MAX_ARTIFACTS_BYTES falls back to the 20 GiB default. Explicit DELETE /runs/:id also removes the artifact directory immediately.


Observability (OpenTelemetry)

light-run ships with OpenTelemetry support out of the box. By default the SDK is constructed but never started, so there is zero runtime cost when you do not want tracing. Setting OTEL_EXPORTER_OTLP_ENDPOINT (or LIGHT_RUN_OTEL_DEBUG=1) turns it on.

What you get

  • HTTP server spans for every incoming request (GET /health, POST /run, GET /runs/:id/artifacts/*, ...) via @fastify/otel (the official Fastify-team plugin). Route name, method, status code, and exceptions are recorded.
  • docker.run + sub-spans emitted by light-runner whenever a container is spawned. The trace context (W3C traceparent) propagates automatically so the docker spans nest under the originating HTTP server span.
  • Auto-instrumentations for node:http outbound calls, DNS, undici/fetch, and a few others via @opentelemetry/auto-instrumentations-node. The noisy instrumentation-fs is disabled.
  • OTLP metrics exported on the same endpoint when OTEL_EXPORTER_OTLP_ENDPOINT is set.

Local smoke test (no Collector needed)

LIGHT_RUN_OTEL_DEBUG=1 light-run serve --port 3001 --token dev
curl -H "Authorization: Bearer dev" http://127.0.0.1:3001/runs
# stdout prints a span tree: dns.lookup, GET /runs, handler - fastify -> @fastify/otel, ...

Production setup with an OTel Collector

export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.internal:4318
export OTEL_SERVICE_NAME=light-run-prod
light-run serve --port 3001 --token "$LIGHT_RUN_TOKEN"

The Collector receives traces + metrics over HTTP/protobuf on port 4318 and forwards to your backend of choice (Tempo, Jaeger, Honeycomb, Grafana Cloud, SigNoz, Datadog, etc.).

ESM caveat

For full canonical setup including monkey-patching of node:http, launch with the ESM loader:

node \
  --experimental-loader=@opentelemetry/instrumentation/hook.mjs \
  --import ./dist/src/instrumentation.js \
  ./dist/src/bin/light-run.js serve

Without the loader, @fastify/otel still works (it is a Fastify plugin, no monkey-patching needed) so server-side spans are emitted correctly. Outbound HTTP from light-run (e.g. the detached callback) will not be auto-traced.


Docs

  • Landing + guides + API reference: enixcode.github.io/light-run, a Fumadocs (Next.js static export) site under website/, sharing the light-landing-page component with the rest of the ecosystem.
  • Local build:
    npm run docs:api     # regenerate the API reference into website/content/docs/api
    npm run docs:dev     # docs:api + the Fumadocs dev server
    npm run docs:build   # docs:api + the static export (website/out)
  • API reference is generated from src/index.ts by TypeDoc (markdown) and post-processed for Fumadocs by scripts/gen-fumadocs-api.mjs. The landing content lives in website/src/app/(home)/data.tsx; the guides in website/content/docs/.
  • Pre-commit hook: npm run setup:hooks installs scripts/hooks/pre-commit, which regenerates and re-stages website/content/docs/api/ whenever staged files include something under src/.
  • Deploy: .github/workflows/docs.yml builds website/ and publishes to GitHub Pages on every push to main. The Pages source must be set to "GitHub Actions" in the repo settings.

Testing

npm test              # clean + build + node --test (60 e2e tests, skipped if Docker daemon absent)
npm run test:docker   # same inside a container with the host Docker socket mounted

Tests are split across six files, all using Fastify's inject() with real light-runner containers against the host Docker daemon:

  • test/e2e/server.test.ts (13 tests) - core surface: auth, sync + detached runs, artifacts, cancel, delete, list, storage auto-eviction.
  • test/e2e/languages.test.ts (8 tests) - Python / Node / shell real workloads: stdin + JSON compute, multi-file project with local import, crypto.createHash determinism, env vars, build-time run step, nested directory extraction, multi-MB binary streaming, unicode round-trip.
  • test/e2e/adversarial.test.ts (17 tests) - failure paths: malformed/wrong/empty Bearer, Zod rejects (absolute path, .., empty files, invalid env name, oversize image/entrypoint), 413 Payload Too Large on body-limit breach, .. artifact traversal, timeout kills a sleep 60 in <10 s, network: 'none' actually blocks outbound, shell metacharacters in env values passed literally (no command injection).
  • test/e2e/lifecycle.test.ts (9 tests) - run lifecycle: real GET /health Docker ping, pause then resume then stop on a live detached run, 404 on unknown or finished runs, 400 on invalid stop body, 401 without Bearer.
  • test/e2e/networks.test.ts (8 tests) - Docker network CRUD: create then exists then delete round-trip, idempotent create + delete, IPAM subnet, 400 on missing name, 401 without Bearer, POST /networks/cleanup removed count.
  • test/e2e/persistence.test.ts (5 tests) - light-runner as source of truth: run id is the container name, GET /runs/:id served from the state dir, a fresh server instance recovers runs from disk (restart proxy), DELETE removes the state file, GET /runs lists from disk.

Contributing

Short-lived feature branches, squash-merged into main. No direct commits on main. Tags v* trigger the npm publish.

  • CONTRIBUTING.md - branching model, PR guidelines, code style, local testing flow
  • RELEASE.md - tag-based release, OIDC trusted publishing, guards, recovery paths

License

MIT