@twick/render-server 0.15.12 → 0.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.dockerignore ADDED
@@ -0,0 +1,13 @@
1
+ node_modules
2
+ npm-debug.log
3
+ Dockerfile
4
+ .dockerignore
5
+ *.md
6
+ dist
7
+ .git
8
+ .gitignore
9
+ .env*
10
+ *.log
11
+ coverage
12
+ .nyc_output
13
+ test.js
package/Dockerfile ADDED
@@ -0,0 +1,39 @@
1
+ FROM ghcr.io/puppeteer/puppeteer:latest AS builder
2
+ WORKDIR /app
3
+
4
+ COPY package*.json ./
5
+ RUN npm install
6
+
7
+ # Copy only source and config (avoid overwriting node_modules from context)
8
+ COPY src ./src
9
+ COPY tsup.config.ts tsconfig.json ./
10
+ RUN npm run build
11
+ # Ensure server entry point exists (tsup outputs ESM as .mjs)
12
+ RUN test -f /app/dist/server.mjs || test -f /app/dist/server.js || (echo "Missing dist/server.mjs or server.js. Contents of /app/dist:" && ls -la /app/dist 2>/dev/null || true && exit 1)
13
+
14
+ FROM ghcr.io/puppeteer/puppeteer:latest
15
+
16
+ WORKDIR /app
17
+
18
+ # Switch to root to install system packages (needed for ffmpeg)
19
+ USER root
20
+
21
+ RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
22
+
23
+ # Drop back to the non-root Puppeteer user for runtime
24
+ USER pptruser
25
+
26
+ # Tell @twick/ffmpeg to use the system binaries
27
+ ENV FFMPEG_PATH=/usr/bin/ffmpeg
28
+ ENV FFPROBE_PATH=/usr/bin/ffprobe
29
+
30
+ COPY package*.json ./
31
+ RUN npm install --only=production
32
+
33
+ COPY --from=builder /app/dist ./dist
34
+
35
+ EXPOSE 5000
36
+
37
+ # Start the built-in render server on port 5000
38
+ CMD ["node", "dist/server.mjs"]
39
+
package/README.md CHANGED
@@ -20,7 +20,40 @@ pnpm add @twick/render-server
20
20
 
21
21
  ## Quick Start
22
22
 
23
- ### Option 1: Scaffold a Server (Recommended)
23
+ ### Option 1: Run via Docker
24
+
25
+ **Prebuilt image (no repo clone):**
26
+
27
+ ```bash
28
+ docker run --platform linux/amd64 -e PORT=5000 -p 5000:5000 ghcr.io/ncounterspecialist/render-server:<version>
29
+ ```
30
+
31
+ **Build and run from this package (monorepo):**
32
+
33
+ ```bash
34
+ cd packages/render-server
35
+ docker build -t twick-render-server:latest .
36
+ docker run -e PORT=5000 -p 5000:5000 twick-render-server:latest
37
+ ```
38
+
39
+ **Docker Compose (configurable via `.env`):**
40
+
41
+ ```bash
42
+ cd packages/render-server
43
+ cp .env.example .env # optional: edit port, rate limits, etc.
44
+ docker compose up -d
45
+ ```
46
+
47
+ The server listens on the port set by `PORT` (default in Docker: `5000`). Endpoints:
48
+
49
+ - **Render**: `POST http://localhost:<PORT>/api/render-video`
50
+ - **Download**: `GET http://localhost:<PORT>/download/:filename`
51
+ - **Health**: `GET http://localhost:<PORT>/health`
52
+
53
+ `ffmpeg` and `ffprobe` are preinstalled in the image.
54
+ The published image targets `linux/amd64`. On Apple Silicon (M1/M2/M3), Docker runs it under emulation, or use `--platform=linux/amd64`.
55
+
56
+ ### Option 2: Scaffold a Server (Recommended for customization)
24
57
 
25
58
  Scaffold a complete server with all endpoints configured:
26
59
 
@@ -46,7 +79,7 @@ npm run build && npm start # Production mode
46
79
 
