A lightweight, configurable static API server that turns files in text-based data serialization formats (JSON, YAML) into HTTP endpoints. It can serve files directly from the filesystem, expose declarative endpoints, optionally run filter functions over the data, and secure requests with OAuth2/JWT validation. It allows merging multiple files into a single file, e.g., to split configuration files into manageable chunks and serve them as a single file. Logging is split into HTTP access logs and application logs.
The server has different "locations" to handle requests:
- The
heartbeatlocation provides a health probe. - The
endpointslocation exposes configurable endpoints which can be mapped to specific files (http://myserver/api/v1/usersmight provide the content of/public/users.json). The server can optionally run filter functions before providing the content; named parameters (http://myserver/api/v1/users/{id}/{email}) can be used to specify the behaviour of the filter. - The
filesystemlocation just serves files in the server'sSERVER_PATH_PUBLIC. However a modifier can be used to transform files, e.g. in order to merge multiple files into one single file.
The server will check the locations in the following order
Heartbeat → [Authentication] → Endpoints → Filesystem
If authentication is enabled, a request to the endpoints or filesystem location needs a valid JSON web token (JWT). Requests to the heartbeat location never need authentication.
A request to a filesystem or endpoints location will be processed by different modules in the following order:
- Parse file (currently
JSONandYAML) - Apply modifiers (currently
includemodifier to merge the content of separate files in order to create a single response from files) - Run a JavaScript filter function (only available for
endpointlocation) - Format output (currently
JSON,YAMLorJS)
Parser → Modifier → Filters (optional; only endpoints) → Formatter
To get started, you might want to clone and manual build the app or you can use the provided Dockerfile to create and run a Docker image.
Clone, install, build, and run the server directly with Node.js.
Prerequisites
- Node.js 20 or newer with npm
Install
# Clone this repository
git clone https://github.com/borispulyer/tiny-node-api.git
cd tiny-node-api
# Install dependencies
npm ci
# Build TypeScript to ./dist
npm run build:prod
# Copy and modify your environment file
cp .env.example .env
# ...then edit .env to your needs (start with the example below)
# Run the compiled server
npm start:prodMinimal .env example:
# Endpoints
ENDPOINTS='[{"enable":true,"path":"/api/v1/users","file":"./data/users.yaml","format":"json"},{"enable":true,"path":"/api/v1/users/{id}","file":"data/users.yaml","format":"json","filter":"./userById.js"}]'You can use the provided Dockerfile to build and run a Docker image.
Prerequisites
- Docker 24+ and Docker Compose v2
Minimal docker-compose.yml example:
services:
tiny-node-api:
build:
context: 'https://github.com/borispulyer/tiny-node-api.git'
# ... or build from local
#context: .
dockerfile: './Dockerfile'
container_name: 'tiny-node-api'
restart: 'unless-stopped'
security_opt:
- 'no-new-privileges:true'
healthcheck:
test:
- CMD-SHELL
- >
node -e "const http=require('http');
const port=process.env.SERVER_PORT||'3000';
const req=http.get({host:'127.0.0.1',port:port,path:'/_heartbeat'},res=>process.exit(res.statusCode===200?0:1));
req.on('error',()=>process.exit(1));
setTimeout(()=>{try{req.destroy();}catch{} process.exit(1)},2000);"
interval: 30s
timeout: 3s
retries: 5
start_period: 10s
ports:
- '3000:3000'
volumes:
- './public:/app/public:ro'
- './filter:/app/filter:ro'
- './logs:/app/logs'
environment:
# You might want to copy the content of /public and /filter from the repository to use it for testing
ENDPOINTS: '[{"enable":true,"path":"/api/v1/users","file":"./data/users.yaml","format":"json"},{"enable":true,"path":"/api/v1/users/{id}","file":"data/users.yaml","format":"json","filter":"./userById.js"}]'Build and run
docker compose up --build-
Health check (if
SERVER_LOCATIONS_HEARTBEAT=true), for example:GET http://<host>:3000/_heartbeat -
Filesystem serving:
GET http://<host>:3000/data/users # resolves extension if enabled GET http://<host>:3000/data/users.yaml # explicit -
Endpoint serving (from
ENDPOINTS):GET http://<host>:3000/api/v1/users GET http://<host>:3000/api/v1/users/1
Below you’ll find an overview of every environment variable consumed by the current codebase, grouped by topic and shown with its default values. Paths marked CWD are resolved relative to the current working directory. Please find a detailed explanation of the configuration properties afterwards.
Overview
# Example environment configuration
###
# Server configuration
###
# Port for the HTTP server (default: 3000)
SERVER_PORT=3000
# Absolute or relative path to static configuration files (./public)
SERVER_PATH_PUBLIC=./public
# Absolute or relative path where filter functions are located (./filter)
SERVER_PATH_FILTER=./filter
# Enable the built-in heartbeat endpoint (true)
SERVER_LOCATIONS_HEARTBEAT=true
# Enable custom endpoints defined in ENDPOINTS (true)
SERVER_LOCATIONS_ENDPOINTS=true
# Enable direct filesystem access (true)
SERVER_LOCATIONS_FILESYSTEM=true
# Cache-Control Header without authentication
SERVER_CACHE_HEADER='stale-while-revalidate=300, stale-if-error=3600'
# Cache-Control Header with authentication
SERVER_CACHE_HEADER_AUTH='private, no-cache'
# Socket timeout in milliseconds (5000 ms)
SERVER_TIMEOUTS_SOCKET=5000
# Keep-alive timeout in milliseconds (75000 ms)
SERVER_TIMEOUTS_KEEPALIVE=75000
# Headers timeout in milliseconds (80000 ms)
SERVER_TIMEOUTS_HEADERS=80000
# Request timeout in milliseconds (60000 ms)
SERVER_TIMEOUTS_REQUEST=60000
# Maximum number of requests per socket (1000 requests)
SERVER_MAX_REQUESTS=1000
###
# Filesystem behaviour
###
# Resolve file extensions automatically (true)
FILESYSTEM_RESOLVE_EXT=true
###
# Endpoint definitions
###
# JSON array with endpoints ([]):
ENDPOINTS='
[
{
"enable": true,
"path": "/api/v1/users",
"file": "data/users.yaml",
"format": "json"
}, {
"enable": true,
"path": "/api/v1/users/{id}",
"file": "data/users.yaml",
"format": "json",
"filter": "userById.js"
}
]'
###
# Authentication
###
# Enable OAuth2 authentication (false)
AUTH_ENABLE=false
# OAuth2 issuer URI
AUTH_OAUTH2_ISSUER=https://issuer.example.com
# JWKS endpoint providing public keys
AUTH_OAUTH2_JWKS=https://issuer.example.com/.well-known/jwks.json
# Expected audience value
AUTH_OAUTH2_AUDIENCE=my-audience
###
# Modifier modules
###
# Enable modifier pipeline (true)
MODIFIER_ENABLE=true
# Enable modifier for merging files (true)
MODIFIER_INCLUDE_ENABLE=true
###
# Default formatter
###
# Output format used when it is not specified (via file extensions e.g. http://example.com/file.json) or extension is not valid
FORMATTER_DEFAULT=json
###
# HTTP request logging
###
# Enable HTTP access logs (true)
LOGGING_HTTP_ENABLE=true
# Stream HTTP logs to stdout (false)
LOGGING_HTTP_STDOUT_ENABLE=false
# Log level for HTTP stdout logging (options: silent|fatal|error|warn|info|debug|trace; default: info)
LOGGING_HTTP_STDOUT_LEVEL=info
# Write HTTP logs to filesystem (true)
LOGGING_HTTP_FILE_ENABLE=true
# File path prefix for HTTP logs (./logs/access)
LOGGING_HTTP_FILE=./logs/access
# Log level for HTTP file logging (options: silent|fatal|error|warn|info|debug|trace; default: info)
LOGGING_HTTP_FILE_LEVEL=info
# Maximum size of each HTTP log before rotation (10M bytes)
LOGGING_HTTP_LOGROTATION_SIZE=10M
# How often HTTP logs rotate (options: daily|weekly; default: daily)
LOGGING_HTTP_LOGROTATION_FREQUENCY=daily
# Number of rotated HTTP logs to keep (180 files)
LOGGING_HTTP_LOGROTATION_LIMIT=180
# File extension for rotated HTTP logs (log)
LOGGING_HTTP_LOGROTATION_EXTENSION=log
# Date format in rotated HTTP log names (yyyy-MM-dd)
LOGGING_HTTP_LOGROTATION_DATEFORMAT=yyyy-MM-dd
# Create symlink to latest HTTP log (false)
LOGGING_HTTP_LOGROTATION_SYMLINK=false
###
# Application logging
###
# Enable application logs (true)
LOGGING_APP_ENABLE=true
# Stream application logs to stdout (true)
LOGGING_APP_STDOUT_ENABLE=true
# Log level for application stdout logging (options: silent|fatal|error|warn|info|debug|trace; default: warn)
LOGGING_APP_STDOUT_LEVEL=warn
# Write application logs to filesystem (true)
LOGGING_APP_FILE_ENABLE=true
# File path prefix for application logs (./logs/app)
LOGGING_APP_FILE=./logs/app
# Log level for application file logging (options: silent|fatal|error|warn|info|debug|trace; default: info)
LOGGING_APP_FILE_LEVEL=info
# Maximum size of each application log before rotation (10M bytes)
LOGGING_APP_LOGROTATION_SIZE=10M
# How often application logs rotate (options: daily|weekly; default: daily)
LOGGING_APP_LOGROTATION_FREQUENCY=daily
# Number of rotated application logs to keep (180 files)
LOGGING_APP_LOGROTATION_LIMIT=180
# File extension for rotated application logs (log)
LOGGING_APP_LOGROTATION_EXTENSION=log
# Date format in rotated application log names (yyyy-MM-dd)
LOGGING_APP_LOGROTATION_DATEFORMAT=yyyy-MM-dd
# Create symlink to latest application log (false)
LOGGING_APP_LOGROTATION_SYMLINK=false| Variable | Purpose | Default |
|---|---|---|
SERVER_PORT |
Port for the HTTP server. | 3000 |
SERVER_PATH_PUBLIC |
Root directory for serving files and resolving endpoints[].file. |
CWD/public |
SERVER_PATH_FILTER |
Root directory where filter function files live; endpoints[].filter is resolved under this folder. |
CWD/filter |
SERVER_LOCATIONS_HEARTBEAT |
Enable the heartbeat location. | true |
SERVER_LOCATIONS_ENDPOINTS |
Enable endpoint-based serving. | true |
SERVER_LOCATIONS_FILESYSTEM |
Enable direct filesystem serving. | true |
SERVER_CACHE_HEADER |
Set the "Cache-Control" HTTP header. | stale-while-revalidate=300, stale-if-error=3600 |
SERVER_CACHE_HEADER_AUTH |
Set the "Cache-Control" HTTP header if a user is authenticated. | private, no-cache |
SERVER_TIMEOUTS_SOCKET |
Idle socket timeout (ms). | 5_000 |
SERVER_TIMEOUTS_KEEPALIVE |
Keep-alive timeout (ms). | 75_000 |
SERVER_TIMEOUTS_HEADERS |
Headers timeout (ms). | 80_000 |
SERVER_TIMEOUTS_REQUEST |
Request timeout (ms). | 60_000 |
SERVER_MAX_REQUESTS |
Max requests per TCP socket. Helps against slow-loris style connections. | 1000 |
Notes
- Relative paths in environment variables (e.g.,
SERVER_PATH_PUBLIC,SERVER_PATH_FILTER) are resolved relative to the current working directory. - All timeouts are milliseconds.
- Filesystem requests resolve the requested path under
SERVER_PATH_PUBLIC. If it falls outside, it is rejected. Please note, that the server does not check the real path of symlinks. - Filter function files are resolved under
SERVER_PATH_FILTER. If it falls outside, it is rejected. Please note, that the server does not check the real path of symlinks.
Caching and conditional requests
- For file-backed responses the server sets:
ETag: W/"<mtimeMs>-<size>"Last-Modified
- If the request includes
If-None-MatchorIf-Modified-Sincethat matches, the server responds with 304 Not Modified.
A request is handled by one of the location modules in the following order.
Heartbeat → Endpoints → Filesystem
Requesting http://myserver/_heartbeatwill return a 200 OK HTTP status code.
Note: No Authentication is needed for a request to the heartbeat location.
Expose files as user defined endpoints and optionally run custom JavaScript filter functions.
| Variable | Purpose | Default |
|---|---|---|
ENDPOINTS |
JSON (array or single object) describing endpoints to expose. | [] |
Examples
- Minimal list (array):
[{ "enable": true, "path": "/api/v1/users", "file": "./data/users.json" }]- With format override and filter:
[
{
"enable": true,
"path": "/api/v1/user/{id}",
"file": "./data/users.yaml",
"format": "json",
"filter": "./userById.js"
}
]You can find additional information about filter functions and ready-to-use examples in 4. Filter functions.
Serve files from SERVER_PATH_PUBLIC with a file format known to the available parsers. If a request falls outside, it is rejected. Please note that the server does not check the real path of symlinks.
| Variable | Purpose | Default |
|---|---|---|
FILESYSTEM_RESOLVE_EXT |
If the URL omits an extension or the extension is not valid, try every registered parser extension to find a matching file. | true |
Example: Requesting /data/users will probe /data/users.json, /data/users.yaml, etc., in the order of registered parsers.
Modifiers run after parsing and before filtering and formatting.
Parser → Modifier → Endpoint Filters (optional) → Formatter
This server supports file inclusion during the modifier stage. The include modifier is enabled by default and replaces any object shaped like {"__include__": "<relative-path>"} with the parsed contents of the referenced file. In YAML you can use a custom tag !include that expands to the same directive.
| Variable | Purpose | Default |
|---|---|---|
MODIFIER_ENABLE |
Globally enable/disable the modifier stage. | true |
MODIFIER_INCLUDE_ENABLE |
Enable/disable the include modifier module. |
true |
Use the !include tag anywhere a value is expected. The path is resolved relative to the file that contains the directive.
# public/config/app.yaml
name: tiny-node-api
version: 1
# Merge an object from another file into this branch
database: !include ./partials/database.yaml
# Include inside arrays
features:
- name: core
- !include ./partials/feature-logging.yaml
- !include ./partials/feature-auth.yamlExample partials:
# public/config/partials/database.yaml
driver: postgres
host: db.example.local
port: 5432# public/config/partials/feature-logging.yaml
name: logging
level: infoUse an object with an __include__ property whose value is a string path. The path is resolved relative to the file that contains the directive.
{
"name": "tiny-node-api",
"version": 1,
"database": { "__include__": "./partials/database.json" },
"features": [{ "name": "core" }, { "__include__": "./partials/feature-logging.json" }]
}- Relative paths are resolved from the directory of the file that contains the directive; included files may themselves contain further includes, and the base directory updates at each step.
- Security boundary: The target must resolve inside
SERVER_PATH_PUBLIC. If not, the server responds with 403 Forbidden. - Duplicate includes: The same file may not be included more than once within a single request. Re-including the same file raises a 500 Syntax Error.
- Unparsable/unsupported files: If the included file cannot be parsed by a registered parser or its content has syntax issues, it is treated as a syntax error and results in an HTTP 500.
- Merging/flattening: Includes are pure replacement of the node where they appear. The server does not merge objects or flatten arrays for you. If you include an array inside an array, you will get a nested array.
- Outside of
SERVER_PATH_PUBLIC→ 403 Forbidden (file access error). - Re-including the same file (even from different locations) → 500 Syntax error in file.
- I/O error during include (file not found) → 404 File not found.
- Unsupported or unparsable include target → 500 Syntax error in file.
Formatters run at the end of the pipeline before sending the file to the client. They convert the JavaScript object to the request output format. For a detailed explanation on how to select the desired format see URL structure and format selection
Parser → Modifier → Endpoint Filters (optional) → Formatter
| Variable | Purpose | Default |
|---|---|---|
FORMATTER_DEFAULT |
Fallback output format if a given endpoint has neither format nor a requested file has a known extension. |
"json" |
Registered formatters: json, yaml, js.
Authentication is done by providing a JSON Web Token (JWT) in the authentication header. The server checks if the token is valid and authenticates the user.
| Variable | Purpose | Default |
|---|---|---|
AUTH_ENABLE |
Enable/disable authentication. | false |
AUTH_OAUTH2_ISSUER |
Issuer (Authorization Server) base URL. | null |
AUTH_OAUTH2_JWKS |
JWKS URL for verifying JWT signatures. | null |
AUTH_OAUTH2_AUDIENCE |
Expected aud claim (your API identifier). |
null |
- To authenticate the client must provide a JSON Web Token (JWT) in its Authorization header. The server expects the following header:
Authorization: Bearer <JWT>. - It fetches the JWKS (public keys) from
AUTH_OAUTH2_JWKSand validates the token’s signature and standard claims. It enforces theaudclaim if configured. - On failure, it returns
401 Unauthorizedand setsWWW-Authenticatewith details (realm and error code/message). - If
AUTH_ENABLE=false, requests are not authenticated. Requests to the heartbeat location are never authenticated. - Under the hood, jose is performing the token validation.
- JSON Web Tokens (JWT): JWTs are self-contained, stateless tokens that carry all the necessary information for authentication and authorization within the token itself. They are digitally signed, which verifies the token's origin and integrity.
- Issuer: Your OAuth2/OpenID Provider (e.g., Zitadel, Keycloak, Auth0).
- JWKS: JSON Web Key Set. A URL where the provider publishes the public keys used to sign JWTs.
- Audience (
aud): Intended recipient of the token — usually the identifier of your API. - Realm: A descriptive name of the protection space, added to the
WWW-Authenticateheader.
Logging uses two channels:
- HTTP access logs: request/response summaries.
- Application logs: module-level diagnostics from parser/modifier/formatter/server/auth.
| Variable | Purpose | Default |
|---|---|---|
LOGGING_HTTP_ENABLE |
Enable/disable HTTP access logging pipeline. | true |
LOGGING_HTTP_STDOUT_ENABLE |
Enable stdout target for access logs. | false |
LOGGING_HTTP_STDOUT_LEVEL |
Min level for stdout target. | "info" |
LOGGING_HTTP_FILE_ENABLE |
Enable file target for access logs. | true |
LOGGING_HTTP_FILE |
File path prefix (rotated file base path). | CWD/logs/access |
LOGGING_HTTP_FILE_LEVEL |
Min level for file target. | "info" |
LOGGING_HTTP_LOGROTATION_SIZE |
Rotate after size. | "10M" |
LOGGING_HTTP_LOGROTATION_FREQUENCY |
Rotation cadence. | "daily" |
LOGGING_HTTP_LOGROTATION_LIMIT |
Keep up to N rotations. | 180 |
LOGGING_HTTP_LOGROTATION_EXTENSION |
File extension. | "log" |
LOGGING_HTTP_LOGROTATION_DATEFORMAT |
Date format used in filenames. | "yyyy-MM-dd" |
LOGGING_HTTP_LOGROTATION_SYMLINK |
Maintain a stable symlink to the latest file. | false |
| Variable | Purpose | Default |
|---|---|---|
LOGGING_APP_ENABLE |
Enable/disable application logging pipeline. | true |
LOGGING_APP_STDOUT_ENABLE |
Enable stdout target for app logs. | true |
LOGGING_APP_STDOUT_LEVEL |
Min level for stdout target. | "warn" |
LOGGING_APP_FILE_ENABLE |
Enable file target for app logs. | true |
LOGGING_APP_FILE |
File path prefix (rotated file base path). | CWD/logs/app |
LOGGING_APP_FILE_LEVEL |
Min level for file target. | "info" |
LOGGING_APP_LOGROTATION_SIZE |
Rotate after size. | "10M" |
LOGGING_APP_LOGROTATION_FREQUENCY |
Rotation cadence. | "daily" |
LOGGING_APP_LOGROTATION_LIMIT |
Keep up to N rotations. | 180 |
LOGGING_APP_LOGROTATION_EXTENSION |
File extension. | "log" |
LOGGING_APP_LOGROTATION_DATEFORMAT |
Date format used in filenames. | "yyyy-MM-dd" |
LOGGING_APP_LOGROTATION_SYMLINK |
Maintain a stable symlink to the latest file. | false |
The server has two “locations” that can handle a request: filesystem and endpoints. How the output format is chosen differs slightly between them.
- The requested URL path is mapped to a file under
SERVER_PATH_PUBLIC. Path traversal outside this root is rejected. - If the URL includes an file extension, the server tries that exact file first (e.g.
http://myserver/data/users.json→public/data/users.json). - If
FILESYSTEM_RESOLVE_EXT=trueand the URL has no file extension, uses an unknown extension or the file with the given extension is not existing, the server will probe files with the same basename using each registered parser extension, in registration order. By default:- parsers: YAML then JSON
- extensions tried:
yaml,yml,json,jsonc - example:
http://myserver/data/userswill resolve to the first existing file among/data/users.yaml,/data/users.yml,/data/users.json,/data/users.jsonc.
- The output formatter is selected from the URL’s extension:
http://myserver/data/users.json→ JSON formatterhttp://myserver/data/users.yaml→ YAML formatterhttp://myserver/data/users.js→ JS formatter (ES module:export default <data>;)
- If the URL has no file extension or uses an unknown extension, the server falls back to
FORMATTER_DEFAULT(default:json) regardless of the source file’s extension. Hence, if you want the filesystem to always return a specific format when extensions are omitted, setFORMATTER_DEFAULTaccordingly. - Example: with
FORMATTER_DEFAULT=json, a request tohttp://myserver/data/userswill return a JSON formatted file, even if the probed source isusers.yaml.
- JSON →
application/json; charset=utf-8 - YAML →
application/x-yaml; charset=utf-8 - JS (ES module) →
application/javascript; charset=utf-8
Assume that a file public/config/app.yaml is available on the server:
GET http://myserver/config/app.yaml→ parsespublic/config/app.yaml, returns YAML.GET http://myserver/config/appwithFILESYSTEM_RESOLVE_EXT=trueandFORMATTER_DEFAULT=json→ resolvespublic/config/app.yamland returns JSON.
Endpoints are configured via the ENDPOINTS JSON array. Matching uses literal paths with optional named parameters ({id}, {path}, …).
Only the file specified in endpoint.file will be processed for the response.
- The output formatter is chosen from:
endpoint.formatif present, otherwise- the extension of
endpoint.file, otherwise - the server falls back to
FORMATTER_DEFAULT(default:json)
- The URL’s own extension (if any) does not affect the formatter for endpoints.
- If you want deterministic output formats for endpoints, set the
formatfield explicitly.
- Endpoint
{ "path": "/api/v1/users", "file": "data/users.yaml", "format": "json" }→ returns always JSON, independent of the request URL. - Endpoint
{ "path": "/api/v1/users/{id}", "file": "data/users.json" }→ format inferred from the file (json) ifformatis omitted.
Filter functions can be configured within an endpoint location. The filesystem location is intended to just serve static files and does not provide any filter function.
Filter functions run after the parser and the modifier in the pipeline and allow you to programmatically transform the parsed data per endpoint before formatting.
Parser → Modifier → Endpoint Filters (optional) → Formatter
- Root folder:
SERVER_PATH_FILTER, defaultCWD/filter(CWD is the current working directory). - In each endpoint: set
"filter": "<file.js>". The code resolves the file aspath.resolve(SERVER_PATH_FILTER, <file>).
Each file must default-export a JavaScript function with the following signature:
// (TypeScript signature for reference, function must be JavaScript)
type FilterFn = (data: any, params?: Record<string, string>) => any | Promise<any>datacontains the raw data after handled by Parser → Modifierparamscontains the named placeholders extracted from your endpoint path, e.g. for/api/v1/users/{id}, a request to/api/v1/users/123passes{ id: "123" }. Multiple placholders are valid.
Notes:
- File location: Place these files under the directory configured by
SERVER_PATH_FILTER(defaultCWD/filter, CWD is the current working directory). InENDPOINTS, reference them by filename (e.g.,"filter": "./userById.js"). The server resolves them withpath.resolve(SERVER_PATH_FILTER, <file>). - The module cache is kept; subsequent requests reuse the loaded filter function. If you modify a filter which has already been in use, you need to restart the server.
- Parameters: Only path parameters (from
{...}segments) are passed inparams. If you need query parameters, encode them into the path or implement a custom location stage. - Return values: Return any JSON-serializable value. Returning
nullfor “not found” is a common, API-friendly pattern (it avoids 500 errors). - Errors: Throwing an error will surface as a 5xx response. If you need semantic 404/400 responses, prefer returning
null/an error object in the payload unless your deployment wraps filters with custom error handling. If the module doesn’t default-export a function, configuration fails with a ConfigurationError - Async filters: You can
awaitI/O (e.g., read another file). - TypeScript filters: If you prefer
.tsfilters, compile them to.jsin your build pipeline and reference the compiled file inENDPOINTS, or ensure your runtime can import TypeScript directly.
Below are ready-to-use filter function examples you can drop into your filter directory (the path is controlled by SERVER_PATH_FILTER, default CWD/filter, CWD is the current working directory).
Use when: Your data is an array of objects (e.g., users), and you want to return the one with a matching id.
// filter/userById.js
export default function userById(data, params) {
// Validation
if (!Array.isArray(data) || !params || !('id' in params)) return data
const id = String(params.id)
// Accept numeric or string ids, perform a string comparison.
const item = data.find((x) => String(x?.id) === id)
// Return null if not found (client gets `null` as JSON).
return item ?? null
}Endpoint example
[
{
"enable": true,
"path": "/api/v1/users/{id}",
"file": "./data/users.json",
"format": "json",
"filter": "./userById.js"
}
]Use when: Your data is a nested object and you want to extract a branch, e.g. /api/v1/config/{path} with {path} like database.pool or features.myFeature.
// filter/sectionByPath.js
export default function sectionByPath(data, params) {
const path = params?.path
if (!path || typeof data !== 'object' || data === null) return data
// Support dot-notation: "a.b.c"
const keys = String(path).split('.').filter(Boolean)
let current = data
for (const k of keys) {
if (current && typeof current === 'object' && k in current) {
current = current[k]
} else {
return null // branch not found
}
}
return current
}Endpoint example
[
{
"enable": true,
"path": "/api/v1/config/{path}",
"file": "./config/settings.yaml",
"format": "json",
"filter": "./sectionByPath.js"
}
]Use when: Your data is an array and you want to filter by a property (e.g., active === true) and return a reduced shape.
// filter/activeDevices.js
export default function activeDevices(data, _params) {
if (!Array.isArray(data)) return data
return data
.filter((d) => d && d.active === true)
.map((d) => ({
id: d.id,
name: d.name,
lastSeen: d.lastSeen ?? null,
}))
}Endpoint example
[
{
"enable": true,
"path": "/api/v1/devices/active",
"file": "./data/devices.yaml",
"format": "json",
"filter": "./activeDevices.js"
}
]Use when: Items have a tags array and you want to return items that include one or more requested tags. Supports comma-separated tags in {tag}.
// filter/itemsByTag.js
export default function itemsByTag(data, params) {
if (!Array.isArray(data)) return data
const raw = params?.tag
if (!raw) return data
const wanted = String(raw)
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
if (wanted.length === 0) return data
return data.filter((item) => {
const tags = Array.isArray(item?.tags) ? item.tags : []
const lower = tags.map((t) => String(t).toLowerCase())
return wanted.some((w) => lower.includes(w))
})
}Endpoint example
[
{
"enable": true,
"path": "/api/v1/items/by-tag/{tag}",
"file": "./data/items.json",
"format": "json",
"filter": "./itemsByTag.js"
}
]Use when: You need simple pagination on array data and you encode both values in the path.
// filter/paginate.js
function toInt(value, fallback) {
const n = Number.parseInt(String(value), 10)
return Number.isFinite(n) && n >= 0 ? n : fallback
}
export default function paginate(data, params) {
if (!Array.isArray(data)) return data
const offset = toInt(params?.offset, 0)
const limit = toInt(params?.limit, 20)
const slice = data.slice(offset, offset + limit)
return {
offset,
limit,
total: data.length,
items: slice,
}
}Endpoint example
[
{
"enable": true,
"path": "/api/v1/users/page/{offset}/{limit}",
"file": "data/users.json",
"format": "json",
"filter": "./paginate.js"
}
]The tiny-node-api server might run behind a reverse-proxy like NGINX. The following NGINX example configurations proxy to a local instance on localhost:3000. Adjust as needed.
server {
listen 80;
server_name example.com;
location /api/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://127.0.0.1:3000;
}
}server {
listen 80;
server_name example.com;
location ~* \.(json|yaml|yml)$ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://127.0.0.1:3000;
}
}- Terminate TLS in NGINX (
listen 443 ssl;) and pass to Node over HTTP. - By default, the server sets
ETag/Last-Modifiedfor file-backed responses. Ensureproxy_set_header If-None-Match $http_if_none_match;andproxy_set_header If-Modified-Since $http_if_modified_since;are not stripped if you rely on conditional GETs.
You can use the provided Dockerfile to build and run a Docker image.
services:
tiny-node-api:
build:
context: 'https://github.com/borispulyer/tiny-node-api.git'
# ... or build from local
#context: .
dockerfile: './Dockerfile'
container_name: 'tiny-node-api'
restart: 'unless-stopped'
security_opt:
- 'no-new-privileges:true'
healthcheck:
test:
- CMD-SHELL
- >
node -e "const http=require('http');
const port=process.env.SERVER_PORT||'3000';
const req=http.get({host:'127.0.0.1',port:port,path:'/_heartbeat'},res=>process.exit(res.statusCode===200?0:1));
req.on('error',()=>process.exit(1));
setTimeout(()=>{try{req.destroy();}catch{} process.exit(1)},2000);"
interval: 30s
timeout: 3s
retries: 5
start_period: 10s
ports:
- '3000:3000'
volumes:
- './public:/app/public:ro'
- './filter:/app/filter:ro'
- './logs:/app/logs'
#env_file: ".env"
environment:
###
# Server configuration
###
# Port for the HTTP server (default: 3000)
#SERVER_PORT: 3000
# Absolute or relative path to static configuration files (./public)
#SERVER_PATH_PUBLIC: "./public"
# Absolute or relative path where filter functions are located (./filter)
#SERVER_PATH_FILTER: "./filter"
# Enable the built-in heartbeat endpoint (true)
#SERVER_LOCATIONS_HEARTBEAT: true
# Enable custom endpoints defined in ENDPOINTS (true)
#SERVER_LOCATIONS_ENDPOINTS: true
# Enable direct filesystem access (true)
#SERVER_LOCATIONS_FILESYSTEM: true
# Cache-Control Header without authentication
#SERVER_CACHE_HEADER: 'stale-while-revalidate=300, stale-if-error=3600'
# Cache-Control Header with authentication
#SERVER_CACHE_HEADER_AUTH: 'private, no-cache'
# Socket timeout in milliseconds (5000 ms)
#SERVER_TIMEOUTS_SOCKET: 5000
# Keep-alive timeout in milliseconds (75000 ms)
#SERVER_TIMEOUTS_KEEPALIVE: 75000
# Headers timeout in milliseconds (80000 ms)
#SERVER_TIMEOUTS_HEADERS: 80000
# Request timeout in milliseconds (60000 ms)
#SERVER_TIMEOUTS_REQUEST: 60000
# Maximum number of requests per socket (1000 requests)
#SERVER_MAX_REQUESTS: 1000
###
# Filesystem behaviour
###
# Resolve file extensions automatically (true)
#FILESYSTEM_RESOLVE_EXT: true
###
# Endpoint definitions
###
# JSON array with endpoints ([]):
#ENDPOINTS:
# '[
# {
# "enable": true,
# "path": "/api/v1/users",
# "file": "data/users.yaml",
# "format": "json"
# }, {
# "enable": true,
# "path": "/api/v1/users/{id}",
# "file": "data/users.yaml",
# "format": "json",
# "filter": "userById.js"
# }
# ]'
###
# Authentication
###
# Enable OAuth2 authentication (false)
#AUTH_ENABLE: false
# OAuth2 issuer URI
#AUTH_OAUTH2_ISSUER: https://issuer.example.com
# JWKS endpoint providing public keys
#AUTH_OAUTH2_JWKS: https://issuer.example.com/.well-known/jwks.json
# Expected audience value
#AUTH_OAUTH2_AUDIENCE: my-audience
###
# Modifier modules
###
# Enable modifier pipeline (true)
#MODIFIER_ENABLE: true
# Enable modifier for merging files (true)
#MODIFIER_INCLUDE_ENABLE: true
###
# Default formatter
###
# Output format used when it is not specified (via file extensions e.g. http://example.com/file.json) or extension is not valid
#FORMATTER_DEFAULT: json
###
# HTTP request logging
###
# Enable HTTP access logs (true)
#LOGGING_HTTP_ENABLE: true
# Stream HTTP logs to stdout (false)
#LOGGING_HTTP_STDOUT_ENABLE: false
# Log level for HTTP stdout logging (options: silent|fatal|error|warn|info|debug|trace; default: info)
#LOGGING_HTTP_STDOUT_LEVEL: info
# Write HTTP logs to filesystem (true)
#LOGGING_HTTP_FILE_ENABLE: true
# File path prefix for HTTP logs (./logs/access)
#LOGGING_HTTP_FILE: ./logs/access
# Log level for HTTP file logging (options: silent|fatal|error|warn|info|debug|trace; default: info)
#LOGGING_HTTP_FILE_LEVEL: info
# Maximum size of each HTTP log before rotation (10M bytes)
#LOGGING_HTTP_LOGROTATION_SIZE: 10M
# How often HTTP logs rotate (options: daily|weekly; default: daily)
#LOGGING_HTTP_LOGROTATION_FREQUENCY: daily
# Number of rotated HTTP logs to keep (180 files)
#LOGGING_HTTP_LOGROTATION_LIMIT: 180
# File extension for rotated HTTP logs (log)
#LOGGING_HTTP_LOGROTATION_EXTENSION: log
# Date format in rotated HTTP log names (yyyy-MM-dd)
#LOGGING_HTTP_LOGROTATION_DATEFORMAT: yyyy-MM-dd
# Create symlink to latest HTTP log (false)
#LOGGING_HTTP_LOGROTATION_SYMLINK: false
###
# Application logging
###
# Enable application logs (true)
#LOGGING_APP_ENABLE: true
# Stream application logs to stdout (true)
#LOGGING_APP_STDOUT_ENABLE: true
# Log level for application stdout logging (options: silent|fatal|error|warn|info|debug|trace; default: warn)
#LOGGING_APP_STDOUT_LEVEL: warn
# Write application logs to filesystem (true)
#LOGGING_APP_FILE_ENABLE: true
# File path prefix for application logs (./logs/app)
#LOGGING_APP_FILE: ./logs/app
# Log level for application file logging (options: silent|fatal|error|warn|info|debug|trace; default: info)
#LOGGING_APP_FILE_LEVEL: info
# Maximum size of each application log before rotation (10M bytes)
#LOGGING_APP_LOGROTATION_SIZE: 10M
# How often application logs rotate (options: daily|weekly; default: daily)
#LOGGING_APP_LOGROTATION_FREQUENCY: daily
# Number of rotated application logs to keep (180 files)
#LOGGING_APP_LOGROTATION_LIMIT: 180
# File extension for rotated application logs (log)
#LOGGING_APP_LOGROTATION_EXTENSION: log
# Date format in rotated application log names (yyyy-MM-dd)
#LOGGING_APP_LOGROTATION_DATEFORMAT: yyyy-MM-dd
# Create symlink to latest application log (false)
#LOGGING_APP_LOGROTATION_SYMLINK: falseYou can deploy the tiny-node-api with NGINX in a single docker-compose.yaml:
services:
# Nginx
nginx:
image: 'docker.io/nginx:latest'
container_name: 'nginx'
restart: 'unless-stopped'
depends_on:
tiny-node-api:
condition: 'service_healthy'
security_opt:
- 'no-new-privileges:true'
networks:
- 'wan'
ports:
#- "80:80"
volumes:
- './public:/usr/share/nginx/html:ro'
- './nginx/config:/etc/nginx:ro'
# tiny-node-api
tiny-node-api:
build:
context: 'https://github.com/borispulyer/tiny-node-api.git'
# ... or build from local
#context: .
dockerfile: './Dockerfile'
container_name: 'tiny-node-api'
restart: 'unless-stopped'
security_opt:
- 'no-new-privileges:true'
healthcheck:
test:
- CMD-SHELL
- >
node -e "const http=require('http');
const port=process.env.SERVER_PORT||'3000';
const req=http.get({host:'127.0.0.1',port:port,path:'/_heartbeat'},res=>process.exit(res.statusCode===200?0:1));
req.on('error',()=>process.exit(1));
setTimeout(()=>{try{req.destroy();}catch{} process.exit(1)},2000);"
interval: 30s
timeout: 3s
retries: 5
start_period: 10s
volumes:
- './public:/app/public:ro'
- './filter:/app/filter:ro'
- './logs:/app/logs'
#env_file: ".env"
environment:
# Example endpoints (optional):
ENDPOINTS: >-
[
{"enable":true,"path":"/api/v1/users","file":"data/users.yaml","format":"json"},
{"enable":true,"path":"/api/v1/users/{id}","file":"data/users.yaml","format":"json","filter":"userById.js"}
]
networks:
wan:
driver: bridgeEdit the nginx.conf
server {
listen 80;
server_name _;
location /api {
proxy_pass http://tiny-node-api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}This section is for developers who want to extend or maintain the codebase.
-
src/server/server.ts– HTTP server lifecycle; applies authentication and routes through locations; setsETag/Last-Modifiedwhere possible.modules/auth.ts– Auth wrapper around the auth subsystem.endpoints.ts– Indexes and matchesENDPOINTS; resolves files; runs parser → modifiers → filter → formatter.filesystem.ts– Serves files underSERVER_PATH_PUBLIC; safe root checking; optional extension resolution.heartbeat.ts– Health endpoint.
-
src/parser/modules/–yaml.ts,json.tswith a commonParsershape.parser.ts– Registry and dispatch; error mapping; helper for supported extensions.
-
src/modifier/modules/–include.ts(resolves__include__/!include).modifier.ts– Registry and dispatch.
-
src/formatter/modules/–json.ts,yaml.ts,js.ts.formatter.ts– Registry and dispatch.
-
src/auth/auth.ts– JWT verification using JWKS.errors.ts– Auth-specific error classes andWWW-Authenticatehelpers.
-
src/core/config/– Types, defaults, env merge, validation.logger/– Application/access log setup.utilities/– Helpers (files.ts,imports.ts,parsers.ts,url.ts, etc.).
Parser module shape
export type Parser = {
extensions: string[]
fn: (file: string) => Promise<any>
}Example: Add TOML support
-
Install a TOML library (e.g.,
toml). -
Create
src/parser/modules/toml.ts:import fs from 'node:fs/promises' import * as toml from 'toml' // or your chosen lib import type { Parser } from '.' export default { extensions: ['toml'], fn: async (file: string) => toml.parse(await fs.readFile(file, 'utf8')), } satisfies Parser
-
Export it from
src/parser/modules/index.ts:export { default as toml } from './toml'
The registry auto-discovers everything exported from modules/index.ts.
Errors you may see
ParserMissingError– No parser registered for the file extension.ParserFilereadError– Read error (e.g., ENOENT).ParserSyntaxError– Parse exception (JSON.parse, YAML parse, etc.).
Modifier module shape
export type Modifier = {
selector: string
fn: (data: any, options?: any) => Promise<any>
}- The pipeline activates a modifier by name (e.g.,
"include") when it’s enabled in config.
Add a new modifier
Create src/modifier/modules/myModifier.ts with the shape above and export it in src/modifier/modules/index.ts.
Errors you may see
ModifierMissingError– You referenced a modifier that isn’t registered.ModifierFileAccesError– File access during modification failed.ModifierSyntaxError– Your modifier created invalid structure or failed on input.ModifierError– Generic modifier error.
Formatter module shape
export type Formatter = {
selectors: string[]
mime: string
fn: (data: any) => Promise<string>
}Add a custom output
Create src/formatter/modules/toml.ts and export it. selectors are the names you can put into endpoints[].format or choose via file extension.
Errors you may see
FormatterMissingError– No formatter for the requested output.
- Safe root check:
isFileWithinRoot(file, id='public')rejects files outside configured roots. It usespath.relativechecks. Note: it does not dereference symlinks; treat symlink policies carefully at deployment time. - Dynamic imports for filters:
imports.getFilterFn(file)loads and caches a default-exported function from a filter file (pathToFileURLis used to avoid bundling or resolution issues). - Env parsers: flexible boolean/number/port/log-level parsing (strings like
true,yes,onare supported). - Global error types
HttpError– Carries optionalstatusand headers for controlled responses.ConfigurationError– Thrown when config is invalid (e.g., a formatter/parsers/modifier/auth misconfiguration).
[ { // MUST be true to activate (if omitted, treated as disabled) "enable": true, // Request path; named params allowed via {param} "path": "/api/v1/users/{id}", // File to serve as the endpoint. Resolved under SERVER_PATH_PUBLIC. Might be outside of SERVER_PATH_PUBLIC. "file": "./users.yaml", // Optional: "json" | "yaml" | "js". If omitted, taken from file extension of the server request; if unknown, fallback to FORMATTER_DEFAULT. "format": "json", // Optional: path to a JavaScript filter file. Throws an error, if the file is not within SERVER_PATH_FILTER; relative locations are resolved under SERVER_PATH_FILTER "filter": "./userById.js", }, { // ... }, ]