dns retry

This commit is contained in:
cpu
2025-03-28 01:47:05 +01:00
parent 398c6473a4
commit 228f4984d8
12 changed files with 192 additions and 81 deletions

View File

@@ -5,4 +5,6 @@ Dockerfile
.git .git
.gitignore .gitignore
README.md README.md
*.example *.example
.env.example
.labels.example

View File

@@ -10,11 +10,6 @@ VAPID_PRIVATE_KEY=
# Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact # Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact
VAPID_SUBJECT=mailto:admin@virtonline.eu VAPID_SUBJECT=mailto:admin@virtonline.eu
# Flic Button Configuration
FLIC_BUTTON1_SERIAL=your_button1_serial
FLIC_BUTTON2_SERIAL=your_button2_serial
FLIC_BUTTON3_SERIAL=your_button3_serial
# Subscription Storage # Subscription Storage
SUBSCRIPTIONS_FILE=subscriptions.json SUBSCRIPTIONS_FILE=subscriptions.json
@@ -30,4 +25,19 @@ LOG_LEVEL=INFO
# If you want to add a simple security layer between Flic and this app. # If you want to add a simple security layer between Flic and this app.
# If set, configure Flic's HTTP request to include an "Authorization: Bearer YOUR_SECRET_VALUE" header. # If set, configure Flic's HTTP request to include an "Authorization: Bearer YOUR_SECRET_VALUE" header.
# use e.g.: openssl rand -hex 32 # use e.g.: openssl rand -hex 32
FLIC_SECRET= FLIC_SECRET=
# --- DNS and Network Configuration ---
# These settings help with Docker DNS resolution issues (EAI_AGAIN errors)
# Maximum number of retry attempts for failed DNS resolutions
MAX_NOTIFICATION_RETRIES=3
# Initial delay in milliseconds before first retry (will increase with backoff)
INITIAL_RETRY_DELAY_MS=1000
# DNS resolution timeout in milliseconds
DNS_TIMEOUT_MS=5000
# HTTP request timeout in milliseconds
HTTP_TIMEOUT_MS=10000

2
.gitignore vendored
View File

@@ -1,5 +1,6 @@
myenv myenv
.vscode .vscode
.cursor
# Node.js # Node.js
node_modules/ node_modules/
@@ -8,7 +9,6 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.env .env
subscriptions.json subscriptions.json
labels
# OS generated files # OS generated files
.DS_Store .DS_Store

View File