47
80
  The server will start on port 3001 by default. You can change this by setting the `PORT` environment variable.
48
81
 
49
- ### Option 2: Use Programmatically
82
+ ### Option 3: Use Programmatically
50
83
 
51
84
  Import and use the `renderTwickVideo` function directly. The package supports both ESM and CommonJS:
52
85
 
@@ -134,20 +167,18 @@ Renders a video using Twick.
134
167
  ```json
135
168
  {
136
169
  "success": true,
137
- "downloadUrl": "http://localhost:3001/download/my-video.mp4"
170
+ "downloadUrl": "http://localhost:<PORT>/download/my-video.mp4"
138
171
  }
139
172
  ```
173
+ (`<PORT>` is the server port, e.g. `5000` when run via Docker.)
140
174
 
141
175
  ### GET /download/:filename
142
176
 
143
177
  Downloads a rendered video file. This endpoint is rate-limited to prevent abuse.
144
178
 
145
- **Rate Limits:**
146
- - 100 requests per 15 minutes per IP address
147
- - Rate limit headers are included in responses:
148
- - `X-RateLimit-Limit`: Maximum requests allowed
149
- - `X-RateLimit-Remaining`: Remaining requests in current window
150
- - `X-RateLimit-Reset`: When the rate limit window resets
179
+ **Rate limits** (configurable via environment variables, see [Configuration](#configuration)):
180
+ - Default: 100 requests per 15 minutes per IP
181
+ - Response headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
151
182
 
152
183
  ### GET /health
153
184
 
@@ -155,10 +186,17 @@ Health check endpoint. Returns server status and current timestamp.
155
186
 
156
187
  ## Configuration
157
188
 
158
- The server uses the following environment variables:
189
+ Environment variables (supported in Docker via `-e`, `docker-compose`, or `.env`):
190
+
191
+ | Variable | Description | Default |
192
+ |----------|-------------|---------|
193
+ | `PORT` | Server port | `5000` (Docker), `3001` (scaffolded server) |
194
+ | `NODE_ENV` | Environment | `production` |
195
+ | `RATE_LIMIT_WINDOW_MS` | Rate limit window, in milliseconds | `900000` (15 min) |
196
+ | `RATE_LIMIT_MAX_REQUESTS` | Max requests per IP per window | `100` |
197
+ | `RATE_LIMIT_CLEANUP_INTERVAL_MS` | Cleanup interval for rate-limit store, in ms | `60000` (1 min) |
159
198
 
160
- - `PORT`: Server port (default: 3001)
161
- - `NODE_ENV`: Environment (development/production)
199
+ Copy `.env.example` to `.env` in this package to customize when using Docker Compose.
162
200
 
163
201
  ## Package Development
164
202
 
package/dist/server.mjs CHANGED
@@ -42,13 +42,13 @@ var renderTwickVideo = async (variables, settings) => {
42
42
  var renderer_default = renderTwickVideo;
43
43
 
44
44
  // src/server.ts
45
- var PORT = process.env.PORT || 3001;
45
+ var PORT = process.env.PORT || 5e3;
46
46
  var BASE_PATH = `http://localhost:${PORT}`;
47
47
  var __filename = fileURLToPath(import.meta.url);
48
48
  var __dirname = path.dirname(__filename);
49
- var RATE_LIMIT_WINDOW_MS = 15 * 60 * 1e3;
50
- var RATE_LIMIT_MAX_REQUESTS = 100;
51
- var RATE_LIMIT_CLEANUP_INTERVAL_MS = 60 * 1e3;
49
+ var RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "900000", 10) || 15 * 60 * 1e3;
50
+ var RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? "100", 10) || 100;
51
+ var RATE_LIMIT_CLEANUP_INTERVAL_MS = parseInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS ?? "60000", 10) || 60 * 1e3;
52
52
  var rateLimitStore = /* @__PURE__ */ new Map();
