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

local-serverless-stack

v1.0.1

Published

Local control plane for serverless development — an in-process AWS emulator, no Docker required

Readme

Local Serverless Stack (LSS)

npm version

Local control plane for serverless development — with its own in-process AWS engine. No Docker, no container, no auth token.

LSS provides a unified local development environment for serverless microservices: one orchestrator provisions and serves every AWS resource your services declare, so a monorepo needs a single local stack instead of one emulator per service. Measured on a synthetic 40-service / 400-lambda / 400-table monorepo: 128 MB resident and a 2 s boot.

flowchart LR
    DEV[Developer] -->|npx lss start| CLI[CLI<br/>bin/cli.js]
    CLI --> ORCH["Orchestrator :14566<br/>REST API + dashboard + AWS wire"]
    SVC[Your services<br/>serverless.yml] -->|lss register / onboarding| ORCH
    ORCH -->|provision + events| SELF[Self engine<br/>in-process, same listener]
    ORCH --> RT[Lambda runtime + API Gateway emulation<br/>your handlers, ports 30xx/130xx]

Features

Every feature LSS ships, grouped by area. Each one expands into how it works (the real mechanism and defaults), where it lives (the files that own it) and why it exists (the problem it solves). 86 features in nine areas — start with Quick Start if you just want it running.

⚠️ Keep this inventory alive. A new capability MUST be added here and to docs/FEATURES.md (the canonical inventory, with the test that asserts each promise) — plus docs/SELF_ENGINE.md when it touches the engine. A feature that isn't listed is treated as a feature that doesn't exist.

Jump to an area: CLI · Service registration & discovery · Resource provisioning · Lambda runtime & API Gateway · Self engine · Explorers & testing primitives · Dashboard · Client & MCP · Configuration & isolation

CLI

How it works. start checks the PID file first: if the recorded PID answers kill(pid, 0) it just reprints the URLs and returns; a dead PID file is deleted and boot continues. It resolves dist/server/index.js, opens the log file in append mode and spawns node detached with stdio piped to that file, unrefs the child and writes its PID. Ports come from config plus env (serverPort and the self engine both default to 14566; the DynamoDB proxy is off, port 8000 when enabled), and they are passed down as PORT, LSS_ENABLE_DYNAMO_PROXY, LSS_DYNAMO_PROXY_PORT. Two seconds later it re-probes the PID: alive prints "Service is running", dead prints the failure, the tail of the log, who holds the port — and exits 1, because a daemonized boot discovers a busy port only after the CLI has printed a PID, and a step that fails must not report success. lss start --wait[=<seconds>] (default 30) goes further and blocks until GET /api/health answers, which is what a sequential chain needs so the next register isn't run against a stack that never came up. stop sends SIGTERM and then polls kill(pid, 0) every 200 ms for up to 10 s before deleting the PID file — without that wait an immediate lss start would race the dying process for the port and die with EADDRINUSE. status prints PID, server URL, self engine URL, proxy URL (only when enabled) and log path, and self-heals a stale PID file. When there is no PID file for this project it asks GET /api/config who is on the port and reports port 14566 is in use by the orchestrator of /other/project instead of NOT RUNNING — under --net=host the port belongs to the whole machine, and a status that says "not running" about a port answering 200 sends you looking in the wrong place.

Where it lives. bin/cli.js — startOrchestrator(), waitForHealthy(), reportStartFailure(), probePortOwner(), stopOrchestrator(), waitForExit(), showStatus(), runtimePaths(). src/server/index.ts — the orchestrator entry point that gets spawned.

Why it exists. One command replaces "start LocalStack, then start one serverless-offline per service". Because the process is detached and PID-tracked, the stack survives closing the terminal, and status answers the "is it up?" question — and, when the answer is no, the "then who has my port?" question — without grepping ps.

How it works. It requires a running orchestrator (PID file present, otherwise it prints the start hint and exits 1), then resolves each positional argument to an absolute path — defaulting to . when none is given — and skips (counting as failed) any directory that does not exist. For each path it POSTs { servicePath } to /api/services/register on the server port; the orchestrator does everything else: packaging on demand via autoPackage, then reading name, region and custom.lss ports out of the packaged serverless-state.json. Each success prints resource/function/route counts plus any warnings the server returned — including everything provisioning could not do (a resource that failed, a rule waiting for a bus, HTTP routes with no apiPort), which used to reach the daemon log only; if any target failed, the process exits 1.

Where it lives. bin/cli.js — registerServices(), postJson(), allPositionals(). src/server/routes/services.ts — POST /register, which validates the path and delegates to the registrar.

Why it exists. 0.x shipped a serverless-lss plugin that hooked into deploys; it was retired in 1.0. Registration is now a plain HTTP call, so a service joins the stack from any shell, a Makefile or CI without adding a dependency to its serverless.yml.

How it works. It calls GET /api/services/scan, which walks the project root (skipping node_modules, .git, .serverless, .lss, .build, .esbuild, plus any directory listed in scanIgnore) and returns one row per service. Each row prints three flags — registered (known to this orchestrator), installed (a node_modules is resolvable from the service dir upward to the root, so packaging can run without an install) and packaged (.serverless/cloudformation-template-update-stack.json exists) — followed by the resolved api:/invoke: ports when known. A second line shows the effective package command for that service (per-service servicePackaging, else the global one), and any scan warnings are listed underneath — the "not packaged" one worded from the effective autoPackage, so it never promises packaging a config with autoPackage: false will refuse. Empty results print the root that was searched.

Where it lives. bin/cli.js — scanServices() and the row formatting. src/server/routes/services.ts — GET /scan, which overlays runtime/packaging config on the raw scan. src/server/services/service-scanner.ts — the filesystem walk and the installed/packaged detection.

Why it exists. In a monorepo of 15+ services, the first question is "which of these does the stack already know about, and which one is missing an npm install or a serverless package?". scan answers it in one screen and mirrors exactly what the dashboard onboarding shows.

How it works. With no argument it POSTs {} to /api/seeds/run and every <table>.json in seedsDir (default ./seeds) is applied; with an argument only that table runs. Each result prints as inserted-count or skipped-with-reason. When any skip reason contains "does not exist in the engine", the CLI follows up with GET /api/seeds and prints both sides of the comparison: the seed files it inspected and the tables actually live in the engine. If there are live tables it tells you the file name must match the CloudFormation TableName exactly and to check for a prefix/suffix mismatch; if there are none it concludes the stack was never provisioned and prints the lss start → serverless deploy steps. The diagnostic is best-effort — a failure there is reported but never fails the seed.

