feat(pockethost): webhooks

This commit is contained in:
Ben Allfree 2025-07-22 08:15:33 -07:00
parent d601363e6f
commit 0cfed9530f
16 changed files with 1003 additions and 157 deletions

View File

@ -31,6 +31,7 @@
"@types/d3-scale-chromatic": "^3.1.0",
"@types/js-cookie": "^3.0.6",
"autoprefixer": "^10.4.21",
"cron-parser": "^5.3.0",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
"daisyui": "^4.12.24",

View File

@ -88,6 +88,9 @@
<li>
<a href={`/instances/${id}/secrets`} class={activeClass(`secrets`)}>Secrets</a>
</li>
<li>
<a href={`/instances/${id}/webhooks`} class={activeClass(`webhooks`)}>Webhooks</a>
</li>
<li>
<a href={`/instances/${id}/logs`} class={activeClass(`logs`)}>Logs</a>
</li>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { assertExists } from 'pockethost/common'
import { instance } from '../store'
import SecretsInner from './Inner.svelte'
import CardHeader from '$src/components/cards/CardHeader.svelte'
$: ({ status, version, id } = $instance)
assertExists($instance, `Expected instance here`)
const { subdomain } = $instance
</script>
<svelte:head>
<title>{subdomain} webhooks - PocketHost</title>
</svelte:head>
<div class="max-w-2xl">
<CardHeader documentation={`/docs/webhooks/`}>Webhooks</CardHeader>
<SecretsInner />
</div>

View File