53
53
  setInterval(() => {
54
54
  const now = Date.now();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts","../src/renderer.ts"],"sourcesContent":["import express from \"express\";\nimport cors from \"cors\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\nimport renderTwickVideo from \"./renderer\";\n\nconst PORT = process.env.PORT || 3001;\nconst BASE_PATH = `http://localhost:${PORT}`;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Rate limiting configuration\nconst RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes\nconst RATE_LIMIT_MAX_REQUESTS = 100; // Maximum requests per window\nconst RATE_LIMIT_CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every minute\n\n// In-memory store for rate limiting\ninterface RateLimitEntry {\n count: number;\n resetTime: number;\n}\n\nconst rateLimitStore = new Map<string, RateLimitEntry>();\n\n// Cleanup expired entries periodically\nsetInterval(() => {\n const now = Date.now();\n for (const [key, entry] of rateLimitStore.entries()) {\n if (now > entry.resetTime) {\n rateLimitStore.delete(key);\n }\n }\n}, RATE_LIMIT_CLEANUP_INTERVAL_MS);\n\n/**\n * Rate limiting middleware for API endpoints.\n * Tracks request counts per IP address and enforces rate limits\n * to prevent abuse of the render server.\n *\n * @param req - Express request object\n * @param res - Express response object\n * @param next - Express next function\n * \n * @example\n * ```js\n * app.use('/api', rateLimitMiddleware);\n * // Applies rate limiting to all /api routes\n * ```\n */\nconst rateLimitMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {\n const clientIP = req.ip || req.connection.remoteAddress || 'unknown';\n const now = Date.now();\n \n // Get or create rate limit entry for this IP\n let entry = rateLimitStore.get(clientIP);\n \n if (!entry || now > entry.resetTime) {\n // New window or expired entry\n entry = {\n count: 1,\n resetTime: now + RATE_LIMIT_WINDOW_MS\n };\n rateLimitStore.set(clientIP, entry);\n } else {\n // Increment count in current window\n entry.count++;\n \n if (entry.count > RATE_LIMIT_MAX_REQUESTS) {\n // Rate limit exceeded\n const retryAfter = Math.ceil((entry.resetTime - now) / 1000);\n res.set('Retry-After', retryAfter.toString());\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', '0');\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n return res.status(429).json({\n success: false,\n error: 'Too many requests',\n message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,\n retryAfter\n });\n }\n }\n \n // Add rate limit headers\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', (RATE_LIMIT_MAX_REQUESTS - entry.count).toString());\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n next();\n};\n\nconst nodeApp: import(\"express\").Express = express();\n\nnodeApp.use(cors());\nnodeApp.use(express.json());\n\n// Serve static files from output directory\nnodeApp.use(\"/output\", express.static(path.join(__dirname, \"../output\")));\n\n/**\n * POST endpoint for video rendering requests.\n * Accepts project variables and settings, renders the video,\n * and returns a download URL for the completed video.\n *\n * @param req - Express request object containing variables and settings\n * @param res - Express response object\n * \n * @example\n * ```js\n * POST /api/render-video\n * Body: { variables: {...}, settings: {...} }\n * Response: { success: true, downloadUrl: \"...\" }\n * ```\n */\nnodeApp.post(\"/api/render-video\", async (req, res) => {\n const { variables, settings } = req.body;\n\n try {\n const outputPath = await renderTwickVideo(variables, settings);\n res.json({\n success: true,\n downloadUrl: `${BASE_PATH}/download/${path.basename(outputPath)}`,\n });\n } catch (error) {\n console.error(\"Render error:\", error);\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : \"Unknown error\",\n });\n }\n});\n\n/**\n * GET endpoint for downloading rendered videos.\n * Serves video files with rate limiting and security checks\n * to prevent path traversal attacks.\n *\n * @param req - Express request object with filename parameter\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /download/video-123.mp4\n * // Downloads the specified video file\n * ```\n */\nnodeApp.get(\"/download/:filename\", rateLimitMiddleware, (req, res) => {\n const outputDir = path.resolve(__dirname, \"../output\");\n const requestedPath = path.resolve(outputDir, req.params.filename);\n if (!requestedPath.startsWith(outputDir + path.sep)) {\n // Attempted path traversal or access outside output directory\n res.status(403).json({\n success: false,\n error: \"Forbidden\",\n });\n return;\n }\n res.download(requestedPath, (err) => {\n if (err) {\n res.status(404).json({\n success: false,\n error: \"File not found\",\n });\n }\n });\n});\n\n/**\n * Health check endpoint for monitoring server status.\n * Returns server status and current timestamp for health monitoring.\n *\n * @param req - Express request object\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /health\n * Response: { status: \"ok\", timestamp: \"2024-01-01T00:00:00.000Z\" }\n * ```\n */\nnodeApp.get(\"/health\", (req, res) => {\n res.json({\n status: \"ok\",\n timestamp: new Date().toISOString(),\n });\n});\n\n// Export the app for programmatic usage\nexport default nodeApp;\n\n// Start the server\nnodeApp.listen(PORT, () => {\n console.log(`Render server running on port ${PORT}`);\n console.log(`Health check: ${BASE_PATH}/health`);\n console.log(`API endpoint: ${BASE_PATH}/api/render-video`);\n console.log(`Download endpoint rate limited: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS / 60000} minutes`);\n});\n","import { renderVideo } from \"@twick/renderer\";\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n try {\n const { input } = variables;\n const { properties } = input;\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: properties.width,\n y: properties.height,\n },\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables: variables,\n settings: mergedSettings,\n });\n console.log(\"Successfully rendered: \", file);\n return file;\n } catch (error) {\n console.error(\"Render error:\", error);\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/server.ts","../src/renderer.ts"],"sourcesContent":["import express from \"express\";\nimport cors from \"cors\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\nimport renderTwickVideo from \"./renderer\";\n\nconst PORT = process.env.PORT || 5000;\nconst BASE_PATH = `http://localhost:${PORT}`;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Rate limiting configuration (all configurable via env)\nconst RATE_LIMIT_WINDOW_MS =\n parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? \"900000\", 10) || 15 * 60 * 1000; // default 15 min (ms)\nconst RATE_LIMIT_MAX_REQUESTS =\n parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? \"100\", 10) || 100; // max requests per window\nconst RATE_LIMIT_CLEANUP_INTERVAL_MS =\n parseInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS ?? \"60000\", 10) || 60 * 1000; // default 1 min (ms)\n\n// In-memory store for rate limiting\ninterface RateLimitEntry {\n count: number;\n resetTime: number;\n}\n\nconst rateLimitStore = new Map<string, RateLimitEntry>();\n\n// Cleanup expired entries periodically\nsetInterval(() => {\n const now = Date.now();\n for (const [key, entry] of rateLimitStore.entries()) {\n if (now > entry.resetTime) {\n rateLimitStore.delete(key);\n }\n }\n}, RATE_LIMIT_CLEANUP_INTERVAL_MS);\n\n/**\n * Rate limiting middleware for API endpoints.\n * Tracks request counts per IP address and enforces rate limits\n * to prevent abuse of the render server.\n *\n * @param req - Express request object\n * @param res - Express response object\n * @param next - Express next function\n * \n * @example\n * ```js\n * app.use('/api', rateLimitMiddleware);\n * // Applies rate limiting to all /api routes\n * ```\n */\nconst rateLimitMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {\n const clientIP = req.ip || req.connection.remoteAddress || 'unknown';\n const now = Date.now();\n \n // Get or create rate limit entry for this IP\n let entry = rateLimitStore.get(clientIP);\n \n if (!entry || now > entry.resetTime) {\n // New window or expired entry\n entry = {\n count: 1,\n resetTime: now + RATE_LIMIT_WINDOW_MS\n };\n rateLimitStore.set(clientIP, entry);\n } else {\n // Increment count in current window\n entry.count++;\n \n if (entry.count > RATE_LIMIT_MAX_REQUESTS) {\n // Rate limit exceeded\n const retryAfter = Math.ceil((entry.resetTime - now) / 1000);\n res.set('Retry-After', retryAfter.toString());\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', '0');\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n return res.status(429).json({\n success: false,\n error: 'Too many requests',\n message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,\n retryAfter\n });\n }\n }\n \n // Add rate limit headers\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', (RATE_LIMIT_MAX_REQUESTS - entry.count).toString());\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n next();\n};\n\nconst nodeApp: import(\"express\").Express = express();\n\nnodeApp.use(cors());\nnodeApp.use(express.json());\n\n// Serve static files from output directory\nnodeApp.use(\"/output\", express.static(path.join(__dirname, \"../output\")));\n\n/**\n * POST endpoint for video rendering requests.\n * Accepts project variables and settings, renders the video,\n * and returns a download URL for the completed video.\n *\n * @param req - Express request object containing variables and settings\n * @param res - Express response object\n * \n * @example\n * ```js\n * POST /api/render-video\n * Body: { variables: {...}, settings: {...} }\n * Response: { success: true, downloadUrl: \"...\" }\n * ```\n */\nnodeApp.post(\"/api/render-video\", async (req, res) => {\n const { variables, settings } = req.body;\n\n try {\n const outputPath = await renderTwickVideo(variables, settings);\n res.json({\n success: true,\n downloadUrl: `${BASE_PATH}/download/${path.basename(outputPath)}`,\n });\n } catch (error) {\n console.error(\"Render error:\", error);\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : \"Unknown error\",\n });\n }\n});\n\n/**\n * GET endpoint for downloading rendered videos.\n * Serves video files with rate limiting and security checks\n * to prevent path traversal attacks.\n *\n * @param req - Express request object with filename parameter\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /download/video-123.mp4\n * // Downloads the specified video file\n * ```\n */\nnodeApp.get(\"/download/:filename\", rateLimitMiddleware, (req, res) => {\n const outputDir = path.resolve(__dirname, \"../output\");\n const requestedPath = path.resolve(outputDir, req.params.filename);\n if (!requestedPath.startsWith(outputDir + path.sep)) {\n // Attempted path traversal or access outside output directory\n res.status(403).json({\n success: false,\n error: \"Forbidden\",\n });\n return;\n }\n res.download(requestedPath, (err) => {\n if (err) {\n res.status(404).json({\n success: false,\n error: \"File not found\",\n });\n }\n });\n});\n\n/**\n * Health check endpoint for monitoring server status.\n * Returns server status and current timestamp for health monitoring.\n *\n * @param req - Express request object\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /health\n * Response: { status: \"ok\", timestamp: \"2024-01-01T00:00:00.000Z\" }\n * ```\n */\nnodeApp.get(\"/health\", (req, res) => {\n res.json({\n status: \"ok\",\n timestamp: new Date().toISOString(),\n });\n});\n\n// Export the app for programmatic usage\nexport default nodeApp;\n\n// Start the server\nnodeApp.listen(PORT, () => {\n console.log(`Render server running on port ${PORT}`);\n console.log(`Health check: ${BASE_PATH}/health`);\n console.log(`API endpoint: ${BASE_PATH}/api/render-video`);\n console.log(`Download endpoint rate limited: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS / 60000} minutes`);\n});\n","import { renderVideo } from \"@twick/renderer\";\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n try {\n const { input } = variables;\n const { properties } = input;\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: properties.width,\n y: properties.height,\n },\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables: variables,\n settings: mergedSettings,\n });\n console.log(\"Successfully rendered: \", file);\n return file;\n } catch (error) {\n console.error(\"Render error:\", error);\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":"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","names":[]}
@@ -0,0 +1,22 @@
1
+ # @twick/render-server — configurable via .env
2
+ # Copy .env.example to .env and adjust values.
3
+
4
+ services:
5
+ render-server:
6
+ build:
7
+ context: .
8
+ dockerfile: Dockerfile
9
+ image: twick-render-server:latest
10
+ container_name: twick-render-server
11
+ restart: unless-stopped
12
+
13
+ environment:
14
+ PORT: "${PORT:-5000}"
15
+ NODE_ENV: "${NODE_ENV:-production}"
16
+ # Rate limiting: window in ms, max requests per window, cleanup interval in ms
17
+ RATE_LIMIT_WINDOW_MS: "${RATE_LIMIT_WINDOW_MS:-900000}"
18
+ RATE_LIMIT_MAX_REQUESTS: "${RATE_LIMIT_MAX_REQUESTS:-100}"
19
+ RATE_LIMIT_CLEANUP_INTERVAL_MS: "${RATE_LIMIT_CLEANUP_INTERVAL_MS:-60000}"
20
+
21
+ ports:
22
+ - "${PORT:-5000}:${PORT:-5000}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twick/render-server",
3
- "version": "0.15.12",
3
+ "version": "0.15.13",
4
4
  "license": "https://github.com/ncounterspecialist/twick/blob/main/LICENSE.md",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -26,12 +26,12 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@twick/2d": "^0.15.12",
30
- "@twick/core": "^0.15.12",
31
- "@twick/ffmpeg": "^0.15.12",
32
- "@twick/renderer": "^0.15.12",
33
- "@twick/ui": "^0.15.12",
34
- "@twick/visualizer": "0.15.12",
29
+ "@twick/2d": "^0.15.13",
30
+ "@twick/core": "^0.15.13",
31
+ "@twick/ffmpeg": "^0.15.13",
32
+ "@twick/renderer": "^0.15.13",
33
+ "@twick/ui": "^0.15.13",
34
+ "@twick/visualizer": "0.15.13",
35
35
  "cors": "^2.8.5",
36
36
  "express": "^4.18.2",
37
37
  "express-rate-limit": "^8.0.1",
package/package.json.bak CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twick/render-server",
3
- "version": "0.15.12",
3
+ "version": "0.15.13",
4
4
  "license": "https://github.com/ncounterspecialist/twick/blob/main/LICENSE.md",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -26,12 +26,12 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@twick/2d": "^0.15.12",
30
- "@twick/core": "^0.15.12",
31
- "@twick/ffmpeg": "^0.15.12",
32
- "@twick/renderer": "^0.15.12",
33
- "@twick/ui": "^0.15.12",
34
- "@twick/visualizer": "0.15.12",
29
+ "@twick/2d": "^0.15.13",
30
+ "@twick/core": "^0.15.13",
31
+ "@twick/ffmpeg": "^0.15.13",
32
+ "@twick/renderer": "^0.15.13",
33
+ "@twick/ui": "^0.15.13",
34
+ "@twick/visualizer": "0.15.13",
35
35
  "cors": "^2.8.5",
36
36
  "express": "^4.18.2",
37
37
  "express-rate-limit": "^8.0.1",
package/src/server.ts CHANGED
@@ -4,16 +4,19 @@ import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import renderTwickVideo from "./renderer";
6
6
 
7
- const PORT = process.env.PORT || 3001;
7
+ const PORT = process.env.PORT || 5000;
8
8
  const BASE_PATH = `http://localhost:${PORT}`;
9
9
 
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
12
 
13
- // Rate limiting configuration
14
- const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
15
- const RATE_LIMIT_MAX_REQUESTS = 100; // Maximum requests per window
16
- const RATE_LIMIT_CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every minute
13
+ // Rate limiting configuration (all configurable via env)
14
+ const RATE_LIMIT_WINDOW_MS =
15
+ parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "900000", 10) || 15 * 60 * 1000; // default 15 min (ms)
16
+ const RATE_LIMIT_MAX_REQUESTS =
17
+ parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? "100", 10) || 100; // max requests per window
18
+ const RATE_LIMIT_CLEANUP_INTERVAL_MS =
19
+ parseInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS ?? "60000", 10) || 60 * 1000; // default 1 min (ms)
17
20
 
18
21
  // In-memory store for rate limiting
19
22
  interface RateLimitEntry {