Where it lives. bin/cli.js — runSeed(), printSeedRunResults(), printSeedMismatchDiagnostic(). src/server/routes/seeds.ts — GET /, POST /run. src/server/services/config-manager.ts — getSeedsDir(), the ./seeds default.

Why it exists. Local development needs reproducible data after every wipe. The diagnostic exists because the single most common failure — a seed file whose name doesn't match the deployed TableName — is otherwise indistinguishable from "I forgot to deploy".

How it works. It resolves the log path for the instance this invocation addresses (the same stateDir/port-scoped rule start used), and prints the last 50 lines of that file. If the file doesn't exist yet it says so and names the path it looked at. Since start redirects both stdout and stderr of the detached process into this file, everything the orchestrator, the engine and the Lambda workers print ends up here.

Where it lives. bin/cli.js — showLogs(), runtimePaths().

Why it exists. A detached process has nowhere to print. This is the debugging path when start reports a failed boot, and it works without knowing where the temp file lives.

How it works. Before deleting anything it calls GET /api/seeds and intersects the seed files with the tables that actually exist, so it can print the exact scope — one named table, or the count and names of all of them — along with the engine URL being targeted and an explicit "this does NOT touch any AWS account" line. If nothing matches it says why (no such table / no seed files / files exist but no live tables) and prints the same mismatch diagnostic as seed, without writing. Then it prompts you to type the word confirmar — deliberately untranslated in every locale, because scripts and docs depend on the token — and any other input aborts. --yes (or -y) skips the prompt and announces that it did; only then does it POST /api/seeds/clear.

Where it lives. bin/cli.js — clearSeed(), promptConfirmation(), printSeedClearResults(). src/server/routes/seeds.ts — POST /clear.

Why it exists. The one destructive command in the CLI. Showing the live target list and the local engine URL before the prompt is what makes "am I about to nuke a real table?" answerable in the moment; --yes keeps it usable from CI.

How it works. Config resolution is: --config <path> (or LSS_CONFIG), resolved absolute, then ./lss.config.json, ./.lssrc, $HOME/lss.config.json, $HOME/.lssrc — a missing or unparseable explicit file warns and falls back to the search rather than exiting, so stop/status can never orphan a running instance. Env overrides mirror the server's exactly: LSS_DASHBOARD_PORT or PORT for serverPort, LSS_ENGINE_PORT for the engine, LSS_DYNAMO_PROXY_PORT, and LSS_ENABLE_DYNAMO_PROXY (true or 1); a non-numeric port is ignored rather than becoming NaN. PID/log paths follow from that: with a stateDir they are <stateDir>/orchestrator.pid and .log; otherwise lss-orchestrator.pid/.log in the OS temp dir on the default port 14566, or lss-orchestrator-<port>.* on any other. When --config is used, the file is also handed to the server as LSS_CONFIG_PATH so both config loaders read the same file.

Where it lives. bin/cli.js — loadConfig(), getConfig(), envPort(), runtimePaths(), EXPLICIT_CONFIG. src/server/services/config-manager.ts — the server-side loader that honours LSS_CONFIG_PATH and the same env vars.

Why it exists. An e2e stack, a demo example and your dev instance need to run side by side without stopping each other. Scoping the PID/log files by stateDir or port is what makes lss stop --config ./e2e.json hit the right process — and keeping the legacy global path on the default port means an upgrade doesn't lose track of an already-running orchestrator.

How it works. It locates dist/mcp/server.js, reads the package version, and dynamically imports the module (the MCP build is ESM; loading it lazily keeps every other CLI invocation free of it) calling main(version). Startup failures print to stderr and exit 1. The process speaks JSON-RPC on stdout — so it is launched by an MCP client, never typed at a prompt — and it only talks HTTP to an orchestrator that is already running; it never boots one, so lss start has to have happened first.

Where it lives. bin/cli.js — runMcpServer(), getMcpServerPath(), getPackageVersion(). src/mcp/server.ts — the MCP server and its tools.

Why it exists. It turns the running stack into tools an agent can call (list tables, invoke a lambda, read logs) instead of having the agent shell out and parse CLI output.

How it works. The locale is resolved once at require time from LSS_LANG, then LC_ALL, LC_MESSAGES, LANG; POSIX suffixes are stripped (pt_BR.UTF-8@euro → pt_BR) and an unknown tag falls back to English rather than guessing. Every user-facing string goes through t(), which interpolates {placeholder} params and falls back English → raw key so an untranslated line still says something. Deliberately never translated: AWS proper nouns, command names, flags, config keys, paths, URLs and the confirmar token. help assembles its own layout — commands in a 19-column name gutter, options/env in a 29-column one — reflowing and hanging-indenting each description so a longer translation doesn't shred the column, and it prints the full lss.config.json template plus a worked example for each command.

Where it lives. bin/i18n.js — the three catalogues, detectLocale(), matchLocale(), t(). bin/cli.js — showHelp(), helpRow(), helpExample().