@ -0,0 +1,278 @@
<script lang="ts">
import AlertBar from '$components/AlertBar.svelte'
import { client } from '$src/pocketbase-client/index.js'
import { reduce } from '@s-libs/micro-dash'
import { type UpdateInstancePayload } from 'pockethost/common'
import { instance } from '../store.js'
import { items } from './stores.js'
import { faFloppyDisk } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
// Validate webhook endpoint path (must start with /, can include query params, no host/protocol)
const validateWebhookEndpoint = (endpoint: string): boolean => {
if (!endpoint || endpoint.length === 0) return false
// Must start with /
if (!endpoint.startsWith('/')) return false
// Check if it contains protocol or host (should not)
if (endpoint.includes('://') || endpoint.includes('://')) return false
if (endpoint.includes('http://') || endpoint.includes('https://')) return false
// Basic path validation - should be a valid URL path
try {
// Create a fake URL to validate the path part
const testUrl = new URL(`http://example.com${endpoint}`)
const path = testUrl.pathname
// Path should be at least 2 characters (including the leading /)
if (path.length < 2) return false
// Should not contain invalid characters for URL paths
if (path.includes('//') || path.includes('\\')) return false
return true
} catch {
return false
}
}
// Basic cron expression validation
const validateCronExpression = (cronExpression: string): boolean => {
if (!cronExpression || cronExpression.length === 0) return false
const expression = cronExpression.trim()
// Check for predefined macros first
const validMacros = [
'@yearly',
'@annually',
'@monthly',
'@weekly',
'@daily',
'@midnight',
'@hourly',
'@minutely',
'@secondly',
'@weekdays',
'@weekends',
]
if (validMacros.includes(expression)) {
return true
}
// Basic validation: should have 5 parts separated by spaces
const parts = expression.split(/\s+/)
if (parts.length !== 5) return false
// Each part should contain valid cron characters
const validChars = /^[\d*,\-/?LW#]+$/
return parts.every((part) => validChars.test(part))
}
// Keep track of the new key and value to be added
let apiEndpoint: string = ''
let cronValue: string = ''
// These will validate the key and value before being submitted
let isApiEndpointValid = false
let isCronValueValid = false
let isFormValid = false
// This will animate a success message when the key is saved
let successfulSave = false
// Keep track of any error message
let errorMessage: string = ''
// Watch for changes in real time and update the key and value as the user types them
$: {
apiEndpoint = apiEndpoint.trim()
isApiEndpointValid = validateWebhookEndpoint(apiEndpoint)
isCronValueValid = cronValue.length > 0 && validateCronExpression(cronValue)
isFormValid = isApiEndpointValid && isCronValueValid
}
// Submit the form to create the new environment variable
const handleSubmit = async (e: Event) => {
e.preventDefault()
// Reset any messaging
errorMessage = ''
try {
// Block the button from submitting more than once
isFormValid = false
// Save to the database
items.upsert({ endpoint: apiEndpoint, value: cronValue.trim() })
await client().updateInstance({
id: $instance.id,
fields: {
webhooks: $items,
},
})
// Reset the values when the POST is done
apiEndpoint = ''
cronValue = ''
// Enable the submit button
isFormValid = true
// Show the success message
successfulSave = true
// Remove the success toast after a few seconds
setTimeout(() => {
successfulSave = false
}, 5000)
} catch (error: any) {
errorMessage = error.message
}
}
</script>
<h3 class="text-xl">Add New Webhook</h3>
<div class="mb-8">
{#if successfulSave}
<AlertBar message="Your new webhook has been saved." type="success" />
{/if}
<AlertBar message={errorMessage} type="error" />
<form on:submit={handleSubmit} class="mb-4">
<div class="flex flex-row gap-4 mb-4">
<label class="flex-1 form-control">
<input
id="webhook-api-endpoint"
type="text"
bind:value={apiEndpoint}
placeholder="/api/webhooks/my-endpoint"
class={`input input-bordered ${!isApiEndpointValid && apiEndpoint.length > 0 ? 'input-error text-error' : ''}`}
/>
{#if !isApiEndpointValid && apiEndpoint.length > 0}
<div class="label">
<span class="text-error">
API endpoints must be valid paths starting with / (e.g., <code>/api/webhooks/my-webhook</code> or
<code>/api/cron?token=abc</code>). Do not include protocol or host.
</span>
</div>
{/if}
</label>
<div class="flex-1 form-control">
<input
id="webhook-schedule"
type="text"
bind:value={cronValue}
placeholder="Schedule (UTC time)"
class={`input input-bordered ${!isCronValueValid && cronValue.length > 0 ? 'input-error text-error' : ''}`}
/>
{#if !isCronValueValid && cronValue.length > 0}
<div class="label">
<span class="text-error">
Please enter a valid cron expression (e.g., <code>0 9 * * 1-5</code> for weekdays at 9 AM UTC, or
<code>@daily</code> for daily at midnight UTC).
</span>
</div>
{/if}
</div>
<div class="flex-none text-right">
<button type="submit" class="btn btn-primary" disabled={!isFormValid}>
Add <Fa icon={faFloppyDisk} />
</button>
</div>
</div>
<div class="mb-4">
<p>
The schedule is a cron expression that defines when the webhook will be called in <strong>UTC time</strong>. For
example,
<code>0 0 * * *</code> means every day at midnight UTC.
</p>
<table class="table table-sm w-full mt-4">
<thead>
<tr>
<th>Field</th>
<th>Allowed Values</th>
<th>Special Characters</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Minute</code></td>
<td>0-59</td>
<td><code>,</code> <code>-</code> <code>*</code> <code>/</code></td>
</tr>
<tr>
<td><code>Hour</code></td>
<td>0-23</td>
<td><code>,</code> <code>-</code> <code>*</code> <code>/</code></td>
</tr>
<tr>
<td><code>Day of Month</code></td>
<td>1-31</td>
<td
><code>,</code> <code>-</code> <code>*</code> <code>/</code> <code>?</code> <code>L</code>
<code>W</code></td
>
</tr>
<tr>
<td><code>Month</code></td>
<td>1-12 or JAN-DEC</td>
<td><code>,</code> <code>-</code> <code>*</code> <code>/</code></td>
</tr>
<tr>
<td><code>Day of Week</code></td>
<td>0-6 or SUN-SAT</td>
<td
><code>,</code> <code>-</code> <code>*</code> <code>/</code> <code>?</code> <code>L</code>
<code>#</code></td
>
</tr>
</tbody>
</table>
<div class="mt-2">
<strong>Common Macros:</strong>
<ul class="list-disc pl-6">
<li><code>@yearly</code>: <code>0 0 1 1 *</code> (once a year at midnight, Jan 1)</li>
<li><code>@annually</code>: <code>0 0 1 1 *</code> (same as <code>@yearly</code>)</li>
<li><code>@monthly</code>: <code>0 0 1 * *</code> (once a month at midnight, first day)</li>
<li><code>@weekly</code>: <code>0 0 * * 0</code> (once a week at midnight, Sunday)</li>
<li><code>@daily</code>: <code>0 0 * * *</code> (once a day at midnight)</li>
<li><code>@midnight</code>: <code>0 0 * * *</code> (same as <code>@daily</code>)</li>
<li><code>@hourly</code>: <code>0 * * * *</code> (once an hour at minute 0)</li>
</ul>
</div>
<div class="mt-4">
<strong>Practical Examples:</strong>
<ul class="list-disc pl-6">
<li><code>0 9 * * 1-5</code> - Every weekday at 9:00 AM</li>
<li><code>0 12 * * 1</code> - Every Monday at noon</li>
<li><code>0 0 1 * *</code> - First day of every month at midnight</li>
<li><code>0 18 * * 5</code> - Every Friday at 6:00 PM</li>
<li><code>30 2 * * *</code> - Every day at 2:30 AM</li>
<li><code>0 */6 * * *</code> - Every 6 hours (00:00, 06:00, 12:00, 18:00)</li>
<li><code>0 0 * * 0</code> - Every Sunday at midnight</li>
<li><code>0 8 15 * *</code> - 15th of every month at 8:00 AM</li>
<li><code>0 0 1 1 *</code> - New Year's Day at midnight</li>
<li><code>0 12 * * 0,6</code> - Weekends at noon</li>
</ul>
</div>
</div>
</form>
</div>
<style>
:global(code) {
background-color: #565656;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875em;
}
</style>

View File

@ -0,0 +1,53 @@
<script lang="ts">
import CodeSample from '$components/CodeSample.svelte'
import { forEach } from '@s-libs/micro-dash'
import { instance } from '../store'
import Form from './Form.svelte'
import List from './List.svelte'
import { items } from './stores'
import Fa from 'svelte-fa'
import { faUserSecret } from '@fortawesome/free-solid-svg-icons'
$: {
const { id, webhooks } = $instance
items.clear()
forEach(webhooks, (hook) => {
items.upsert(hook)
})
}
$: code =
`// pb_hooks/my-webhook.pb.js\n\n` +
($items.length > 0
? $items
.map(
({ endpoint, value }) => `routerAdd("GET", "${endpoint}", (e) => {
return e.json(200, { "message": "Webhook called" })
}))`
)
.join('\n')
: ``)
</script>
<div class="mb-4">
Webhooks let you call API endpoints on your instance at scheduled times, replacing PocketBase's standard cron
scheduler.
</div>
<!-- If the user has any secrets, render them in a code block -->
{#if $items.length > 0}
<div class="mb-8">
<CodeSample {code} />
</div>
{/if}
{#if $items.length === 0}
<div class="alert border-2 border-primary mb-8">
<Fa icon={faUserSecret} />
<span>No webhooks yet. Create your first webhook to get started.</span>
</div>
{:else}
<List />
{/if}
<Form />

View File

@ -0,0 +1,123 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import { items } from './stores'
import { client } from '$src/pocketbase-client'
import { instance } from '../store'
import { reduce } from '@s-libs/micro-dash'
import { logger, type UpdateInstancePayload } from 'pockethost/common'
import Fa from 'svelte-fa'
import { faTrash, faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons'
// Track which webhooks have expanded details
let expandedWebhooks: Set<string> = new Set()
const toggleExpanded = (endpoint: string) => {
if (expandedWebhooks.has(endpoint)) {
expandedWebhooks.delete(endpoint)
} else {
expandedWebhooks.add(endpoint)
}
expandedWebhooks = expandedWebhooks
}
const getStatusColor = (status: number | undefined) => {
if (!status) return 'text-gray-400'
if (status >= 200 && status < 300) return 'text-success'
if (status >= 400) return 'text-error'
return 'text-warning'
}
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
const handleDelete = (name: string) => async (e: Event) => {
e.preventDefault()
logger().debug(`Deleting ${name}`)
items.delete(name)
await client().updateInstance({
id: $instance.id,
fields: {
webhooks: $items,
},
})
}
</script>
<table class="table mb-8">
<thead>
<tr>
<th class="w-1/3 border-b-2 border-neutral">API Endpoint</th>
<th class="w-1/3 border-b-2 border-neutral">Schedule</th>
<th class="w-1/6 border-b-2 border-neutral">Status</th>
<th class="w-1/6 border-b-2 border-neutral text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each $items as item}
<tr transition:fade>
<th>{item.endpoint}</th>
<td>{item.value}</td>
<td>
{#if item.lastFired}
<button
class="btn btn-ghost btn-sm p-1 {getStatusColor(item.lastFired.response.status)}"
on:click={() => toggleExpanded(item.endpoint)}
title="Click to view response details"
>
<Fa icon={expandedWebhooks.has(item.endpoint) ? faChevronDown : faChevronRight} class="mr-1" />
<span class="font-mono font-bold">{item.lastFired.response.status}</span>
</button>
{:else}
<span class="text-gray-400">No runs yet</span>
{/if}
</td>
<td class="text-right">
<button
aria-label="Delete"
on:click={handleDelete(item.endpoint)}
type="button"
class="btn btn-sm btn-square btn-outline btn-warning"><Fa icon={faTrash} /></button
>
</td>
</tr>
{#if item.lastFired && expandedWebhooks.has(item.endpoint)}
<tr transition:fade>
<td colspan="4" class="bg-base-200 p-4">
<div class="space-y-2">
<div class="flex justify-between items-center">
<h4 class="font-semibold">Last Execution Details</h4>
<span class="text-sm text-gray-500">{formatTimestamp(item.lastFired.timestamp)}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="font-medium text-sm">Status Code:</label>
<div class="mt-1">
<span
class="badge {item.lastFired.response.status >= 200 && item.lastFired.response.status < 300
? 'badge-success'
: 'badge-error'}"
>
{item.lastFired.response.status}
</span>
</div>
</div>
<div class="md:col-span-1">
<label class="font-medium text-sm">Response Body:</label>
<div class="mt-1">
<pre class="text-xs bg-base-300 p-2 rounded max-h-32 overflow-y-auto">{item.lastFired.response
.body || '(empty)'}</pre>
</div>
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>

View File

@ -0,0 +1,63 @@
import { scaleOrdinal } from 'd3-scale'
import { schemeTableau10 } from 'd3-scale-chromatic'
import { type InstanceWebhookCollection, type InstanceWebhookItem } from 'pockethost/common'
import { writable } from 'svelte/store'
// color scale used in both visualizations
const colorScale = scaleOrdinal(schemeTableau10)
// Use the proper types from the schema
export type CronItem = InstanceWebhookItem
export type CronArray = InstanceWebhookCollection
// function to sort the input array and add a color according to the sorted values
function formatInput(input: CronArray): CronArray {
return input
.sort((a, b) => (a.endpoint < b.endpoint ? -1 : 1))
.map(({ endpoint, value, lastFired }) => ({
endpoint,
value,
lastFired,
}))
}
const sanitize = (item: CronItem) => {
return {
endpoint: item.endpoint.trim(),
value: item.value.trim(),
lastFired: item.lastFired,
}
}
// create a custom store fulfilling the CRUD operations
function createItems(initialItems: CronArray) {
const { subscribe, set, update } = writable(initialItems)
const api = {
subscribe,
clear: () => {
set([])
},
// create: add an object for the item at the end of the store's array
upsert: (item: CronItem) => {
const { endpoint, value, lastFired } = sanitize(item)
return update((n) => {
return formatInput([...n.filter((i) => i.endpoint !== endpoint), { endpoint, value, lastFired }])
})
},
// delete: remove the item from the array
delete: (name: string) => {
return update((n) => {
const index = n.findIndex((item) => item.endpoint === name)
n = [...n.slice(0, index), ...n.slice(index + 1)]
return formatInput(n)
})
},
}
return api
}
export const items = createItems(formatInput([]))

View File

@ -32,6 +32,7 @@
<DocLink path="logs" title="Logging" />
<DocLink path="dev-mode" title="Dev Mode" />
<DocLink path="secrets" title="Secrets" />
<DocLink path="webhooks" title="Webhooks" />
<DocLink path="ftp" title="FTP Access" />
<DocLink path="admin-sync" title="Admin Sync" />
<DocLink path="js" title="Extending via JS" />

View File

@ -0,0 +1,262 @@
# Webhooks
Webhooks allow you to schedule API calls to your PocketBase instance at specific times, replacing the need for external cron job schedulers. This feature enables automated tasks like data cleanup, backups, notifications, and integrations with external services.
> **Important**: Webhooks replace PocketBase's built-in [cron job scheduling](https://pocketbase.io/docs/js-jobs-scheduling/) (`cronAdd`) on PocketHost. While `cronAdd` works in standard PocketBase deployments, it becomes unreliable on PocketHost due to instance hibernation. Scheduled webhooks will always execute reliably, even when your instance is hibernated.
## Overview
Webhooks are configured through the PocketHost dashboard and automatically execute HTTP requests to your specified endpoints at scheduled intervals. Each webhook consists of:
- **API Endpoint**: The URL path within your instance to call
- **Schedule**: A cron expression defining when the webhook executes
All webhooks execute in **UTC time**. Make sure to adjust your cron schedules accordingly.
### Why Use Webhooks Instead of `cronAdd`?
On PocketHost, webhooks provide several advantages over PocketBase's built-in `cronAdd`:
- **Reliability**: Webhooks execute even when your instance is hibernated
- **Consistency**: No dependency on your instance's uptime
- **Scalability**: Handled by PocketHost's infrastructure, not your instance
- **Monitoring**: Better visibility into execution status and failures
## Configuration
### API Endpoint
The API endpoint must be a valid path within your PocketBase instance:
- Must start with `/` (e.g., `/api/webhooks/backup`)
- Can include query parameters (e.g., `/api/cron?token=abc123`)
- Cannot include protocol or host (no `http://` or `https://`)
- Supports any valid URL path structure
**Examples:**
- `/api/webhooks/daily-cleanup`
- `/api/backup?type=full&compress=true`
- `/webhook/slack/notifications`
- `/api/maintenance/cleanup-old-records`
### Schedule (Cron Expression)
Webhooks use standard cron expressions to define execution schedules. You can use either:
#### Predefined Macros
| Macro | Description | Equivalent Expression |
| ----------- | ------------------------------------ | --------------------- |
| `@yearly` | Once a year at midnight, January 1st | `0 0 1 1 *` |
| `@annually` | Same as `@yearly` | `0 0 1 1 *` |
| `@monthly` | Once a month at midnight, first day | `0 0 1 * *` |
| `@weekly` | Once a week at midnight on Sunday | `0 0 * * 0` |
| `@daily` | Once a day at midnight | `0 0 * * *` |
| `@midnight` | Same as `@daily` | `0 0 * * *` |
| `@hourly` | Once an hour at the beginning | `0 * * * *` |
| `@minutely` | Once a minute | `* * * * *` |
| `@secondly` | Once a second | `* * * * * *` |
| `@weekdays` | Every weekday at midnight | `0 0 * * 1-5` |
| `@weekends` | Every weekend at midnight | `0 0 * * 0,6` |
#### Standard Cron Expressions
Standard cron expressions use 5 fields: `minute hour day month weekday`
| Field | Values | Special Characters | Description |
| ------------ | ------ | ------------------ | -------------------------- |
| Minute | 0-59 | `* , - / ?` | Minute of the hour |
| Hour | 0-23 | `* , - / ?` | Hour of the day |
| Day of Month | 1-31 | `* , - / ? L W` | Day of the month |
| Month | 1-12 | `* , - / ?` | Month of the year |
| Day of Week | 0-6 | `* , - / ? L #` | Day of the week (0=Sunday) |
**Special Characters:**
- `*` - Any value
- `,` - Value list separator
- `-` - Range of values
- `/` - Step values
- `?` - Any value (alias for `*`)
- `L` - Last day of month/week
- `W` - Weekday (nearest to given day)
- `#` - Nth day of month
### Timing
All webhooks execute in **UTC time**. When scheduling webhooks, convert your local time to UTC:
- **EST (UTC-5)**: 9 AM EST = 2 PM UTC (14:00)
- **PST (UTC-8)**: 6 PM PST = 2 AM UTC next day (02:00)
- **GMT+3**: 3 PM = 12 PM UTC (12:00)
Use online UTC converters to help calculate the correct schedule times.
## Common Examples
### Business Operations (UTC Time)
```cron
# Weekdays at 9 AM UTC
0 9 * * 1-5
# Every Monday at noon UTC
0 12 * * 1
# Every Friday at 6 PM UTC
0 18 * * 5
# First day of every month at midnight UTC
0 0 1 * *
# 15th of every month at 8 AM UTC
0 8 15 * *
```
### Data Management (UTC Time)
```cron
# Daily backup at 2 AM UTC
0 2 * * *
# Cleanup old records every 6 hours
0 */6 * * *
# Weekly data export on Sundays at midnight UTC
0 0 * * 0
# Monthly maintenance on the 1st at midnight UTC
0 0 1 * *
```
### Using Macros
```cron
# Daily operations
@daily
# Weekly reports
@weekly
# Monthly cleanup
@monthly
# Business hours only
@weekdays
```
## Implementation
### Creating Webhook Endpoints
Create API endpoints in your PocketBase instance to handle webhook requests using [PocketBase's routing system](https://pocketbase.io/docs/js-routing/):
```javascript
// pb_hooks/onRequest.pb.js
routerAdd('POST', '/api/webhooks/backup', (e) => {
// Your backup logic here
console.log('Backup webhook triggered')
// Example: Create a backup record
const backup = new Record($app.dao().findCollectionByNameOrId('backups'), {
timestamp: new Date().toISOString(),
status: 'completed',
size: '1.2GB',
})
$app.dao().saveRecord(backup)
return e.json(200, { status: 'success' })
})
```
### Error Handling
Webhooks should return appropriate HTTP status codes:
- `200` - Success
- `400` - Bad request
- `500` - Internal server error
```javascript
routerAdd('POST', '/api/webhooks/cleanup', (e) => {
try {
// Your cleanup logic
return e.json(200, { status: 'success' })
} catch (error) {
console.error('Webhook error:', error)
return e.json(500, { error: 'Internal server error' })
}
})
```
### Authentication
For secure webhooks, include authentication in your endpoints:
```javascript
routerAdd('POST', '/api/webhooks/secure', (e) => {
const token = e.request.url.query().get('token')
if (token !== process.env.WEBHOOK_SECRET) {
return e.json(401, { error: 'Unauthorized' })
}
// Your secure webhook logic
return e.json(200, { status: 'success' })
})
```
## Best Practices
### 1. Idempotency
Make your webhooks idempotent so they can be safely retried:
```javascript
routerAdd('POST', '/api/webhooks/process', (e) => {
const jobId = e.request.url.query().get('jobId')
// Check if already processed
const existing = $app.dao().findFirstRecordByData('jobs', 'jobId', jobId)
if (existing && existing.get('status') === 'completed') {
return e.json(200, { status: 'already_processed' })
}
// Process the job
// ...
})
```
### 2. Logging
Always log webhook executions for debugging:
```javascript
routerAdd('POST', '/api/webhooks/backup', (e) => {
console.log(`Backup webhook triggered at ${new Date().toISOString()}`)
// Your backup logic
console.log('Backup webhook completed successfully')
return e.json(200, { status: 'success' })
})
```
## Troubleshooting
### Common Issues
1. **Webhook not executing**: Check the cron expression syntax and ensure times are in UTC
2. **Endpoint not found**: Ensure the API endpoint exists in your PocketBase instance using [PocketBase routing](https://pocketbase.io/docs/js-routing/)
3. **Authentication errors**: Verify any required tokens or secrets
4. **Timeout errors**: Optimize webhook execution time
5. **Using `cronAdd` instead of webhooks**: Replace `cronAdd` calls with scheduled webhooks for reliable execution on PocketHost
6. **Wrong execution time**: Remember all schedules are in UTC - convert your local time accordingly
## Limitations
- Concurrent webhook executions may be limited
- Webhooks may not run exactly at the time specified, depending on system load and instance state
- Webhooks are triggered by PocketHost's scheduling system, not your instance's internal clock

View File

@ -25,6 +25,13 @@ export type InstanceWebhookCollection = InstanceWebhookItem[]
export type InstanceWebhookItem = {
endpoint: InstanceWebhookEndpoint
value: InstanceWebhookValue
lastFired?: {
timestamp: number
response: {
status: number
body: string
}
}
}
export type InstanceFields<TExtra = {}> = BaseFields & {

View File

@ -251,6 +251,7 @@ const HandleInstanceUpdate = (c) => {
power: null,
version: null,
secrets: null,
webhooks: null,
syncAdmin: null,
dev: null,
cname: null
@ -260,13 +261,14 @@ const HandleInstanceUpdate = (c) => {
log(`After bind`);
data = JSON.parse(JSON.stringify(data));
const id = c.pathParam("id");
const { fields: { subdomain, power, version, secrets, syncAdmin, dev, cname } } = data;
const { fields: { subdomain, power, version, secrets, webhooks, syncAdmin, dev, cname } } = data;
log(`vars`, JSON.stringify({
id,
subdomain,
power,
version,
secrets,
webhooks,
syncAdmin,
dev,
cname
@ -289,6 +291,7 @@ const HandleInstanceUpdate = (c) => {
version,
power,
secrets,
webhooks,
syncAdmin,
dev,
cname

View File

@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "5q2bapw9",
"name": "webhooks",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv")
// remove
collection.schema.removeField("5q2bapw9")
return dao.saveCollection(collection)
})

View File

@ -1,3 +1,4 @@
import { type InstanceWebhookCollection } from '$common'
import { mkLog, StringKvLookup } from '$util/Logger'
import { removeEmptyKeys } from '$util/removeEmptyKeys'
@ -70,6 +71,7 @@ export const HandleInstanceUpdate = (c: echo.Context) => {
power: null,
version: null,
secrets: null,
webhooks: null,
syncAdmin: null,
dev: null,
cname: null,
@ -81,6 +83,7 @@ export const HandleInstanceUpdate = (c: echo.Context) => {
power: boolean | null
version: string | null
secrets: StringKvLookup | null
webhooks: InstanceWebhookCollection | null
syncAdmin: boolean | null
dev: boolean | null
cname: string | null
@ -95,7 +98,7 @@ export const HandleInstanceUpdate = (c: echo.Context) => {
const id = c.pathParam('id')
const {
fields: { subdomain, power, version, secrets, syncAdmin, dev, cname },
fields: { subdomain, power, version, secrets, webhooks, syncAdmin, dev, cname },
} = data
log(
@ -106,6 +109,7 @@ export const HandleInstanceUpdate = (c: echo.Context) => {
power,
version,
secrets,
webhooks,
syncAdmin,
dev,
cname,
@ -143,6 +147,7 @@ export const HandleInstanceUpdate = (c: echo.Context) => {
version,
power,
secrets,
webhooks,
syncAdmin,
dev,
cname,

View File

@ -9,6 +9,7 @@
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"$common": ["../common/index.ts"],
"$util/*": ["src/lib/util/*"]
}
},

View File

@ -0,0 +1,118 @@
import {
InstanceFields,
InstanceId,
LoggerService,
mkInstanceUrl,
mkSingleton,
MothershipAdminClientService,
SingletonBaseConfig,
} from '@'
import Bottleneck from 'bottleneck'
import { CronJob } from 'cron'
import { MothershipMirrorService } from '../MothershipMirrorService'
export type CronServiceConfig = SingletonBaseConfig & {}
export const CronService = mkSingleton(async (config: Partial<CronServiceConfig>) => {
const mirror = await MothershipMirrorService()
const logger = (config.logger ?? LoggerService()).create(`CronService`)
const { dbg, error, info, warn } = logger
info(`Starting`)
const { client } = await MothershipAdminClientService()
const limiter = new Bottleneck({ maxConcurrent: 10 })
const jobs: Map<InstanceId, Set<CronJob>> = new Map()
const removeJobsForInstanceId = (instanceId: InstanceId) => {
if (jobs.has(instanceId)) {
dbg(`Stopping jobs for instance ${instanceId}`)
jobs.get(instanceId)?.forEach((job) => {
job.stop()
})
dbg(`Deleted jobs for instance ${instanceId}`)
jobs.delete(instanceId)
}
}
const upsertInstance = async (instance: InstanceFields) => {
removeJobsForInstanceId(instance.id)
const newJobs = new Set<CronJob>()
const { webhooks } = instance
if (!webhooks) {
dbg(`Instance ${instance.id} has no webhooks`)
return
}
dbg(`Instance has ${instance.webhooks?.length} webhooks`)
dbg(`Instance has ${jobs.get(instance.id)?.size} jobs`)
dbg(`Creating new jobs for instance ${instance.id}`)
webhooks.forEach((webhook) => {
if (!webhook.value) return
dbg(`Creating new job for webhook ${webhook.endpoint} (${webhook.value}) for instance ${instance.id}`)
const job = new CronJob(
webhook.value, // cronTime
() => {
dbg(`Firing webhook ${webhook.endpoint} (${webhook.value}) for instance ${instance.id}`)
limiter.schedule(async () => {
const url = mkInstanceUrl(instance, ...webhook.endpoint.split('/').filter(Boolean))
dbg(`Firing webhook ${url}`)
try {
const response = await fetch(url, {})
const body = await response.text()
// fetch only throws for network errors, not HTTP error status codes
// so 404, 500, etc. will be handled here without throwing
webhook.lastFired = {
timestamp: Date.now(),
response: {
status: response.status,
body: body,
},
}
// Optionally log non-2xx responses
if (!response.ok) {
warn(`Webhook ${url} returned status ${response.status}: ${body}`)
}
} catch (e) {
// This catch block only handles network errors (connection failed, etc.)
webhook.lastFired = {
timestamp: Date.now(),
response: {
status: 500,
body: e instanceof Error ? e.message : String(e),
},
}
error(`Network error firing webhook ${url}: ${e}`)
}
dbg(`Updating instance ${instance.id} with webhooks`, instance.webhooks)
client.updateInstance(instance.id, {
webhooks: instance.webhooks,
})
})
}
)
job.start()
newJobs.add(job)
})
jobs.set(instance.id, newJobs)
dbg(`Created ${newJobs.size} jobs for instance ${instance.id}`)
}
mirror.onInstanceUpserted((instance) => {
dbg(`Instance upserted: ${instance.id}`)
upsertInstance(instance)
})
mirror.onInstanceDeleted((instanceId) => {
dbg(`Instance deleted: ${instanceId}`)
removeJobsForInstanceId(instanceId)
})
dbg(`Upserting instances`)
for (const instance of mirror.getInstances()) {
upsertInstance(instance)
}
return {}
})

189
pnpm-lock.yaml generated
View File

@ -97,6 +97,9 @@ importers:
autoprefixer:
specifier: ^10.4.21
version: 10.4.21(postcss@8.5.6)
cron-parser:
specifier: ^5.3.0
version: 5.3.0
d3-scale:
specifier: ^4.0.2
version: 4.0.2
@ -175,6 +178,9 @@ importers:
cors:
specifier: ^2.8.5
version: 2.8.5
cron:
specifier: ^4.3.2
version: 4.3.2
devcert:
specifier: ^1.2.2
version: 1.2.2
@ -256,12 +262,6 @@ importers:
vhost:
specifier: ^3.0.2
version: 3.0.2
winston:
specifier: ^3.17.0
version: 3.17.0
winston-transport:
specifier: ^4.9.0
version: 4.9.0
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.12
@ -477,17 +477,10 @@ packages:
cpu: [x64]
os: [win32]
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
engines: {node: '>=0.1.90'}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@dabh/diagnostics@2.0.3':
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
'@emnapi/core@1.4.4':
resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==}
@ -1743,6 +1736,9 @@ packages:
'@types/lodash@4.17.14':
resolution: {integrity: sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==}
'@types/luxon@3.6.2':
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@ -1803,9 +1799,6 @@ packages:
'@types/tmp@0.0.33':
resolution: {integrity: sha512-gVC1InwyVrO326wbBZw+AO3u2vRXz/iRWq9jYhpG4W8LXyIgDv3ZmcLQ5Q4Gs+gFMyqx+viFoFT+l3p61QFCmQ==}
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@ -1916,9 +1909,6 @@ packages:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
autoprefixer@10.4.21:
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
engines: {node: ^10 || ^12 || >=14}
@ -2091,32 +2081,20 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@3.2.1:
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
colorspace@1.1.4:
resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
command-exists@1.2.9:
resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==}
@ -2179,6 +2157,14 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
cron-parser@5.3.0:
resolution: {integrity: sha512-IS4mnFu6n3CFgEmXjr+B2zzGHsjJmHEdN+BViKvYSiEn3KWss9ICRDETDX/VZldiW82B94OyAZm4LIT4vcKK0g==}
engines: {node: '>=18'}
cron@4.3.2:
resolution: {integrity: sha512-JxBBnf5zRz+NhW9XcP16gwUKAKIimy2G0QCCQu8kk5XwM4aCGwMt+nntouAfXF9A57965XzB6hitBlJAz5Ts6w==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -2422,9 +2408,6 @@ packages:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
enabled@2.0.0:
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
encodeurl@1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
@ -2585,9 +2568,6 @@ packages:
picomatch:
optional: true
fecha@4.2.3:
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
@ -2631,9 +2611,6 @@ packages:
resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==}
engines: {node: '>=18'}
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
@ -2903,10 +2880,6 @@ packages:
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
engines: {node: '>=0.10.0'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
@ -2984,9 +2957,6 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
kuler@2.0.0:
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@ -3026,10 +2996,6 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
logform@2.7.0:
resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
engines: {node: '>= 12.0.0'}
long@5.2.4:
resolution: {integrity: sha512-qtzLbJE8hq7VabR3mISmVGtoXP8KGc2Z/AT8OuqlYD7JTR3oqrgwdjnk07wpj1twXxYmgDXgoKVWUG/fReSzHg==}
@ -3040,6 +3006,10 @@ packages:
resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==}
engines: {node: 20 || >=22}
luxon@3.7.1:
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
engines: {node: '>=12'}
lzma-native@8.0.6:
resolution: {integrity: sha512-09xfg67mkL2Lz20PrrDeNYZxzeW7ADtpYFbwSQh9U8+76RIzx5QsJBMy8qikv3hbUPfpy6hqwxt6FcGK81g9AA==}
engines: {node: '>=10.0.0'}
@ -3316,9 +3286,6 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
os-tmpdir@1.0.2:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
@ -3748,10 +3715,6 @@ packages:
safe-json-stringify@1.2.0:
resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@ -3868,9 +3831,6 @@ packages:
resolution: {integrity: sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==}
engines: {node: '>=10.16.0'}
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@ -4031,9 +3991,6 @@ packages:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
text-hex@1.0.0:
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@ -4081,10 +4038,6 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
triple-beam@1.4.1:
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
engines: {node: '>= 14.0.0'}
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@ -4319,14 +4272,6 @@ packages:
engines: {node: '>= 8'}
hasBin: true
winston-transport@4.9.0:
resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
engines: {node: '>= 12.0.0'}
winston@3.17.0:
resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==}
engines: {node: '>= 12.0.0'}
with@7.0.2:
resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==}
engines: {node: '>= 10.0.0'}
@ -4635,18 +4580,10 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20250712.0':
optional: true
'@colors/colors@1.6.0': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@dabh/diagnostics@2.0.3':
dependencies:
colorspace: 1.1.4
enabled: 2.0.0
kuler: 2.0.0
'@emnapi/core@1.4.4':
dependencies:
'@emnapi/wasi-threads': 1.0.3
@ -5586,6 +5523,8 @@ snapshots:
'@types/lodash@4.17.14': {}
'@types/luxon@3.6.2': {}
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 2.0.11
@ -5648,8 +5587,6 @@ snapshots:
'@types/tmp@0.0.33': {}
'@types/triple-beam@1.3.5': {}
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@ -5739,8 +5676,6 @@ snapshots:
astral-regex@2.0.0: {}
async@3.2.6: {}
autoprefixer@10.4.21(postcss@8.5.6):
dependencies:
browserslist: 4.25.1
@ -5939,16 +5874,10 @@ snapshots:
clsx@2.1.1: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.3: {}
color-name@1.1.4: {}
color-string@1.9.1:
@ -5956,21 +5885,11 @@ snapshots:
color-name: 1.1.4
simple-swizzle: 0.2.2
color@3.2.1:
dependencies:
color-convert: 1.9.3
color-string: 1.9.1
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
colorspace@1.1.4:
dependencies:
color: 3.2.1
text-hex: 1.0.0
command-exists@1.2.9: {}
commander@12.1.0: {}
@ -6018,6 +5937,15 @@ snapshots:
nan: 2.22.0
optional: true
cron-parser@5.3.0:
dependencies:
luxon: 3.7.1
cron@4.3.2:
dependencies:
'@types/luxon': 3.6.2
luxon: 3.7.1
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -6266,8 +6194,6 @@ snapshots:
empathic@2.0.0: {}
enabled@2.0.0: {}
encodeurl@1.0.2: {}
encodeurl@2.0.0: {}
@ -6494,8 +6420,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fecha@4.2.3: {}
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
@ -6545,8 +6469,6 @@ snapshots:
path-exists: 5.0.0
unicorn-magic: 0.1.0
fn.name@1.1.0: {}
follow-redirects@1.15.9(debug@4.4.0):
optionalDependencies:
debug: 4.4.0
@ -6870,8 +6792,6 @@ snapshots:
is-stream@1.1.0: {}
is-stream@2.0.1: {}
is-stream@4.0.1: {}
is-subdir@1.2.0:
@ -6940,8 +6860,6 @@ snapshots:
kleur@4.1.5: {}
kuler@2.0.0: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@ -6970,21 +6888,14 @@ snapshots:
lodash@4.17.21: {}
logform@2.7.0:
dependencies:
'@colors/colors': 1.6.0
'@types/triple-beam': 1.3.5
fecha: 4.2.3
ms: 2.1.3
safe-stable-stringify: 2.5.0
triple-beam: 1.4.1
long@5.2.4: {}
lru-cache@10.4.3: {}
lru-cache@11.0.2: {}
luxon@3.7.1: {}
lzma-native@8.0.6:
dependencies:
node-addon-api: 3.2.1
@ -7318,10 +7229,6 @@ snapshots:
dependencies:
wrappy: 1.0.2
one-time@1.0.0:
dependencies:
fn.name: 1.1.0
os-tmpdir@1.0.2: {}
outdent@0.5.0: {}
@ -7794,8 +7701,6 @@ snapshots:
safe-json-stringify@1.2.0:
optional: true
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
sass@1.89.2:
@ -7984,8 +7889,6 @@ snapshots:
cpu-features: 0.0.10
nan: 2.22.0
stack-trace@0.0.10: {}
statuses@2.0.1: {}
stoppable@1.1.0: {}
@ -8175,8 +8078,6 @@ snapshots:
term-size@2.2.1: {}
text-hex@1.0.0: {}
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@ -8218,8 +8119,6 @@ snapshots:
totalist@3.0.1: {}
triple-beam@1.4.1: {}
ts-interface-checker@0.1.13: {}
tsdown@0.12.9(typescript@5.7.3):
@ -8418,26 +8317,6 @@ snapshots:
dependencies:
isexe: 2.0.0
winston-transport@4.9.0:
dependencies:
logform: 2.7.0
readable-stream: 3.6.2
triple-beam: 1.4.1
winston@3.17.0:
dependencies:
'@colors/colors': 1.6.0
'@dabh/diagnostics': 2.0.3
async: 3.2.6
is-stream: 2.0.1
logform: 2.7.0
one-time: 1.0.0
readable-stream: 3.6.2
safe-stable-stringify: 2.5.0
stack-trace: 0.0.10
triple-beam: 1.4.1
winston-transport: 4.9.0
with@7.0.2:
dependencies:
'@babel/parser': 7.28.0