@@ -7,8 +7,9 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
## Features ## Features
* Receives POST requests on `/flic-webhook`. * Receives POST requests on `/flic-webhook`.
* Parses `button_id` and `click_type` from the Flic request body. * Uses HTTP headers `Button-Name` and `Timestamp` from the Flic request.
* Looks up the target PWA push subscription based on `button_id` in a JSON file. * Parses `click_type` from the Flic request body.
* Looks up the target PWA push subscription based on the `Button-Name` header in a JSON file.
* Sends a Web Push notification containing the click details (action, button, timestamp) to the corresponding PWA subscription. * Sends a Web Push notification containing the click details (action, button, timestamp) to the corresponding PWA subscription.
* Integrates with Traefik v3 via Docker labels. * Integrates with Traefik v3 via Docker labels.
* Configurable via environment variables (`.env` file). * Configurable via environment variables (`.env` file).
@@ -27,10 +28,10 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
## System Architecture ## System Architecture
### Subscription Flow ### Subscription Flow
![Subscription Flow](/diagrams/subscription-flow.png) <img src="images/subscription-flow.png" width="600" alt="Subscription Flow">
### Interaction Flow ### Interaction Flow
![Interaction Flow](/diagrams/interaction-flow.png) <img src="images/interaction-flow.png" width="300" alt="Interaction Flow">
## Project Structure ## Project Structure
@@ -64,6 +65,10 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
* `ALLOWED_ORIGINS`: Comma-separated list of domains allowed by CORS. Include your PWA's domain if it needs to interact directly (e.g., for setup). Example: `https://my-pwa.com`. * `ALLOWED_ORIGINS`: Comma-separated list of domains allowed by CORS. Include your PWA's domain if it needs to interact directly (e.g., for setup). Example: `https://my-pwa.com`.
* `ALLOWED_METHODS`: (Default: `POST,OPTIONS`) Standard methods needed. * `ALLOWED_METHODS`: (Default: `POST,OPTIONS`) Standard methods needed.
* `ALLOWED_HEADERS`: (Default: `Content-Type,Authorization`) Standard headers needed. * `ALLOWED_HEADERS`: (Default: `Content-Type,Authorization`) Standard headers needed.
* `MAX_NOTIFICATION_RETRIES`: (Default: `3`) Number of retry attempts for failed push notifications. Must be a number.
* `INITIAL_RETRY_DELAY_MS`: (Default: `1000`) Initial delay in milliseconds before first retry. Must be a number.
* `DNS_TIMEOUT_MS`: (Default: `5000`) DNS resolution timeout in milliseconds. Must be a number.
* `HTTP_TIMEOUT_MS`: (Default: `10000`) HTTP request timeout in milliseconds. Must be a number.
* `TRAEFIK_SERVICE_HOST`: Your public domain for this service (e.g., `webpush.virtonline.eu`). * `TRAEFIK_SERVICE_HOST`: Your public domain for this service (e.g., `webpush.virtonline.eu`).
* `TRAEFIK_CERT_RESOLVER`: The name of your TLS certificate resolver configured in Traefik (e.g., `le`, `myresolver`). * `TRAEFIK_CERT_RESOLVER`: The name of your TLS certificate resolver configured in Traefik (e.g., `le`, `myresolver`).
@@ -134,25 +139,27 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
In your Flic app or Flic Hub SDK interface: In your Flic app or Flic Hub SDK interface:
1. Select your Flic button. 1. Select your Flic button.
2. Add an "Internet Request" action (or similar HTTP request action) for Single Click, Double Click, and/or Hold events. 2. Add an "Internet Request" action.
3. **URL:** `https://<YOUR_TRAEFIK_SERVICE_HOST>/flic-webhook` (e.g., `https://webpush.virtonline.eu/flic-webhook`) 3. Fill in the following details:
4. **Method:** `POST` * Set `POST` method.
5. **Body Type:** `JSON` (or `application/json`) * Set URL: `https://webpush.virtonline.eu/flic-webhook`
6. **Body:** Configure the JSON body to include the button's serial number and the click type. Flic usually provides variables for these. The backend expects `button_id` and `click_type`. Adapt the keys if needed, or modify `server.js` to expect different keys (e.g., `serialNumber`). * Add headers:
* Key: `Authorization`
* Value: `Bearer <FLIC_SECRET>` (Replace `<FLIC_SECRET>` with the actual secret from your `.env` file).
* **Body:** Configure the JSON body to include the click type.
```json ```json
{ {
"button_id": "{serialNumber}", "click_type": "SingleClick"
"click_type": "{clickType}",
"timestamp": "{timestamp}"
} }
``` ```
*(Verify the exact variable names like `{serialNumber}`, `{clickType}`, `{timestamp}` within your specific Flic interface.)* * Set Content-Type: `application/json`.
7. **Headers:** * It should look like this:
* Add `Content-Type: application/json`.
* **(Optional - if `FLIC_SECRET` is set):** Add an `Authorization` header:
* Key: `Authorization`
* Value: `Bearer <YOUR_FLIC_SECRET_VALUE>` (Replace `<YOUR_FLIC_SECRET_VALUE>` with the actual secret from your `.env` file).
<!-- <img src="images/flic-button-request.png" width="300" alt="Flic Button Request Configuration"> // TODO: Update image -->
* Tap on `Save action`.
4. Repeat for Double Click and/or Hold events.
## API Endpoint ## API Endpoint
* **`POST /flic-webhook`** * **`POST /flic-webhook`**
@@ -161,16 +168,14 @@ In your Flic app or Flic Hub SDK interface:
* **Request Body (JSON):** * **Request Body (JSON):**
```json ```json
{ {
"button_id": "SERIAL_NUMBER_OF_FLIC_BUTTON", "click_type": "SingleClick | DoubleClick | Hold"
"click_type": "SingleClick | DoubleClick | Hold",
"timestamp": "ISO_8601_TIMESTAMP_STRING (Optional)"
} }
``` ```
* **Responses:** * **Responses:**
* `200 OK`: Webhook received, push notification sent successfully. * `200 OK`: Webhook received, push notification sent successfully.
* `400 Bad Request`: Missing `button_id` or `click_type` in the request body. * `400 Bad Request`: Missing `Button-Name` header or `click_type` in the request body.
* `401 Unauthorized`: Missing or invalid Bearer token (if `FLIC_SECRET` is enabled). * `401 Unauthorized`: Missing or invalid Bearer token (if `FLIC_SECRET` is enabled).
* `404 Not Found`: No subscription found in `subscriptions.json` for the given `button_id`. * `404 Not Found`: No subscription found in `subscriptions.json` for the given `Button-Name`.
* `410 Gone`: The push subscription associated with the button was rejected by the push service (likely expired or revoked). * `410 Gone`: The push subscription associated with the button was rejected by the push service (likely expired or revoked).
* `500 Internal Server Error`: Failed to send the push notification for other reasons. * `500 Internal Server Error`: Failed to send the push notification for other reasons.
@@ -184,6 +189,35 @@ In your Flic app or Flic Hub SDK interface:
} }
``` ```
## Testing the Webhook
Once your service is up and running, you can test the webhook endpoint using curl or any API testing tool:
**Note:** In the command below, replace `a74181969a613c545d66c1e436e75c1e4a6` with your actual FLIC_SECRET value from your .env file.
```bash
curl -X POST https://webpush.virtonline.eu/flic-webhook \
-H "Authorization: Bearer a74181969a613c545d66c1e436e75c1e4a6" \
-H "Content-Type: application/json" \
-H "Button-Name: Game" \
-H "Timestamp: 2025-03-26T01:10:20Z" \
-d '{
"click_type": "SingleClick"
}'
```
The expected response should be:
```json
{"message":"Push notification sent successfully"}
```
If successful, the above response indicates that:
1. Your webhook endpoint is properly configured
2. The button ID was found in your subscriptions.json file
3. The web push notification was successfully sent to the registered PUSH API endpoint (e.g. https://jmt17.google.com/fcm/send/cf907M...)
If you receive a different response, refer to the Troubleshooting section below.
## Troubleshooting ## Troubleshooting
* **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications. * **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications.

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

32
labels Normal file
View File

@@ -0,0 +1,32 @@
# Enable Traefik for this container
traefik.enable=true
# Docker Network
traefik.docker.network=traefik
# Route requests based on Host
traefik.http.routers.flic-webhook-webpush.rule=Host(`webpush.virtonline.eu`)
# Specify the entrypoint ('websecure' for HTTPS)
traefik.http.routers.flic-webhook-webpush.entrypoints=web-secure
traefik.http.routers.flic-webhook-webpush.tls=true # Enable TLS
traefik.http.routers.flic-webhook-webpush.tls.certResolver=default
# Link the router to the service defined below
traefik.http.routers.flic-webhook-webpush.service=flic-webhook-webpush
# Point the service to the container's port
traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000
# Middleware CORS
traefik.http.middlewares.cors-headers.headers.accesscontrolallowmethods=POST,GET,OPTIONS # Allow POST, GET, and OPTIONS requests
traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu # Allow requests from game-timer.virtonline.eu
traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization # Allow Content-Type and Authorization headers
traefik.http.middlewares.cors-headers.headers.accesscontrolmaxage=600 # Cache preflight responses for 10 minutes
traefik.http.middlewares.cors-headers.headers.addvaryheader=true # Add Vary header to responses
# Apply the middleware to the router
traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers
# Middleware Rate Limiting
traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 # requests per second
traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20
# Apply the middleware to the router
traefik.http.routers.flic-webhook-webpush.middlewares=flic-ratelimit

View File

@@ -1,29 +0,0 @@
# Traefik v3 Labels for flic-webhook-webpush service
# Enable Traefik for this container
traefik.enable=true
# --- HTTP Router Definition ---
# Define an HTTP router named 'flic-webhook-http'
# Route requests based on Host and PathPrefix
traefik.http.routers.flic-webhook.rule=Host(`webpush.virtonline.eu`)
# Specify the entrypoint (e.g., 'websecure' for HTTPS)
traefik.http.routers.flic-webhook.entrypoints=websecure
# Specify the TLS certificate resolver
traefik.http.routers.flic-webhook.tls.certresolver=default
# Link this router to the service defined below
traefik.http.routers.flic-webhook.service=flic-webhook
# --- HTTP Service Definition ---
# Define an HTTP service named 'flic-webhook'
# Point the service to the container's port (default 3000)
traefik.http.services.flic-webhook.loadbalancer.server.port=3000
# --- Middleware (Optional Example: Rate Limiting - Uncomment to enable) ---
# traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 # requests per second
# traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20
# traefik.http.routers.flic-webhook.middlewares=flic-ratelimit
# --- Docker Network ---
# Ensure Traefik uses the correct network to communicate with the container
traefik.docker.network=traefik

104
server.js
View File

@@ -3,6 +3,7 @@ const webpush = require('web-push');
const cors = require('cors'); const cors = require('cors');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const dns = require('dns'); // Add DNS module
// Load environment variables from .env file // Load environment variables from .env file
require('dotenv').config(); require('dotenv').config();
@@ -19,7 +20,26 @@ const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret for
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(',').map(origin => origin.trim()).filter(origin => origin); const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(',').map(origin => origin.trim()).filter(origin => origin);
const allowedMethods = (process.env.ALLOWED_METHODS || "POST,OPTIONS,GET").split(',').map(method => method.trim()).filter(method => method); const allowedMethods = (process.env.ALLOWED_METHODS || "POST,OPTIONS,GET").split(',').map(method => method.trim()).filter(method => method);
const allowedHeaders = (process.env.ALLOWED_HEADERS || "Content-Type,Authorization").split(',').map(header => header.trim()).filter(header => header); const allowedHeaders = (process.env.ALLOWED_HEADERS || "Content-Type,Authorization").split(',').map(header => header.trim()).filter(header => header);
// Retry configuration for DNS resolution issues
const maxRetries = parseInt(process.env.MAX_NOTIFICATION_RETRIES || 3, 10);
const initialRetryDelay = parseInt(process.env.INITIAL_RETRY_DELAY_MS || 1000, 10); // 1 second
// HTTP request timeout configuration
const httpTimeout = parseInt(process.env.HTTP_TIMEOUT_MS || 10000, 10); // 10 seconds
// Configure global HTTP agent with timeouts to prevent hanging requests
const https = require('https');
const http = require('http');
// Custom HTTPS agent with timeout
const httpsAgent = new https.Agent({
keepAlive: true,
timeout: httpTimeout,
maxSockets: 50, // Limit concurrent connections
});
// Apply the agent to the webpush module if possible
// Note: The web-push library might use its own agent, but this is a precaution
https.globalAgent = httpsAgent;
// --- Validation --- // --- Validation ---
if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) { if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) {
@@ -34,6 +54,43 @@ webpush.setVapidDetails(
vapidPrivateKey vapidPrivateKey
); );
// Configure DNS settings for more reliable resolution in containerized environments
// These settings can help with temporary DNS resolution failures
dns.setDefaultResultOrder('ipv4first'); // Prefer IPv4 to avoid some IPv6 issues in containers
const dnsTimeout = parseInt(process.env.DNS_TIMEOUT_MS || 5000, 10);
dns.setServers(dns.getServers()); // Reset DNS servers (can help in some Docker environments)
// You can optionally configure a specific DNS server if needed:
// Example: dns.setServers(['8.8.8.8', '1.1.1.1']);
// --- Utility function for retrying web push notifications with exponential backoff ---
async function sendWebPushWithRetry(subscription, payload, retryCount = 0, delay = initialRetryDelay) {
try {
return await webpush.sendNotification(subscription, payload);
} catch (error) {
// Check if the error is a DNS resolution error that might be temporary
const isDnsError = error.code === 'EAI_AGAIN' ||
error.code === 'ENOTFOUND' ||
error.code === 'ETIMEDOUT';
if (isDnsError && retryCount < maxRetries) {
console.log(`DNS resolution failed (${error.code}). Retrying notification in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})...`);
// Wait for the delay
await new Promise(resolve => setTimeout(resolve, delay));
// Exponential backoff with jitter
const nextDelay = delay * (1.5 + Math.random() * 0.5);
// Retry recursively with increased count and delay
return sendWebPushWithRetry(subscription, payload, retryCount + 1, nextDelay);
}
// If we've exhausted retries or it's not a DNS error, rethrow
throw error;
}
}
// --- Subscription Loading and Management --- // --- Subscription Loading and Management ---
let subscriptions = {}; // In-memory cache of subscriptions let subscriptions = {}; // In-memory cache of subscriptions
@@ -159,51 +216,54 @@ app.post('/subscribe', async (req, res) => {
// --- Flic Webhook Endpoint --- // --- Flic Webhook Endpoint ---
// Apply Flic-specific authentication ONLY to this route // Apply Flic-specific authentication ONLY to this route
app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => { app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => {
// Assuming Flic sends 'button_id' which is the serial number // Get buttonName from Header 'Button-Name' and timestamp from Header 'Timestamp'
const { button_id, click_type, timestamp } = req.body; const buttonName = req.headers['button-name'];
const timestamp = req.headers['timestamp'];
// Still get click_type from the request body
const { click_type } = req.body;
console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); console.log(`Received webhook: Button=${buttonName}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`);
// Basic validation // Basic validation
if (!button_id || !click_type) { if (!buttonName || !click_type) {
console.warn(`Webhook Error: Missing button_id or click_type`); console.warn(`Webhook Error: Missing Button-Name header or click_type in body`);
return res.status(400).json({ message: 'Bad Request: Missing button_id or click_type' }); return res.status(400).json({ message: 'Bad Request: Missing Button-Name header or click_type in request body' });
} }
const normalizedButtonId = button_id.toLowerCase(); // Use lowercase for lookup consistency const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency
// Find the subscription associated with this button ID // Find the subscription associated with this normalized button name
const subscription = subscriptions[normalizedButtonId]; const subscription = subscriptions[normalizedButtonName];
if (!subscription) { if (!subscription) {
console.warn(`Webhook: No subscription found for button ID: ${normalizedButtonId} (original: ${button_id})`); console.warn(`Webhook: No subscription found for button ID: ${normalizedButtonName} (original: ${buttonName})`);
return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonId}` }); return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonName}` });
} }
// --- Send Web Push Notification --- // --- Send Web Push Notification ---
const payload = JSON.stringify({ const payload = JSON.stringify({
title: 'Flic Button Action', title: 'Flic Button Action',
body: `Button ${click_type}`, // Simplified body body: `Button ${normalizedButtonName}: ${click_type}`, // Simplified body
data: { data: {
action: click_type, action: click_type,
button: normalizedButtonId, // Send normalized ID button: normalizedButtonName, // Send normalized button name
timestamp: timestamp || new Date().toISOString() timestamp: timestamp || new Date().toISOString()
} }
// icon: '/path/to/icon.png' // icon: '/path/to/icon.png'
}); });
try { try {
console.log(`Sending push notification for ${normalizedButtonId} to endpoint: ${subscription.endpoint.substring(0, 40)}...`); console.log(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`);
await webpush.sendNotification(subscription, payload); await sendWebPushWithRetry(subscription, payload);
console.log(`Push notification sent successfully for button ${normalizedButtonId}.`); console.log(`Push notification sent successfully for button ${normalizedButtonName}.`);
res.status(200).json({ message: 'Push notification sent successfully' }); res.status(200).json({ message: 'Push notification sent successfully' });
} catch (error) { } catch (error) {
console.error(`Error sending push notification for button ${normalizedButtonId}:`, error.body || error.message || error); console.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error);
if (error.statusCode === 404 || error.statusCode === 410) { if (error.statusCode === 404 || error.statusCode === 410) {
console.warn(`Subscription for button ${normalizedButtonId} is invalid or expired (404/410). Removing it.`); console.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`);
// Optionally remove the stale subscription // Optionally remove the stale subscription
delete subscriptions[normalizedButtonId]; delete subscriptions[normalizedButtonName];
saveSubscriptions(); // Attempt to save the updated list saveSubscriptions(); // Attempt to save the updated list
res.status(410).json({ message: 'Subscription Gone' }); res.status(410).json({ message: 'Subscription Gone' });
} else { } else {
@@ -223,7 +283,6 @@ app.get('/health', (req, res) => {
// --- Start Server --- // --- Start Server ---
// Use http.createServer to allow graceful shutdown // Use http.createServer to allow graceful shutdown
const http = require('http');
const server = http.createServer(app); const server = http.createServer(app);
server.listen(port, () => { server.listen(port, () => {
@@ -234,6 +293,9 @@ server.listen(port, () => {
console.log(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); console.log(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`);
console.log(`Subscription Endpoint Auth: Disabled`); console.log(`Subscription Endpoint Auth: Disabled`);
console.log(`Subscriptions File: ${subscriptionsFilePath}`); console.log(`Subscriptions File: ${subscriptionsFilePath}`);
console.log(`Push Notification Retry Config: ${maxRetries} retries, ${initialRetryDelay}ms initial delay`);
console.log(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`);
console.log(`HTTP Timeout: ${httpTimeout}ms`);
}); });
// --- Graceful Shutdown --- // --- Graceful Shutdown ---
@@ -249,7 +311,7 @@ const closeGracefully = (signal) => {
setTimeout(() => { setTimeout(() => {
console.error('Could not close connections in time, forcefully shutting down'); console.error('Could not close connections in time, forcefully shutting down');
process.exit(1); process.exit(1);
}, 10000); // 10 seconds timeout }, 10000); // 10 seconds timeout - This is a literal so no need to parse
} }
process.on('SIGTERM', () => closeGracefully('SIGTERM')); process.on('SIGTERM', () => closeGracefully('SIGTERM'));