Why it exists. Hand-rolled and dependency-free (like the dashboard's i18n) because the CLI is a plain CommonJS file with no build step, and a translation layer isn't worth an npm dependency in every install.

Service registration & discovery

How it works. POST /api/services/register takes { servicePath } — everything else is optional. The registrar resolves the path, reads .serverless/cloudformation-template-update-stack.json and parses it into typed resources, then reads .serverless/serverless-state.json for the service name, stage, functions, HTTP routes and authorizers (the CFN template's fully-resolved function environment is merged over the state's). It saves the template plus metadata to the per-project cache, provisions every parsed resource onto the engine, and activates the data plane: FunctionRegistry entry, Lambda worker, gateway/invoke listeners and the source watcher. A missing serverless-state.json degrades gracefully — functions still come from the CFN template, with a warning and no HTTP routes. The route rejects a path that isn't absolute or contains .., and ports outside 1024–65535. lss register [path...] is the CLI wrapper (defaults to .), and lss_register_service the MCP tool.

Where it lives. src/server/services/service-registrar.ts — the whole read → parse → cache → provision → activate pipeline. src/server/routes/services.ts — POST /register input validation and the response shape. src/server/services/cloudformation-parser.ts — template → typed resources + templateHash. bin/cli.js — lss register.

Why it exists. In 0.x each service had to carry the serverless-lss plugin and announce itself from inside sls package. That package was retired in 1.0: services join the stack from the outside, so onboarding a 40-service monorepo means 40 paths, not 40 edits to 40 serverless.yml files.

How it works. ServerlessStateParser walks the resolved state file Serverless writes next to the CloudFormation template. Functions get fullName from functions[].name (falling back to {service}-{stage}-{fn}) and defaults resolved provider-first: runtime nodejs20.x, memory 1024 MB, timeout 6 s. http and httpApi events become routes — shorthand ("GET /users"), object form, routeKey, and */$default (→ method ANY, path $default); paths are normalized to a leading slash with trailing slashes stripped. provider.httpApi.authorizers become request authorizers (default resultTtlInSeconds 300, payload version 2.0); JWT and other unsupported types are skipped with a warning, and routes using them return 500. Env values still holding CFN intrinsics are coerced to strings ({Ref} → logical id, Fn::GetAtt → dotted join).

Where it lives. src/server/services/serverless-state-parser.ts — functions, routes, authorizers, port/region hints, sanitizeEnvironmentValues. src/server/services/service-registrar.ts — merges the CFN-resolved env over the state's per function. src/server/services/cache-manager.ts — the ServiceMetadata shape all of this lands in.

Why it exists. Serverless Framework already resolves your variables, plugins and stage at packaging time. Re-declaring routes or env in an LSS manifest would drift; parsing the artifact means the local stack always matches what would deploy.

How it works. GET /api/services/scan walks the project root, max depth 6, skipping node_modules, .git, .serverless, .lss, .build, .esbuild, dist, build, coverage, .webpack, .vscode, .devcontainer, .idea and any dot-directory. The first directory holding serverless.yml/.yaml/.json/.ts is a leaf — nested configs under a service are treated as fixtures. Each hit reports name (from service:, else the basename), absolute root, relPath, configFile, and three flags: installed (a node_modules found in the service or any parent up to the scan root, so workspace hoisting counts), packaged (a template already exists) and registered (root matches a cached service). Region and port hints come from a line-oriented scrape — serverless.json is parsed properly, serverless.ts contributes only a warning since it resolves at packaging time. The route then overlays lss.config.json: serviceRuntime ports win, and the effective package command is attached. Results sort by relPath. Surfaces as lss scan, the dashboard onboarding checklist and the lss_scan_services MCP tool.

Where it lives. src/server/services/service-scanner.ts — the walk, the flags and the YAML hint scrape. src/server/routes/services.ts — GET /scan, registered before /:name so the path isn't matched as a service named "scan". bin/cli.js — the lss scan checklist rendering. src/ui/src/pages/OnboardingPage.vue — the install → package → register flow built on it.

Why it exists. The scan is the discovery half the retired plugin used to do from inside each service's sls package. Values here are a preview only — the authoritative name, region and ports come from the packaged state at registration time, so a missed hint costs a default, never a wrong registration.

How it works. When the CloudFormation template is missing and autoPackage is on (default false; LSS_AUTO_PACKAGE also sets it), the registrar runs the effective package command with the service directory as cwd — default npx serverless package, global packageArgs then any per-service packageArgs appended as discrete argv (no shell re-parsing), packageEnv merged over process.env, timeout 300000 ms with SIGTERM then SIGKILL 2 s later — and re-reads the template. A non-zero exit or a timeout raises ServerlessPackageError; the registrar logs the full stdout/stderr and answers 500 pointing at /tmp/lss-orchestrator.log. With autoPackage off, a missing template is a 400 telling you to run serverless package. POST /api/services/package runs the same effective command on demand and returns exitCode, durationMs and the last 8000 characters of output (422 on failure). The source watcher's full reload re-packages the same way before re-registering.

What may be run. packageCommand is settable through PUT /api/config, which has no authentication, and it becomes spawn(firstToken, restTokens) with packageArgs appended — so validating it as "any non-empty string" made the /start and /install allowlists pointless, since two calls reached the same spawn(). Checking only the first token does not close it either, and that version of the fence was walked straight through: every package manager is itself an interpreter one subcommand in, and spawn('npm', ['exec','-c','<shell string>']) runs that string with no shell: true anywhere — as do npx -c, yarn dlx, pnpm dlx, yarn node -e and npm i <pkg> (install scripts). So what a command written through that endpoint must match — global or per-service — is a grammar over the whole argv, tokenized exactly the way the packager tokenizes it before spawn():

npm | yarn | pnpm        run | run-script   [script] [args…]
serverless | sls | osls  package                     [args…]
npx [-y|--yes]…   serverless|sls|osls[@version]   package   [args…]

npx serverless package (the default), npm run package, yarn run package, sls package -c custom.yml and npx -y [email protected] package all pass. /bin/sh -c …, node -e …, /usr/bin/env, ./tool, npm exec -c …, npx -c …, yarn dlx … and a bare yarn package (yarn 1's implicit run, which is what makes yarn node -e indistinguishable from a script name) are 400s. On top of the grammar, every token of the command and every packageArgs element is screened for flags that re-point an allowed program somewhere else — --node-options, --script-shell, --shell, --call, --userconfig, --globalconfig, --use-yarnrc, --registry (npx fetches the CLI when the service has no local copy, and npm reads its config flags from anywhere in the argv), --prefix, --cwd, --dir and node's own --require/--eval/--print/--import/--loader/--input-type — compared with dashes and underscores stripped, so --nodeOptions is the same flag. Serverless's short flags (-c, -p, -r = --config, --package, --region) are deliberately not screened: the grammar already makes them unreachable as npm/npx options. packageEnv likewise refuses keys the runtime or dynamic linker reads before main() — NODE_OPTIONS, NODE_REPL_EXTERNAL_MODULE, LD_PRELOAD, LD_AUDIT, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, plus PATH and NODE_PATH (the child's PATH is what execvp(3) uses to pick which file named npm runs, so setting it names an arbitrary binary), compared case-insensitively — since {"NODE_OPTIONS":"--require /tmp/x.js"} turns an otherwise blameless npx serverless package into an arbitrary-code runner whichever runner passed. Two limits worth stating out loud: the fence is on the API and not the read path (a hand-edited lss.config.json and LSS_PACKAGE_COMMAND are the operator's own shell, and anyone who can write either already runs code here), and what stays expressible is the project's own build — npm run <script> runs whatever the service's package.json declares and serverless package reads its serverless.yml, plugins included. That is code the operator already trusts by pointing LSS at the directory, and forbidding it would mean the dashboard could not set a package command at all, which is what onboarding is built on. What is gone is the part that made the other allowlists worthless: through this API a caller can no longer choose the program, only ask the project's own build to run.

Where it lives. src/server/services/serverless-packager.ts — quote-aware command parsing, spawn, timeout, ServerlessPackageError. src/server/services/service-registrar.ts — readTemplate() fallback and reregister(). src/server/services/config-manager.ts — getPackageConfigForService() merges global config with a servicePackaging entry. src/server/routes/services.ts — POST /package.

Why it exists. A TypeScript/esbuild service must be re-packaged before its template and handlers are current, and a stale .serverless/ silently serves old code. Making packaging part of registration removes the "did you package first?" step from every onboarding and every hot reload.

How it works. apiPort resolves as lss.config.json serviceRuntime[<svc>].apiPort > the request body > custom.lss.apiPort from the packaged state. invokePort follows the same three, then falls back to apiPort + invokePortOffset (default 10000, so api 3010 → invoke 13010). Region resolves as request > provider.region from the state > the config's region > us-east-1. Per-service config entries are matched by path relative to the config file's directory, path relative to the project root, or the directory basename. The resolved trio is written into the cached metadata, so a watcher reload or a boot rehydration replays the exact same numbers. The register route validates supplied ports at 1024–65535; both parsers independently reject anything outside 1–65535 so garbage never reaches a listen().

Where it lives. src/server/services/service-registrar.ts — the precedence chain and the offset fallback. src/server/services/config-manager.ts — getRuntimeConfigForService(), getInvokePortOffset(), the key-matching rules. src/server/services/serverless-state-parser.ts — custom.lss hints, port sanity check. src/server/routes/services.ts — request-level port validation.

Why it exists. Dozens of services sharing one machine need stable, non-colliding ports without declaring them twice. Config wins so an operator can move a port without touching the service, and the packaged state carries the service's own preference so nothing has to be configured centrally by default.

How it works. Each registration writes cloudformation-template.json and metadata.json under ~/.lss/orchestrator/cache/projects/<project-segment>/<serviceName>/, holding the sha256 templateHash, root, ports, region, stage, functions, routes and authorizers. On boot the orchestrator lists that directory and re-activates every entry — registry, Lambda worker, gateway/invoke listeners, watcher; an entry whose root sits outside the project root is logged and reactivated anyway (config under infra/, services elsewhere), and one that throws is logged while the rest continue. If the real service: name differs from the directory basename (the 0.5.x cache key) the legacy entry for that same root is deactivated and deleted so no ghost service survives. DELETE /api/services/:name deactivates the data plane, cleans up provisioned resources from the re-parsed template, then removes the cache directory.

Where it lives. src/server/services/cache-manager.ts — the project-scoped on-disk layout. src/server/services/service-registrar.ts — rehydrateAll(), activate(), deactivate(), the name migration. src/server/routes/services.ts — GET /, GET /:name, DELETE /:name.

Why it exists. Re-registering 40 services after every restart is the tax LocalStack-per-service setups charge. Scoping the cache per project also stops two checkouts with a service of the same name from overwriting each other's metadata and rehydrating into the wrong orchestrator.

How it works. POST /api/services/install runs a package-manager install in a scanned service (packaging fails without node_modules). The command defaults to npm install, and validation is on its shape: the first token must be npm, yarn or pnpm; the second must be install, ci, i or add (yarn/pnpm may omit it, npm may not); every remaining token must be a flag on an allowlist — --production, the three lockfile-strictness spellings (--frozen-lockfile, --no-frozen-lockfile, --immutable), --no-audit, --no-fund, --prefer-offline, --offline, --legacy-peer-deps, --force, --silent, --quiet, plus --omit, --include and --loglevel which may carry a value in the --flag=value spelling. An allowlist rather than "must start with -", because a flag is not inert and the dangerous ones look exactly like the harmless ones: --registry=http://… decides where the installed code comes from and its postinstall then runs on your host, --userconfig= swaps the whole npmrc, and --prefix= / yarn --cwd= / pnpm --dir= relocate the install out of the very directory the path fence vetted. So npm install lodash, npm exec -- anything, node -e "…" and npm install --registry=… are all unrepresentable and answered 400. The path must be absolute and free of .., and both it and the project root are realpath-resolved before they are compared — a lexical check passes a symlink that sits inside the root but points outside it (services/orders → /etc), and statSync and the spawn would follow it — then it must be an existing directory (a statSync throw is treated as not-a-directory rather than crashing the handler). The resolved real path is what the command runs in. Timeout is the service's effective packageTimeoutMs (default 300000). The response carries exitCode, durationMs and the last 8000 characters of stdout+stderr; a non-zero exit is 422. POST /package shares the same path guard.

Where it lives. src/server/routes/services.ts — resolveServiceDir() (realpath project-root confinement), installCommandError() with INSTALL_ALLOWED_FLAGS/INSTALL_ALLOWED_VALUE_FLAGS (command shape), POST /install, and START_RUNNERS/STAGE_PATTERN for POST /:name/start. src/server/services/serverless-packager.ts — the spawn/timeout/output capture. src/server/services/process-manager.ts — the spawn behind a start. src/ui/src/pages/OnboardingPage.vue — the install button in the onboarding checklist.

Why it exists. The dashboard onboarding walks install → package → register for a service you just cloned, and every legitimate caller gets its paths from GET /scan, which never looks outside the project root. Without the fence and the command allowlist, an endpoint on a local port would be arbitrary command execution on the host — which is not a hypothetical: POST /api/services/:name/start took command, args, cwd and env straight from the request body through 0.17.2, with node on its allowlist, and was exactly that. It now derives argv, cwd and env from the server — the service's recorded root plus npm run start/start:<stage> — and the two values a caller may still send are narrowed: command to npm/yarn/pnpm (node and npx are off the list) and stage to a [A-Za-z0-9._-]+ script-name token, so it can pick a script of the service's own package.json and nothing else. The stack's three command surfaces — install, package, start — are each narrowed to the one command they need.

Resource provisioning from CloudFormation

How it works. On lss register (or POST /api/services/register) the registrar reads <service>/.serverless/cloudformation-template-update-stack.json — running serverless package first when the file is missing and autoPackage is on — and CloudFormationParser.parse() walks Resources, switching on Type. Supported types: AWS::Lambda::Function, AWS::DynamoDB::Table, AWS::SQS::Queue, AWS::SNS::Topic, AWS::S3::Bucket, AWS::SecretsManager::Secret, AWS::Lambda::EventSourceMapping, AWS::Events::EventBus, AWS::Events::Rule, AWS::OpenSearchServerless::Collection, AWS::ApiGatewayV2::Api / ::Integration / ::Route / ::Authorizer, and AWS::Lambda::Permission. Unknown types are dropped silently and the Serverless Framework's ServerlessDeploymentBucket is dropped by logical id; AWS::Events::Archive and AWS::OpenSearchServerless::SecurityPolicy / ::AccessPolicy / ::VpcEndpoint push an explicit warning ("not provisioned locally" / "no-op locally") into the warnings[] array returned to the caller and logged as ⚠️ [service] …. A SHA-256 of the whole template is stored as the service's templateHash.

Where it lives. src/server/services/cloudformation-parser.ts — the type switch, one parser per resource type, and calculateHash. src/server/services/service-registrar.ts — reads the template (with auto-package fallback), collects warnings, persists metadata and calls the provisioner.

Why it exists. Your infrastructure is already declared once, in the template sls package emits — LSS provisions from that instead of asking you to maintain a second local description of the same tables, queues and buckets.

How it works. provisionResources(serviceName, resources, { invokePort, region }) re-creates its AWS SDK clients if the region changed, rebuilds a logicalId → resource map for reference resolution, then runs four ordered passes: (1) infrastructure — DynamoDB tables, SQS queues, SNS topics, S3 buckets, secrets, event buses, OpenSearch collections; (2) event source mappings; (3) EventBridge rules and targets; (4) S3 notification wiring (last, so the proxies created in pass 2 already exist). Every item is individually wrapped: the "already exists" family (ResourceInUseException, QueueAlreadyExists, BucketAlreadyOwnedByYou/BucketAlreadyExists, ResourceExistsException, ResourceAlreadyExistsException, ConflictException, ResourceConflictException) is swallowed and logged as ⚠ … already exists; anything else is logged as an error and the loop continues, so one broken resource never aborts a boot. AWS::Lambda::Function resources are parsed but never provisioned — real functions run in the LSS Lambda runtime, not in the engine's Lambda control plane.

Where it lives. src/server/services/resource-provisioner.ts — the singleton that owns the AWS SDK clients (DynamoDB, SQS, SNS, S3 with forcePathStyle, Lambda, EventBridge, Secrets Manager, OpenSearch Serverless), all four passes and the per-resource error policy.

Why it exists. In a monorepo every service re-registers on every save, boot and hot reload, and several services legitimately declare the same shared table or queue. Provisioning has to be a convergence step, not a one-shot deploy.

How it works. CreateTable is sent with the template's KeySchema, AttributeDefinitions, BillingMode and any non-empty GlobalSecondaryIndexes/LocalSecondaryIndexes; attributes referenced by a key or index but missing from AttributeDefinitions are synthesized as type S so a partially-written template still provisions. A stream is enabled whenever StreamSpecification carries either StreamViewType or StreamEnabled (CFN has no separate boolean), with NEW_AND_OLD_IMAGES as the fallback view type. TimeToLiveSpecification is applied afterwards with UpdateTimeToLive (Enabled defaults to true unless explicitly false) — also for tables that already existed, and DynamoDB's "TimeToLive is already enabled/disabled" reply is treated as the steady state, not a failure. A successful create fires SeedManager.seedOnTableCreated in the background when a seed file exists for that table.

Where it lives. src/server/services/cloudformation-parser.ts — parseDynamoDB (stream/TTL normalization). src/server/services/resource-provisioner.ts — createDynamoDBTable and applyTimeToLive. src/server/services/seed-manager.ts — the fire-and-forget auto-seed hook.

Why it exists. Streams and TTL are what most local DynamoDB setups quietly skip, and they are exactly what a stream-triggered Lambda needs to be testable at all.

How it works. CreateQueue receives only the attributes the template declares: VisibilityTimeout, MessageRetentionPeriod, FifoQueue (plus ContentBasedDeduplication) and RedrivePolicy. Redrive is the interesting one: CFN declares it as an object, the SQS API wants a JSON string, and deadLetterTargetArn is usually Fn::GetAtt [OrdersDlq, Arn]. The parser reduces that to OrdersDlq::Arn, and the provisioner resolves the logical id through the template map into arn:aws:sqs:<region>:000000000000:<queueName> — so a DLQ declared after the queue that points at it still resolves, because nothing is looked up on the live engine. A redrive target that isn't an SQS queue in the same template is dropped with a warning and the queue is still created. Because CreateQueue cannot change an existing queue's attributes, a queue that already exists gets the policy pushed separately via SetQueueAttributes.

Where it lives. src/server/services/cloudformation-parser.ts — parseSQS / parseRedrivePolicy (an unparseable policy is dropped, never half-carried). src/server/services/resource-provisioner.ts — createSQSQueue, buildRedrivePolicy, applyRedrivePolicy.

Why it exists. Poison-message handling is a behaviour you want to exercise locally; without redrive the DLQ exists but never receives anything, and the retry path you shipped is untested.

How it works. CreateBucket runs first, then PutBucketVersioning when VersioningConfiguration.Status is Enabled, then PutBucketCors when CorsConfiguration has at least one rule carrying both AllowedMethods and AllowedOrigins (both ExposedHeaders/ExposeHeaders and MaxAge/MaxAgeSeconds spellings are accepted, and MaxAge: 0 is preserved); both follow-ups are non-fatal warnings on failure and PutBucketCors is a full replace, so re-registration is naturally idempotent. In the final pass each NotificationConfiguration.LambdaConfigurations entry resolves its target function through the logical-id map, ensures a trigger proxy exists, grants s3.amazonaws.com with SourceArn arn:aws:s3:::<bucket>, and is sent in one PutBucketNotificationConfiguration with SkipDestinationValidation: true. Events default to s3:ObjectCreated:* when the template omits them, and Filter.S3Key.Rules prefix/suffix carry through.

Where it lives. src/server/services/cloudformation-parser.ts — parseS3 / parseCorsRules. src/server/services/resource-provisioner.ts — createS3Bucket and configureS3Notifications.

Why it exists. S3-triggered pipelines are otherwise the hardest thing to reproduce locally; wiring the notification at provision time means aws s3 cp against the engine drives the real handler with no extra glue.

How it works. The target function name is taken from the CFN Lambda's own FunctionName (via its logical id) rather than reconstructed from service/stage. The EventSourceArn is then resolved: literal ARNs pass through; a Fn::GetAtt ref is looked up in the logical-id map — an SQS queue through GetQueueUrl + GetQueueAttributes(QueueArn), a DynamoDB table through DescribeTable's LatestStreamArn retried up to 5 times at 300 ms intervals (CreateTable returns before the stream exists), an SNS topic as a constructed ARN — with a legacy kebab-case match against live resources as a last resort. Mappings whose resolved ARN already exists on the function are skipped. CreateEventSourceMapping then sends BatchSize (default 10) and Enabled, plus — for stream sources only — StartingPosition (default TRIM_HORIZON), MaximumRetryAttempts and the resolved OnFailure destination; MaximumBatchingWindowInSeconds, FunctionResponseTypes (ReportBatchItemFailures) and FilterCriteria are sent for both source kinds.

Where it lives. src/server/services/resource-provisioner.ts — createEventSourceMapping and resolveEventSourceArn. src/server/services/cloudformation-parser.ts — parseEventSource. src/server/engine/emulators/lambda-ctl/index.ts — the mapping registry the engine's pollers read.

Why it exists. This is the seam serverless-offline never covered: it serves HTTP, but nothing drains your queue or tails your stream. The mapping registered here is what dispatch/sqs-poller.ts and dispatch/stream-tailer.ts act on.

How it works. Each AWS::Events::EventBus is created with CreateEventBus (ResourceAlreadyExistsException swallowed). Rules use PutRule + PutTargets, which are upserts — re-registration is idempotent by construction. State defaults to ENABLED unless the template says DISABLED, EventPattern is re-serialized to JSON, and ScheduleExpression passes straight through to the engine's scheduler. EventBusName resolves a same-template logical id to the provisioned bus name, otherwise treats the value as a literal name/ARN, otherwise targets the default bus; a bus name the parser could not reduce (e.g. Fn::ImportValue) makes the rule skip with a warning rather than silently bind to the default bus and never match. Each target ensures its Lambda trigger proxy, grants events.amazonaws.com with SourceArn arn:aws:events:<region>:000000000000:rule/[<bus>/]<rule>, and carries the optional Input/InputPath.

Where it lives. src/server/services/resource-provisioner.ts — createEventBus, createEventRule, resolveEventBusName. src/server/services/cloudformation-parser.ts — parseEventBus / parseEventRule.

Why it exists. Cross-service choreography in a monorepo usually runs over a custom bus; without the bus and its rules, publishing works locally and nothing ever consumes.

How it works. Every trigger target (event source mapping, EventBridge target, S3 notification) needs a function the engine's Lambda control plane knows about. ensureLambdaProxyExists calls GetFunction first; on ResourceNotFoundException it creates one with CreateFunction — runtime nodejs20.x, MemorySize 256, Timeout 60 — carrying INVOKE_URL and FUNCTION_NAME env vars. INVOKE_URL defaults to http://127.0.0.1:<invokePort> (lambdaRuntime.invokeHost / LSS_INVOKE_HOST override the host; invokePort falls back to apiPort + 10000). If the proxy already exists but its INVOKE_URL is stale — ports renumbered, host changed — UpdateFunctionConfiguration re-points it. On the self engine lambda-ctl discards the uploaded zip and keeps the record as metadata: invocations are handed to the in-process dispatcher, with INVOKE_URL kept only as an HTTP fallback.

Where it lives. src/server/services/resource-provisioner.ts — ensureLambdaProxyExists, resolveLambdaName, generateProxyLambdaCode. src/server/engine/emulators/lambda-ctl/index.ts — absorbs the proxy as metadata and routes Invoke to the dispatcher.

Why it exists. Handlers live in your repo and run in the LSS runtime, while triggers fire inside the engine — the proxy record is the name both halves agree on. It is a leftover of the LocalStack era that the self engine short-circuits instead of executing.

How it works. Each AWS::SecretsManager::Secret is created with CreateSecret (name falls back to the logical id) so an AWSCURRENT version exists up front. When the template declares GenerateSecretString instead of SecretString, the value is synthesized the way CloudFormation does: GetRandomPassword with the real AWS defaults — PasswordLength 32, RequireEachIncludedType true, every exclude flag passed through — and, when both SecretStringTemplate and GenerateStringKey are present, the generated password is injected into the parsed template at that key. ResourceExistsException is the idempotent path; InvalidRequestException (secret scheduled for deletion) warns and skips; a secret declaring neither form is created with no value and warns, matching AWS.

Where it lives. src/server/services/resource-provisioner.ts — createSecret. src/server/services/secret-value.ts — resolveGeneratedSecretString, shared with the boot seed applier so both paths synthesize identically. src/server/services/cloudformation-parser.ts — parseSecret (keeps the spec raw; synthesis happens at provision time).

Why it exists. Services read their credentials at cold start; a secret that does not exist yet turns into an opaque startup crash on every fresh stack.

How it works. When a template declares an HTTP API by hand under resources: — often a gateway stack fronting Lambdas in other stacks — the assembler folds it into the same HttpRoute/AuthorizerConfig registry that serverless-state.json feeds. Each route resolves its Target (integrations/<id> literal, the Fn::Sub idiom and the Fn::Join form the framework compiles), requires IntegrationType AWS_PROXY, and reduces IntegrationUri to a Lambda ARN — unwrapping the arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<arn>/invocations wrapper. Routes are deduped on (METHOD, normalized path) with serverless-state routes winning, because the framework compiles a mirror ::Route for every httpApi event. AuthorizationType: CUSTOM pulls the matching REQUEST authorizer (AuthorizerResultTtlInSeconds default 300, payload format default '1.0'); JWT/AWS_IAM or a non-REQUEST authorizer registers the route unauthorized with a warning. A missing AWS::Lambda::Permission for apigateway.amazonaws.com only warns — the grant is advisory locally. Integration PayloadFormatVersion defaults to '2.0' on the route.

Where it lives. src/server/services/raw-api-assembler.ts — route/integration/authorizer/permission assembly and dedup. src/server/services/cloudformation-parser.ts — parseApi* / parseRouteKey ($default and * become an ANY catch-all). src/server/services/lambda-invoke-uri.ts — the one place the invocation-URI shape is defined, shared with the gateway.

Why it exists. Real repos put a shared gateway in its own stack and point it at other services' functions; a local emulator that only understands httpApi: events silently serves none of those routes.

How it works. resolveArnLike reduces Ref, Fn::GetAtt, Fn::Sub (both the string and [template, vars] forms), Fn::Join and Fn::ImportValue, substituting AWS::Region, AWS::AccountId (000000000000) and AWS::Partition (aws), and mapping same-template Lambda logical ids to arn:aws:lambda:<region>:000000000000:function:<name>. After each registration collectStackExports records that stack's Outputs[].Export.Name → resolved value into an orchestrator-wide map, so a service registered later can Fn::ImportValue a Lambda ARN another service exported. An import with no known export keeps the literal token and warns you to register the exporting service; the result carries a resolved flag so callers can skip a route rather than register a broken one.

Where it lives. src/server/services/cloudformation-parser.ts — resolveArnLike, resolveSub, pseudoParam. src/server/services/raw-api-assembler.ts — collectStackExports. src/server/services/service-registrar.ts — owns the accumulated exportMap across registrations.

Why it exists. A monorepo splits one logical system into many stacks that reference each other by export; resolving those in-process is what lets the whole stack be provisioned service by service, in any order.

How it works. CreateCollection is sent with the template's Name, Type (SEARCH/TIMESERIES/VECTORSEARCH, passed through untouched) and Description. When Name is absent, the logical id is bent into the AOSS naming rule — lowercased, non-[a-z0-9-] replaced with -, leading non-letters stripped, truncated to 32 chars and prefixed col- when shorter than 3 — so it can actually provision. The data-plane URL is logged as <engine endpoint>/_aoss/<name>, which is the path the engine's router recognizes. ConflictException is the idempotent path; a "not yet implemented"/"pro feature" reply is re-thrown with an explicit message pointing at the self engine or the aoss sidecar. Security/access/VPC-endpoint policies are parsed away with a warning — nothing enforces them locally, and accepting them silently would fake a security posture.

Where it lives. src/server/services/resource-provisioner.ts — createOpenSearchCollection. src/server/services/cloudformation-parser.ts — parseOpenSearchCollection / collectionNameFromLogicalId. src/server/engine/http/router.ts — the /_aoss/... data-plane routing.

Why it exists. No LocalStack edition provides aoss, so search-backed services had no local story at all; the self engine serves it natively.

How it works. cleanupResources rebuilds the same logical-id map provisioning uses, then deletes tables, queues, topics, buckets, EventBridge rules (targets removed first), OpenSearch collections and event source mappings, each in its own try/catch where a NotFound-shaped error is treated as success. Event buses are collected and deleted after the main loop, because EventBridge refuses to delete a bus that still has rules and template order guarantees nothing. Buckets are drained best-effort — one ListObjectsV2 page of objects deleted before DeleteBucket — since this is dev cleanup, not production data. Event source mappings and their proxy are looked up under the legacy ${service}-${function}-proxy name. Ends with ✅ Cleaned up N resources for <service>.

Where it lives. src/server/services/resource-provisioner.ts — cleanupResources plus the per-type delete* helpers.

Why it exists. Dropping a service from the stack should not leave orphan tables and rules behind that the next boot rediscovers and the dashboard keeps listing.

Lambda runtime & API Gateway emulation

How it works. Each registered service gets at most one forked worker process (stdio: ignore/pipe/pipe/ipc, cwd = service root, NODE_PATH pointed at the service's own node_modules). An invoke is an IPC message carrying handler, event, env, region, memory and timeout; the worker resolves the handler file (.js, .cjs, .mjs, .ts, .cts, .mts, or index.* in a directory), caches the loaded module between invocations for warm-start behaviour, and runs it with an AWS-shaped context (awsRequestId, invokedFunctionArn, logGroupName: /aws/lambda/<fullName>, getRemainingTimeInMillis()) plus callback-style support when the handler takes 3 arguments. Per-function timeout (default 6 s) and memorySize (default 1024 MB) come from the packaged state; the worker rejects with a TimeoutError at the deadline and the manager adds a 5 s IPC grace on top so a wedged worker still answers. TypeScript sources load through native type stripping when process.features.typescript is on, otherwise via esbuild-register, tsx/cjs or ts-node/register/transpile-only resolved from the service's node_modules first. A crashed worker is restarted up to 3 times per 30 s window before the runtime is parked in error; the last 50 invocations per service are kept with their logs, duration and HTTP status.

Where it lives. src/server/services/lambda-runtime-manager.ts — fork/restart supervision, invoke brokering, invocation history. src/server/runtime/runtime-worker.ts — handler resolution, TS loaders, context/timeout, IPC protocol. src/server/services/function-registry.ts — global name/ARN → function resolution shared by every invoke path. src/server/routes/lambdas.ts — GET /api/lambdas, POST /api/lambdas/:name/invoke, GET /api/lambdas/:name/logs.

Why it exists. This is what replaced serverless-offline: instead of one offline process per service (each with its own port, its own watcher and its own copy of the AWS SDK), the orchestrator owns every handler process and can report status, logs and timings for all of them in one place.

How it works. For every service with routes and an apiPort (the examples use 3631–3634, declared as custom.lss.apiPort), the orchestrator binds an HTTP listener inside its own process. A request is matched against the service's routes by specificity — literal segment 100, {param} 10, {proxy+} 1, the HTTP API $default catch-all 0, with an exact method beating ANY by one point. The matched route builds either a REST payload format 1.0 event (http) or an HTTP API 2.0 event (httpApi, including split cookies and routeKey), invokes the target function, and maps the result back: v1 demands a well-formed {statusCode,…} and answers 502 otherwise, v2 accepts a bare return value and infers 200 application/json. Binary bodies are base64-encoded unless the content type is textual (text/*, application/json|xml|javascript|x-www-form-urlencoded, *+json/*+xml). OPTIONS preflight is answered by the gateway itself (204, access-control-max-age: 300, allow-headers echoing the request or Content-Type,Authorization,X-Api-Key) when any route declares CORS; a handler failure returns 500 for HTTP APIs and 502 for REST.

Where it lives. src/server/services/gateway-manager.ts — listener lifecycle, request/response plumbing, CORS. src/server/services/api-gateway-events.ts — pure route matching, v1/v2 event builders, response mapping. src/server/services/serverless-state-parser.ts — routes from functions[].events. src/server/services/raw-api-assembler.ts — routes from raw AWS::ApiGatewayV2::* resources, de-duplicated against state routes by METHOD /path (state wins).

Why it exists. Existing callers in a monorepo already hard-code http://localhost:3xxx for each service; keeping the per-service port contract means the stack drops in without touching client code, while the emulation lives in one process instead of N serverless-offline instances.

How it works. Every service with functions also binds an invoke listener on its invokePort, derived by default as apiPort + 10000 (lambdaRuntime.invokePortOffset, so 3631 → 13631) unless the service declares one. It serves POST /2015-03-31/functions/<name-or-ARN>/invocations and honours X-Amz-Invocation-Type: RequestResponse returns the payload with X-Amz-Executed-Version: $LATEST and the last 32 log lines base64 in X-Amz-Log-Result, Event answers 202 and runs fire-and-forget, DryRun answers 204. Handler failures follow Lambda semantics — HTTP 200 with X-Amz-Function-Error: Unhandled and an {errorType, errorMessage, trace} body — not an HTTP error. The function name is resolved against the port's own service first, then globally by short name, full name or ARN, so any registered function is reachable from any invoke port.

Where it lives. src/server/services/gateway-manager.ts — handleInvokeRequest, invocation-type handling, headers. src/server/services/lambda-invoke-uri.ts — the single definition of the invoke path, shared with the API Gateway AuthorizerUri/IntegrationUri unwrapper. src/server/engine/dispatch/dispatcher.ts — the engine's own resolution order (registry → in-process invoke → INVOKE_URL HTTP fallback).

Why it exists. Service-to-service calls in a monorepo go through LambdaClient with an endpoint override; keeping the wire contract means those calls, and any tool that speaks the Invoke API, work against the local stack with no code branch for "local".

How it works. lambdaRuntime.execution (and per-service serviceRuntime.<name>.execution) picks how handler code is located. artifact resolves the zips declared in serverless-state.json — trying absolute, .serverless/-relative and root-relative interpretations, then falling back to scanning .serverless/*.zip — and extracts them into a content-addressed directory ~/.lss/orchestrator/runtime/<project>/<service>/<sha256-12 of path:size:mtime>, so an unchanged package is never re-extracted and stale extractions are pruned. source loads handlers straight from the service root. auto (the default) picks artifact when a zip exists and source otherwise. Resolution happens at registration even in lazy mode, so a missing package fails loudly right away (execution mode is "artifact" but no packaged zip was found under <root>/.serverless) instead of at first request; the resolved mode is reported per service as resolvedMode.

Where it lives. src/server/services/artifact-resolver.ts — the three interpretations of package.artifact plus the .serverless scan. src/server/services/lambda-runtime-manager.ts — mode resolution and content-addressed extraction. src/server/services/config-manager.ts — lambdaRuntime.execution / serviceRuntime merge.

Why it exists. Bundled artifacts are what actually ships, but iterating on them means repackaging on every save; source mode gives instant edit-run cycles, and per-service overrides let a monorepo mix both — the service you're editing on source, the other 39 on their bundles.

How it works. lambdaRuntime.lazy defaults to true: registering a service resolves its handler root but does not fork anything, and the runtime reports status: online, warm: false. The first invocation forks the worker (paying one cold start); concurrent invocations arriving while it boots are queued and released when the worker reports ready. After lambdaRuntime.idleTimeoutMs (default 60000; 0 disables) with no invocation, the worker is killed and the service returns to the lazy state — never while an invocation is in flight. Independently, lambdaRuntime.maxWarmWorkers caps resident workers; the default is one per GB of system RAM clamped to 2..12 (0 disables). When a fork pushes past the cap, the least-recently-invoked idle worker is evicted; if nothing is evictable the cap is exceeded transiently rather than blocking the request. Setting lazy: false restores eager forking at registration.

Where it lives. src/server/services/lambda-runtime-manager.ts — armIdleUnload, enforceWarmCap, unloadWorker, the idle → starting → online state machine. src/server/services/config-manager.ts — isLambdaRuntimeLazy(), getLambdaIdleTimeoutMs(), getLambdaMaxWarmWorkers().

Why it exists. A worker is ~48 MB resident, so a 40-service monorepo used to pay ~1.9 GB before a single handler ran. Deferring and reclaiming workers is what makes a large stack usable on a modest laptop, at the cost of a ~200 ms first call and ~20 ms re-forks.

How it works. Each service root is watched non-recursively (that's where serverless.yml and package.json live) plus one recursive watch per non-ignored child directory — node_modules, .serverless, .esbuild, .build, .git, dist and .lss are never watched, not merely filtered, which is what keeps 40 services from asking the kernel for ~70k inotify watches. Changes are debounced 500 ms and classified: a touched serverless.yml/.yaml/.json or package.json at the service root triggers a full reload (re-package when autoPackage is on, then re-register with the ports and region already on record), anything else triggers a runtime reload — a worker restart, which is all that's needed to flush its module cache. Watching is on by default in source mode and off in artifact mode (repackaging every save is expensive); lambdaRuntime.watch / serviceRuntime.<name>.watch overrides that. Last reload time, kind and error are exposed by GET /api/services/:name/runtime.

Where it lives. src/server/services/source-watcher.ts — the watch topology, debounce and change classification. src/server/services/service-registrar.ts — activate() (watch defaults) and reregister() (the full-reload path). src/server/index.ts — wires onRuntimeReload → restartRuntime, onFullReload → reregister.

Why it exists. It's the reload loop serverless-offline gave you per service, but driven centrally and cheaply eno