diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2b9c57c..81ed1b0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,64 +1,83 @@
-name: Package Release
-
-on:
- push:
- branches:
- - latest-release
- - beta
-
-permissions:
- contents: write
- issues: write
- pull-requests: write
-
-jobs:
- release-builder:
- runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: packages/builder
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0 # Important for semantic release to work correctly
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "22"
- registry-url: "https://registry.npmjs.org"
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v2
- with:
- version: "10.8.1"
- run_install: false
-
- - name: Get pnpm store directory
- id: pnpm-cache
- shell: bash
- run: |
- echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
-
- - uses: actions/cache@v3
- name: Setup pnpm cache
- with:
- path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pnpm-store-
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Build package
- run: pnpm build
-
- - name: Release
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: pnpm semantic-release
-
- # Add more jobs for additional packages as repo grows
+name: Package Release
+
+on:
+ push:
+ branches:
+ - latest-release
+ - beta
+
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+ id-token: write # Required for npm OIDC trusted publishing
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ release-builder:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: packages/builder
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Important for semantic release to work correctly
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ registry-url: "https://registry.npmjs.org"
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: "10.8.1"
+ run_install: false
+
+ - name: Get pnpm store directory
+ id: pnpm-cache
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
+
+ - uses: actions/cache@v4
+ name: Setup pnpm cache
+ with:
+ path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+ working-directory: .
+
+ - name: Build package
+ run: pnpm build
+ working-directory: .
+
+ - name: Semantic Release
+ id: semantic
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: pnpm semantic-release
+
+ - name: Publish to npm with OIDC
+ if: steps.semantic.outcome == 'success'
+ run: |
+ # Check if package.json version was updated (not 0.0.0-development)
+ VERSION=$(node -p "require('./package.json').version")
+ if [ "$VERSION" != "0.0.0-development" ]; then
+ echo "Publishing version $VERSION to npm with OIDC provenance..."
+ npm publish --provenance --access public
+ else
+ echo "No new version to publish"
+ fi
+
diff --git a/.gitignore b/.gitignore
index 6f0a37f..635dc67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
node_modules
dist
coverage
+sites
*.tsbuildinfo
diff --git a/.vscode/pushforge.code-workspace b/.vscode/pushforge.code-workspace
index 6654f34..4f22de5 100644
--- a/.vscode/pushforge.code-workspace
+++ b/.vscode/pushforge.code-workspace
@@ -3,6 +3,9 @@
{
"path": "../packages/builder",
},
+ {
+ "path": "../sites/test",
+ },
{
"path": "..",
},
diff --git a/README.md b/README.md
index 9ab23d7..1e002c6 100644
--- a/README.md
+++ b/README.md
@@ -1,133 +1,139 @@
-# PushForge π
-
-

+

+
+# PushForge
-**Modern, Cross-Platform Web Push Notifications**
+**Web Push Notifications for the Modern Stack**
+[](https://www.npmjs.com/package/@pushforge/builder)
[](https://opensource.org/licenses/MIT)
-[](https://nodejs.org/)
-[](CONTRIBUTING.md)
+[](https://www.typescriptlang.org/)
-
+Zero dependencies Β· Works everywhere Β· TypeScript-first
-## What is PushForge?
+[Documentation](packages/builder) Β· [npm](https://www.npmjs.com/package/@pushforge/builder) Β· [Report Bug](https://github.com/draphy/pushforge/issues)
-PushForge is a comprehensive toolkit for implementing Web Push Notifications in modern web applications. It handles the complex parts of push notifications so you can focus on building great user experiences.
+**[Try the Live Demo β](https://pushforge.draphy.org)**
-**Zero dependencies. Cross-platform. TypeScript-first.**
+
-### Features
+---
-- π Compliant VAPID authentication
-- π¦ Streamlined payload encryption
-- π Works everywhere: Node.js, Browsers, Deno, Bun, Cloudflare Workers
-- π§© Modular architecture for flexible implementation
-- π οΈ Built with TypeScript for robust type safety
+## Live Demo
-## Packages
+See PushForge in action at **[pushforge.draphy.org](https://pushforge.draphy.org)** β a fully working test site powered by PushForge on Cloudflare Workers.
-| Package | Description | Path |
-| -------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------- |
-| [@pushforge/builder](packages/builder) | Core library for building push notification requests with proper VAPID encryption | [`packages/builder/`](packages/builder) |
+- **Enable push notifications** on your device with a single toggle
+- **Send a test notification** to all active devices β anyone visiting the page can send and receive
+- **See it working across browsers** β Chrome, Firefox, Edge, Safari 16+, and more
+- Subscriptions auto-expire after 5 minutes β no permanent data stored
-_More packages coming soon!_
+The entire backend is a single Cloudflare Worker using `buildPushHTTPRequest()` from `@pushforge/builder` with zero additional dependencies.
-## Quick Start
+## The Problem
-```bash
-# Install the core package
-npm install @pushforge/builder
+Traditional web push libraries like `web-push` rely on Node.js-specific APIs that don't work in modern edge runtimes:
-# Generate VAPID keys for push authentication
-npx @pushforge/builder generate-vapid-keys
+```
+β Cloudflare Workers - "crypto.createECDH is not a function"
+β Vercel Edge - "https.request is not available"
+β Convex - "Top-level await is not supported"
```
-Check out the complete documentation in each package's README for detailed usage examples.
+## The Solution
-## Project Structure
+PushForge uses standard Web APIs that work everywhere:
-```
-pushforge/
-βββ packages/
-β βββ builder/ # Core push notification builder
-β βββ lib/ # Source code
-β βββ examples/ # Usage examples (coming soon...)
-β βββ README.md # Package documentation
-βββ README.md # This file
-```
+```typescript
+import { buildPushHTTPRequest } from "@pushforge/builder";
-## Requirements
+const { endpoint, headers, body } = await buildPushHTTPRequest({
+ privateJWK: VAPID_PRIVATE_KEY,
+ subscription: userSubscription,
+ message: {
+ payload: { title: "Hello!", body: "This works everywhere." },
+ adminContact: "mailto:admin@example.com"
+ }
+});
-- **Node.js**: v16.0.0 or higher (for WebCrypto API support)
-- **NPM**, **Yarn**, or **pnpm** for package management
+await fetch(endpoint, { method: "POST", headers, body });
+```
-## Development Setup
+## Why PushForge?
-1. Clone the repository:
+| | PushForge | web-push |
+|---|:---:|:---:|
+| Dependencies | **0** | 5+ |
+| Cloudflare Workers | β
| [β](https://github.com/web-push-libs/web-push/issues/718) |
+| Vercel Edge | β
| β |
+| Convex | β
| β |
+| Deno / Bun | β
| Limited |
+| TypeScript | Native | @types |
- ```bash
- git clone https://github.com/draphy/pushforge.git
- cd pushforge
- ```
+## Quick Start
-2. Install dependencies:
+```bash
+# Install
+npm install @pushforge/builder
- ```bash
- pnpm install
- ```
+# Generate VAPID keys
+npx @pushforge/builder vapid
+```
-3. Build packages:
+**Frontend** - Subscribe users:
- ```bash
- pnpm build
- ```
+```javascript
+const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: VAPID_PUBLIC_KEY
+});
+// Send subscription.toJSON() to your server
+```
-4. Available Commands:
+**Backend** - Send notifications:
- ```bash
- # Format and lint code
- pnpm biome:format # Format code with Biome
- pnpm biome:lint # Lint code with Biome
- pnpm biome:check # Check code with Biome
- pnpm biome:fix # Fix issues automatically with Biome
+```typescript
+import { buildPushHTTPRequest } from "@pushforge/builder";
- # Type checking
- pnpm type:check # Run TypeScript type checking
+const { endpoint, headers, body } = await buildPushHTTPRequest({
+ privateJWK: process.env.VAPID_PRIVATE_KEY,
+ subscription,
+ message: {
+ payload: { title: "New Message", body: "You have a notification!" },
+ adminContact: "mailto:admin@example.com"
+ }
+});
- # Commit checks (run before committing)
- pnpm commit:check # Run formatting, type checking and build
- ```
+await fetch(endpoint, { method: "POST", headers, body });
+```
-## Contributing
+See the [full documentation](packages/builder) for platform-specific examples (Cloudflare Workers, Vercel Edge, Convex, Deno, Bun).
-Contributions are always welcome! We follow a structured workflow for contributions - see our [Contributing Guidelines](CONTRIBUTING.md) for details.
+## Packages
-Whether you want to:
+| Package | Description |
+|---------|-------------|
+| [@pushforge/builder](packages/builder) | Core library for building push notification requests |
-- π Report a bug
-- π‘ Suggest new features
-- π§ͺ Improve tests
-- π Enhance documentation
-- π» Submit a PR
+## Requirements
-We appreciate your help making PushForge better for everyone.
+- **Node.js 20+** or any runtime with Web Crypto API
+- Supported: Cloudflare Workers, Vercel Edge, Convex, Deno, Bun, modern browsers
-## Reporting Issues
+## Development
-Found a bug or have a feature request? Please [open an issue](https://github.com/draphy/pushforge/issues/new) and provide as much detail as possible.
+```bash
+git clone https://github.com/draphy/pushforge.git
+cd pushforge
+pnpm install
+pnpm build
+```
-## Sponsorship
+## Contributing
-If you find PushForge valuable, consider [sponsoring the project](https://github.com/sponsors/draphy). Your sponsorship helps maintain and improve the library.
+Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
-
----
-
-
+MIT Β© [David Raphi](https://github.com/draphy)
diff --git a/biome.json b/biome.json
index 18e515d..cbd0fbf 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
+ "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": {
"enabled": true
},
@@ -15,7 +15,15 @@
"useImportType": "error"
}
},
- "ignore": ["dist", "node_modules", "build", "tmp", "dev-dist", ".vscode"]
+ "ignore": [
+ "dist",
+ "node_modules",
+ "build",
+ "tmp",
+ "dev-dist",
+ ".vscode",
+ "sites"
+ ]
},
"formatter": {
"enabled": true,
@@ -29,7 +37,8 @@
"build",
"tmp",
"dev-dist",
- ".vscode"
+ ".vscode",
+ "sites"
]
},
"javascript": {
diff --git a/images/logo.webp b/images/logo.webp
new file mode 100644
index 0000000..7744ead
Binary files /dev/null and b/images/logo.webp differ
diff --git a/images/pushforge_logo.png b/images/pushforge_logo.png
deleted file mode 100644
index 9a40ac9..0000000
Binary files a/images/pushforge_logo.png and /dev/null differ
diff --git a/package.json b/package.json
index 272e842..3a166ad 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"email": "david@draphy.org"
},
"engines": {
- "node": ">=16.0.0"
+ "node": ">=20.0.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
diff --git a/packages/builder/README.md b/packages/builder/README.md
index c2fcf21..e5f554c 100644
--- a/packages/builder/README.md
+++ b/packages/builder/README.md
@@ -1,198 +1,360 @@
-# PushForge Builder
+
-A robust, cross-platform Web Push notification library that handles VAPID authentication and payload encryption following the Web Push Protocol standard.
+

-## Installation
+# PushForge Builder
-Choose your preferred package manager:
+**A lightweight, dependency-free Web Push library built on the standard Web Crypto API.**
-```bash
-# NPM
-npm install @pushforge/builder
+[](https://www.npmjs.com/package/@pushforge/builder)
+[](https://opensource.org/licenses/MIT)
+[](https://www.typescriptlang.org/)
-# Yarn
-yarn add @pushforge/builder
+Send push notifications from any JavaScript runtime Β· Zero dependencies
-# pnpm
-pnpm add @pushforge/builder
-```
+[GitHub](https://github.com/draphy/pushforge) Β· [npm](https://www.npmjs.com/package/@pushforge/builder) Β· [Report Bug](https://github.com/draphy/pushforge/issues)
-## Features
+**[Try the Live Demo β](https://pushforge.draphy.org)**
-- π Compliant VAPID authentication
-- π Web Push Protocol encryption
-- π Cross-platform compatibility (Node.js 16+, Browsers, Deno, Bun, Cloudflare Workers)
-- π§© TypeScript definitions included
-- π οΈ Zero dependencies
+
-## Getting Started
+---
-### Step 1: Generate VAPID Keys
+```bash
+npm install @pushforge/builder
+```
-PushForge includes a built-in CLI tool to generate VAPID keys for Web Push Authentication:
+## Live Demo
-```bash
-# Generate VAPID keys using npx
-npx @pushforge/builder generate-vapid-keys
+Try PushForge in your browser at **[pushforge.draphy.org](https://pushforge.draphy.org)** β a live test site running on Cloudflare Workers.
-# Using yarn
-yarn dlx @pushforge/builder generate-vapid-keys
+- Toggle push notifications on, send a test message, and see it arrive in real time
+- Works across all supported browsers β Chrome, Firefox, Edge, Safari 16+
+- The backend is a single Cloudflare Worker using `buildPushHTTPRequest()` with zero additional dependencies
+- Subscriptions auto-expire after 5 minutes β no permanent data stored
-# Using pnpm
-pnpm dlx @pushforge/builder generate-vapid-keys
-```
+## Why PushForge?
-This will output a public key and private key that you can use for VAPID authentication:
+| | PushForge | web-push |
+|---|:---:|:---:|
+| Dependencies | **0** | 5+ (with nested deps) |
+| Cloudflare Workers | Yes | [No](https://github.com/web-push-libs/web-push/issues/718) |
+| Vercel Edge | Yes | No |
+| Convex | Yes | No |
+| Deno / Bun | Yes | Limited |
+| TypeScript | First-class | @types package |
-```
-ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β β
-β VAPID Keys Generated Successfully β
-β β
-β Public Key: β
-β BDd0DtL3qQmnI7-JPwKMuGuFBC7VW9GjKP0qR-4C9Y9lJ2LLWR0pSI... β
-β β
-β Private Key (JWK): β
-β { β
-β "alg": "ES256", β
-β "kty": "EC", β
-β "crv": "P-256", β
-β "x": "N3QO0vepCacjv4k_AoyYa4UELtVb0aMo_SpH7gL1j2U", β
-β "y": "ZSdiy1kdKUiOGjuoVgMbp4HwmQDz0nhHxPJLbFYh1j8", β
-β "d": "8M9F5JCaEsXdTU1OpD4ODq-o5qZQcDmCYS6EHrC1o8E" β
-β } β
-β β
-β Store these keys securely. Never expose your private key. β
-β β
-ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-```
+Traditional web push libraries rely on Node.js-specific APIs (`crypto.createECDH`, `https.request`) that don't work in modern edge runtimes. PushForge uses the standard [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), making it portable across all JavaScript environments.
-**Requirements:**
+## Quick Start
-- Node.js 16.0.0 or later
-- The command uses the WebCrypto API which is built-in to Node.js 16+
+### 1. Generate VAPID Keys
-### Step 2: Set Up Push Notifications in Your Web Application
+```bash
+npx @pushforge/builder vapid
+```
-To implement push notifications in your web application, you'll need to:
+This outputs a public key (for your frontend) and a private key in JWK format (for your server).
-1. Use the VAPID public key generated in Step 1
-2. Request notification permission from the user
-3. Subscribe to push notifications using the Push API
-4. Save the subscription information in your backend
+### 2. Subscribe Users (Frontend)
-When implementing your service worker, handle push events to display notifications when they arrive:
+Use the VAPID public key to subscribe users to push notifications:
-1. Listen for `push` events
-2. Parse the notification data
-3. Display the notification to the user
-4. Handle notification click events
+```javascript
+// In your frontend application
+const registration = await navigator.serviceWorker.ready;
-Refer to the [Push API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) and [Notifications API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) for detailed implementation.
+const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: 'YOUR_VAPID_PUBLIC_KEY' // From step 1
+});
-### Step 3: Send Push Notifications from Your Server
+// Send this subscription to your server
+// subscription.toJSON() returns:
+// {
+// endpoint: "https://fcm.googleapis.com/fcm/send/...",
+// keys: {
+// p256dh: "BNcRd...",
+// auth: "tBHI..."
+// }
+// }
+await fetch('/api/subscribe', {
+ method: 'POST',
+ body: JSON.stringify(subscription)
+});
+```
-On your backend server, use the VAPID private key to send push notifications:
+### 3. Send Notifications (Server)
```typescript
import { buildPushHTTPRequest } from "@pushforge/builder";
-// Load the private key from your secure environment
-// This should be the private key from your VAPID key generation
-const privateJWK = process.env.VAPID_PRIVATE_KEY;
+// Your VAPID private key (JWK format from step 1)
+const privateJWK = {
+ kty: "EC",
+ crv: "P-256",
+ x: "...",
+ y: "...",
+ d: "..."
+};
-// User subscription from browser push API
+// The subscription object from the user's browser
const subscription = {
- endpoint: "https://fcm.googleapis.com/fcm/send/DEVICE_TOKEN",
+ endpoint: "https://fcm.googleapis.com/fcm/send/...",
keys: {
- p256dh: "USER_PUBLIC_KEY",
- auth: "USER_AUTH_SECRET",
- },
-};
-
-// Create message with payload
-const message = {
- payload: {
- title: "New Message",
- body: "You have a new message!",
- icon: "/images/icon.png",
- },
- options: {
- //Default value is 24 * 60 * 60 (24 hours).
- //The VAPID JWT expiration claim (`exp`) must not exceed 24 hours from the time of the request.
- ttl: 3600, // Time-to-live in seconds
- urgency: "normal", // Options: "very-low", "low", "normal", "high"
- topic: "updates", // Optional topic for replacing notifications
- },
- adminContact: "mailto:admin@example.com", //The contact information of the administrator
+ p256dh: "BNcRd...",
+ auth: "tBHI..."
+ }
};
-// Build the push notification request
-const {endpoint, headers, body} = await buildPushHTTPRequest({
+// Build and send the notification
+const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK,
- message,
subscription,
+ message: {
+ payload: {
+ title: "New Message",
+ body: "You have a new notification!",
+ icon: "/icon.png"
+ },
+ adminContact: "mailto:admin@example.com"
+ }
});
-// Send the push notification
const response = await fetch(endpoint, {
method: "POST",
headers,
- body,
+ body
});
if (response.status === 201) {
- console.log("Push notification sent successfully");
-} else {
- console.error("Failed to send push notification", await response.text());
+ console.log("Notification sent");
}
```
-## Cross-Platform Support
+## Understanding Push Subscriptions
-PushForge works in all major JavaScript environments:
+When a user subscribes to push notifications, the browser returns a `PushSubscription` object:
-### Node.js 16+
+```javascript
+{
+ // The unique URL for this user's browser push service
+ endpoint: "https://fcm.googleapis.com/fcm/send/dAPT...",
-```js
-import { buildPushHTTPRequest } from "@pushforge/builder";
-// OR
-const { buildPushHTTPRequest } = require("@pushforge/builder");
+ keys: {
+ // Public key for encrypting messages (base64url)
+ p256dh: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA...",
+
+ // Authentication secret (base64url)
+ auth: "tBHItJI5svbpez7KI4CCXg=="
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `endpoint` | The push service URL. Each browser vendor has their own (Google FCM, Mozilla autopush, Apple APNs). |
+| `p256dh` | The user's public key for ECDH P-256 message encryption. |
+| `auth` | A shared 16-byte authentication secret. |
+
+Store these securely on your server. You'll need them to send notifications to this user.
+
+## API Reference
+
+### `buildPushHTTPRequest(options)`
+
+Builds an HTTP request for sending a push notification.
-// Use normally - Node.js 16+ has Web Crypto API built-in
+```typescript
+const { endpoint, headers, body } = await buildPushHTTPRequest({
+ privateJWK, // Your VAPID private key (JWK object or JSON string)
+ subscription, // User's push subscription
+ message: {
+ payload, // Any JSON-serializable data
+ adminContact, // Contact email (mailto:...) or URL
+ options: { // Optional
+ ttl, // Time-to-live in seconds (default: 86400)
+ urgency, // "very-low" | "low" | "normal" | "high"
+ topic // Topic for notification coalescing
+ }
+ }
+});
+```
+
+**Returns:** `{ endpoint: string, headers: Headers, body: ArrayBuffer }`
+
+## Platform Examples
+
+### Cloudflare Workers
+
+```javascript
+export default {
+ async fetch(request, env) {
+ const subscription = await request.json();
+
+ const { endpoint, headers, body } = await buildPushHTTPRequest({
+ privateJWK: JSON.parse(env.VAPID_PRIVATE_KEY),
+ subscription,
+ message: {
+ payload: { title: "Hello from the Edge!" },
+ adminContact: "mailto:admin@example.com"
+ }
+ });
+
+ return fetch(endpoint, { method: "POST", headers, body });
+ }
+};
```
-### Browsers
+### Vercel Edge Functions
-```js
+```typescript
import { buildPushHTTPRequest } from "@pushforge/builder";
-// Use in a service worker for push notification handling
+export const config = { runtime: "edge" };
+
+export default async function handler(request: Request) {
+ const subscription = await request.json();
+
+ const { endpoint, headers, body } = await buildPushHTTPRequest({
+ privateJWK: JSON.parse(process.env.VAPID_PRIVATE_KEY!),
+ subscription,
+ message: {
+ payload: { title: "Edge Notification" },
+ adminContact: "mailto:admin@example.com"
+ }
+ });
+
+ await fetch(endpoint, { method: "POST", headers, body });
+ return new Response("Sent", { status: 200 });
+}
+```
+
+### Convex
+
+```typescript
+import { action } from "./_generated/server";
+import { buildPushHTTPRequest } from "@pushforge/builder";
+import { v } from "convex/values";
+
+export const sendPush = action({
+ args: { subscription: v.any(), title: v.string(), body: v.string() },
+ handler: async (ctx, { subscription, title, body }) => {
+ const { endpoint, headers, body: reqBody } = await buildPushHTTPRequest({
+ privateJWK: JSON.parse(process.env.VAPID_PRIVATE_KEY!),
+ subscription,
+ message: {
+ payload: { title, body },
+ adminContact: "mailto:admin@example.com"
+ }
+ });
+
+ await fetch(endpoint, { method: "POST", headers, body: reqBody });
+ }
+});
```
### Deno
-```js
-// Import from npm CDN
-import { buildPushHTTPRequest } from "https://cdn.jsdelivr.net/npm/@pushforge/builder/dist/lib/main.js";
+```typescript
+import { buildPushHTTPRequest } from "npm:@pushforge/builder";
+
+const { endpoint, headers, body } = await buildPushHTTPRequest({
+ privateJWK: JSON.parse(Deno.env.get("VAPID_PRIVATE_KEY")!),
+ subscription,
+ message: {
+ payload: { title: "Hello from Deno!" },
+ adminContact: "mailto:admin@example.com"
+ }
+});
-// Run with --allow-net permissions
+await fetch(endpoint, { method: "POST", headers, body });
```
### Bun
-```js
+```typescript
import { buildPushHTTPRequest } from "@pushforge/builder";
-// Works natively in Bun with no special configuration
+const { endpoint, headers, body } = await buildPushHTTPRequest({
+ privateJWK: JSON.parse(Bun.env.VAPID_PRIVATE_KEY!),
+ subscription,
+ message: {
+ payload: { title: "Hello from Bun!" },
+ adminContact: "mailto:admin@example.com"
+ }
+});
+
+await fetch(endpoint, { method: "POST", headers, body });
```
-### Cloudflare Workers
+## Service Worker Setup
+
+Handle incoming push notifications in your service worker:
+
+```javascript
+// sw.js
+self.addEventListener('push', (event) => {
+ const data = event.data?.json() ?? {};
+
+ event.waitUntil(
+ self.registration.showNotification(data.title, {
+ body: data.body,
+ icon: data.icon,
+ badge: data.badge,
+ data: data.url
+ })
+ );
+});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+
+ if (event.notification.data) {
+ event.waitUntil(clients.openWindow(event.notification.data));
+ }
+});
+```
+
+## Requirements
+
+**Node.js 20+** or any runtime with Web Crypto API support.
+
+| Environment | Status |
+|-------------|--------|
+| Node.js 20+ | Fully supported |
+| Cloudflare Workers | Fully supported |
+| Vercel Edge | Fully supported |
+| Deno | Fully supported |
+| Bun | Fully supported |
+| Convex | Fully supported |
+| Modern Browsers | Fully supported |
+
+
+Node.js 18 (requires polyfill)
+
+```javascript
+import { webcrypto } from "node:crypto";
+globalThis.crypto = webcrypto;
-```js
import { buildPushHTTPRequest } from "@pushforge/builder";
```
+Or: `node --experimental-global-webcrypto your-script.js`
+
+
+
+## Security
+
+PushForge validates all inputs before processing:
+
+- VAPID key structure (EC P-256 curve with required x, y, d parameters)
+- Subscription endpoint (must be valid HTTPS URL)
+- p256dh key format (65-byte uncompressed P-256 point)
+- Auth secret length (exactly 16 bytes)
+- Payload size (max 4KB per Web Push spec)
+- TTL bounds (max 24 hours per VAPID spec)
+
## License
MIT
diff --git a/packages/builder/lib/commandLine/keys.ts b/packages/builder/lib/commandLine/keys.ts
index a7f216e..61489c2 100644
--- a/packages/builder/lib/commandLine/keys.ts
+++ b/packages/builder/lib/commandLine/keys.ts
@@ -1,47 +1,35 @@
#!/usr/bin/env node
import { getPublicKeyFromJwk } from '../utils.js';
-let webcrypto: Crypto;
-try {
- const nodeCrypto = await import('node:crypto');
- webcrypto = nodeCrypto.webcrypto as Crypto;
-} catch {
- console.error('Error: This command requires Node.js environment.');
- console.error("Please ensure you're running Node.js 16.0.0 or later.");
+if (!globalThis.crypto?.subtle) {
+ console.error('Error: Web Crypto API not available.');
+ console.error('Please ensure you are running Node.js 20.0.0 or later.');
process.exit(1);
}
async function generateVapidKeys(): Promise {
try {
- console.log('Generating VAPID keys...');
+ console.log('Generating VAPID keys...\n');
- const keypair = await webcrypto.subtle.generateKey(
+ const keypair = await globalThis.crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
- const privateJWK = await webcrypto.subtle.exportKey(
+ const privateJWK = await globalThis.crypto.subtle.exportKey(
'jwk',
keypair.privateKey,
);
const privateJWKWithAlg = { alg: 'ES256', ...privateJWK };
const publicKey = getPublicKeyFromJwk(privateJWKWithAlg);
- // Display in a nice formatted output
- const resultText = `
-VAPID Keys Generated Successfully
-
-Public Key:
-${publicKey}
-
-Private Key (JWK):
-${JSON.stringify(privateJWKWithAlg, null, 2)}
-
-Store these keys securely. Never expose your private key.
-`;
-
- console.log(resultText);
+ console.log('VAPID Keys Generated Successfully\n');
+ console.log('Public Key:');
+ console.log(publicKey);
+ console.log('\nPrivate Key (JWK):');
+ console.log(JSON.stringify(privateJWKWithAlg, null, 2));
+ console.log('\nStore these keys securely. Never expose your private key.');
} catch (error: unknown) {
console.error('Error generating VAPID keys:');
if (error instanceof Error) {
@@ -50,25 +38,43 @@ Store these keys securely. Never expose your private key.
console.error('An unknown error occurred.');
}
console.error(
- '\nThis tool requires Node.js v16.0.0 or later with WebCrypto API support.',
+ '\nThis tool requires Node.js 20.0.0 or later with Web Crypto API support.',
);
process.exit(1);
}
}
-// Simple command parsing
-const args = process.argv.slice(2);
-const command = args[0];
-
-if (command === 'generate-vapid-keys') {
- generateVapidKeys();
-} else {
+function showHelp(): void {
console.log(`
-PushForge CLI Tools
+PushForge CLI
-Usage:
- npx @pushforge/builder generate-vapid-keys Generate VAPID key pair for Web Push Authentication
+Usage: npx @pushforge/builder
+
+Commands:
+ vapid Generate VAPID key pair for Web Push authentication
+ help Show this help message
+
+Examples:
+ npx @pushforge/builder vapid
+
+Documentation: https://github.com/draphy/pushforge#readme
+`);
+}
+
+// Parse command
+const args = process.argv.slice(2);
+const command = args[0]?.toLowerCase();
-For more information, visit: https://github.com/draphy/pushforge
- `);
+switch (command) {
+ case 'vapid':
+ generateVapidKeys();
+ break;
+ case 'help':
+ case '--help':
+ case '-h':
+ showHelp();
+ break;
+ default:
+ showHelp();
+ process.exit(command ? 1 : 0);
}
diff --git a/packages/builder/lib/crypto.ts b/packages/builder/lib/crypto.ts
index 6d754c4..55a0caa 100644
--- a/packages/builder/lib/crypto.ts
+++ b/packages/builder/lib/crypto.ts
@@ -3,34 +3,25 @@
/**
* A module that provides a cross-platform cryptographic interface.
+ * Uses globalThis.crypto which is available in:
+ * - Node.js 20+ (current LTS)
+ * - Browsers
+ * - Cloudflare Workers
+ * - Deno
+ * - Bun
+ * - Convex
*
* @module crypto
*/
-let isomorphicCrypto: Crypto;
-
-// Cloudflare Worker, Deno, Bun and Browser environments have crypto globally available
-if (globalThis.crypto?.subtle) {
- isomorphicCrypto = globalThis.crypto;
-}
-// Node.js requires importing the webcrypto module
-else if (typeof process !== 'undefined' && process.versions?.node) {
- try {
- const { webcrypto } = await import('node:crypto');
- isomorphicCrypto = webcrypto as unknown as Crypto;
- } catch {
- throw new Error(
- 'Crypto API not available in this Node.js environment. Please use Node.js 16+ which supports the Web Crypto API.',
- );
- }
-}
-// Fallback error for unsupported environments
-else {
+if (!globalThis.crypto?.subtle) {
throw new Error(
- 'No Web Crypto API implementation available in this environment.',
+ 'Web Crypto API not available. Ensure you are using Node.js 20+ or a modern runtime with globalThis.crypto support.',
);
}
+const isomorphicCrypto: Crypto = globalThis.crypto;
+
/**
* A cryptographic interface that provides methods for generating random values
* and accessing subtle cryptographic operations.
diff --git a/packages/builder/lib/payload.ts b/packages/builder/lib/payload.ts
index cd4c201..453ca9d 100644
--- a/packages/builder/lib/payload.ts
+++ b/packages/builder/lib/payload.ts
@@ -40,6 +40,20 @@ const importClientKeys = async (
decodedKey = new Uint8Array(Buffer.from(base64Key, 'base64'));
}
+ // Validate p256dh key format: must be 65 bytes (uncompressed P-256 point)
+ // Format: 0x04 (1 byte) + x coordinate (32 bytes) + y coordinate (32 bytes)
+ if (decodedKey.byteLength !== 65) {
+ throw new Error(
+ `Invalid p256dh key: expected 65 bytes but got ${decodedKey.byteLength} bytes`,
+ );
+ }
+
+ if (decodedKey[0] !== 0x04) {
+ throw new Error(
+ `Invalid p256dh key: expected uncompressed point format (0x04 prefix) but got 0x${decodedKey[0].toString(16).padStart(2, '0')}`,
+ );
+ }
+
const p256 = await crypto.subtle.importKey(
'jwk',
{
@@ -95,7 +109,7 @@ const derivePseudoRandomKey = async (
const createContext = async (
clientPublicKey: CryptoKey,
localPublicKey: CryptoKey,
-): Promise => {
+): Promise> => {
const [clientKeyBytes, localKeyBytes] = await Promise.all([
crypto.subtle.exportKey('raw', clientPublicKey),
crypto.subtle.exportKey('raw', localPublicKey),
@@ -120,8 +134,8 @@ const createContext = async (
*/
const deriveNonce = async (
pseudoRandomKey: CryptoKey,
- salt: Uint8Array,
- context: Uint8Array,
+ salt: Uint8Array,
+ context: Uint8Array,
): Promise => {
const nonceInfo = concatTypedArrays([
new TextEncoder().encode('Content-Encoding: nonce\0'),
@@ -145,8 +159,8 @@ const deriveNonce = async (
*/
const deriveContentEncryptionKey = async (
pseudoRandomKey: CryptoKey,
- salt: Uint8Array,
- context: Uint8Array,
+ salt: Uint8Array,
+ context: Uint8Array,
): Promise => {
const info = concatTypedArrays([
new TextEncoder().encode('Content-Encoding: aesgcm\0'),
@@ -162,6 +176,19 @@ const deriveContentEncryptionKey = async (
return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt']);
};
+/**
+ * Maximum payload size after accounting for encryption overhead.
+ * Web push payloads have an overall max size of 4KB (4096 bytes).
+ * With the required overhead (16 bytes auth tag + 2 bytes padding length),
+ * the actual max payload size is 4078 bytes.
+ */
+const MAX_PAYLOAD_SIZE = 4078;
+
+/**
+ * Minimum required size for padding length prefix (2 bytes).
+ */
+const PADDING_LENGTH_PREFIX_SIZE = 2;
+
/**
* Pads the payload to ensure it fits within the maximum allowed size for web push notifications.
*
@@ -169,20 +196,32 @@ const deriveContentEncryptionKey = async (
* required overhead for encryption, the actual max payload size is 4078 bytes.
*
* @param {Uint8Array} payload - The original payload to be padded.
- * @returns {Uint8Array} The padded payload, including length information.
+ * @returns {Uint8Array} The padded payload, including length information.
+ * @throws {Error} Throws an error if the payload exceeds the maximum allowed size.
*/
-const padPayload = (payload: Uint8Array): Uint8Array => {
- const MAX_PAYLOAD_SIZE = 4078; // Maximum payload size after encryption overhead
-
- let paddingSize = Math.round(Math.random() * 100); // Random padding size
- const payloadSizeWithPadding = payload.byteLength + 2 + paddingSize;
+const padPayload = (payload: Uint8Array): Uint8Array => {
+ const maxPayloadContentSize = MAX_PAYLOAD_SIZE - PADDING_LENGTH_PREFIX_SIZE;
- if (payloadSizeWithPadding > MAX_PAYLOAD_SIZE) {
- // Adjust padding size if the total exceeds the maximum allowed size
- paddingSize -= payloadSizeWithPadding - MAX_PAYLOAD_SIZE;
+ if (payload.byteLength > maxPayloadContentSize) {
+ throw new Error(
+ `Payload too large. Maximum size is ${maxPayloadContentSize} bytes, but received ${payload.byteLength} bytes`,
+ );
}
- const paddingArray = new ArrayBuffer(2 + paddingSize);
+ // Calculate available space for padding
+ const availableSpace =
+ MAX_PAYLOAD_SIZE - PADDING_LENGTH_PREFIX_SIZE - payload.byteLength;
+
+ // Generate random padding size, clamped to available space
+ const maxRandomPadding = Math.min(100, availableSpace);
+ const paddingSize =
+ maxRandomPadding > 0
+ ? Math.floor(Math.random() * (maxRandomPadding + 1))
+ : 0;
+
+ const paddingArray = new ArrayBuffer(
+ PADDING_LENGTH_PREFIX_SIZE + paddingSize,
+ );
new DataView(paddingArray).setUint16(0, paddingSize); // Store the length of the padding
// Return the new payload with padding added
@@ -200,7 +239,7 @@ const padPayload = (payload: Uint8Array): Uint8Array => {
*/
export const encryptPayload = async (
localKeys: CryptoKeyPair,
- salt: Uint8Array,
+ salt: Uint8Array,
payload: string,
target: PushSubscription,
): Promise => {
diff --git a/packages/builder/lib/request.ts b/packages/builder/lib/request.ts
index 3f0bfd2..b415de7 100644
--- a/packages/builder/lib/request.ts
+++ b/packages/builder/lib/request.ts
@@ -3,6 +3,61 @@ import { encryptPayload } from './payload.js';
import type { BuilderOptions, PushOptions } from './types.js';
import { vapidHeaders } from './vapid.js';
+/**
+ * Validates that a JWK has the required properties for ECDSA P-256.
+ *
+ * @param {JsonWebKey} jwk - The JSON Web Key to validate.
+ * @throws {Error} Throws if the JWK is missing required properties or has invalid values.
+ */
+const validatePrivateJWK = (jwk: JsonWebKey): void => {
+ if (jwk.kty !== 'EC') {
+ throw new Error(
+ `Invalid JWK: 'kty' must be 'EC', received '${jwk.kty ?? 'undefined'}'`,
+ );
+ }
+
+ if (jwk.crv !== 'P-256') {
+ throw new Error(
+ `Invalid JWK: 'crv' must be 'P-256', received '${jwk.crv ?? 'undefined'}'`,
+ );
+ }
+
+ if (!jwk.x || typeof jwk.x !== 'string') {
+ throw new Error("Invalid JWK: missing or invalid 'x' coordinate");
+ }
+
+ if (!jwk.y || typeof jwk.y !== 'string') {
+ throw new Error("Invalid JWK: missing or invalid 'y' coordinate");
+ }
+
+ if (!jwk.d || typeof jwk.d !== 'string') {
+ throw new Error("Invalid JWK: missing or invalid 'd' (private key)");
+ }
+};
+
+/**
+ * Validates that the subscription endpoint is a valid HTTPS URL.
+ *
+ * @param {string} endpoint - The push subscription endpoint URL.
+ * @throws {Error} Throws if the endpoint is not a valid HTTPS URL.
+ */
+const validateEndpoint = (endpoint: string): void => {
+ let url: URL;
+ try {
+ url = new URL(endpoint);
+ } catch {
+ throw new Error(
+ `Invalid subscription endpoint: '${endpoint}' is not a valid URL`,
+ );
+ }
+
+ if (url.protocol !== 'https:') {
+ throw new Error(
+ `Invalid subscription endpoint: push endpoints must use HTTPS, received '${url.protocol}'`,
+ );
+ }
+};
+
/**
* Builds an HTTP request body and headers for sending a push notification.
*
@@ -67,8 +122,18 @@ export async function buildPushHTTPRequest({
headers: Record | Headers;
}> {
// Parse the private JWK if it's a string
- const jwk: JsonWebKey =
- typeof privateJWK === 'string' ? JSON.parse(privateJWK) : privateJWK;
+ let jwk: JsonWebKey;
+ try {
+ jwk = typeof privateJWK === 'string' ? JSON.parse(privateJWK) : privateJWK;
+ } catch {
+ throw new Error('Invalid privateJWK: failed to parse JSON string');
+ }
+
+ // Validate the JWK structure
+ validatePrivateJWK(jwk);
+
+ // Validate the subscription endpoint
+ validateEndpoint(subscription.endpoint);
const MAX_TTL = 24 * 60 * 60;
diff --git a/packages/builder/lib/utils.ts b/packages/builder/lib/utils.ts
index de1fdf9..4acea10 100644
--- a/packages/builder/lib/utils.ts
+++ b/packages/builder/lib/utils.ts
@@ -104,9 +104,11 @@ export const getPublicKeyFromJwk = (jwk: JsonWebKey): string =>
* Concatenates multiple Uint8Array instances into a single Uint8Array.
*
* @param {Uint8Array[]} arrays - An array of Uint8Array instances to concatenate.
- * @returns {Uint8Array} A new Uint8Array containing all the concatenated data.
+ * @returns {Uint8Array} A new Uint8Array containing all the concatenated data.
*/
-export const concatTypedArrays = (arrays: Uint8Array[]): Uint8Array => {
+export const concatTypedArrays = (
+ arrays: readonly Uint8Array[],
+): Uint8Array => {
const length = arrays.reduce(
(accumulator, current) => accumulator + current.byteLength,
0,
diff --git a/packages/builder/package.json b/packages/builder/package.json
index dcc5146..fe55871 100644
--- a/packages/builder/package.json
+++ b/packages/builder/package.json
@@ -14,7 +14,7 @@
"url": "https://github.com/draphy/pushforge/issues"
},
"engines": {
- "node": ">=16.0.0"
+ "node": ">=20.0.0"
},
"repository": {
"type": "git",
@@ -40,7 +40,7 @@
"semantic-release": "semantic-release"
},
"bin": {
- "generate-vapid-keys": "./dist/lib/commandLine/keys.js"
+ "pushforge": "./dist/lib/commandLine/keys.js"
},
"devDependencies": {
"@semantic-release/commit-analyzer": "^11.1.0",
diff --git a/packages/builder/release.config.cjs b/packages/builder/release.config.cjs
index 636213b..a4e73ab 100644
--- a/packages/builder/release.config.cjs
+++ b/packages/builder/release.config.cjs
@@ -3,7 +3,12 @@ module.exports = {
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
- '@semantic-release/npm',
+ [
+ '@semantic-release/npm',
+ {
+ npmPublish: false,
+ },
+ ],
[
'@semantic-release/github',
{
diff --git a/packages/builder/test/unit.test.ts b/packages/builder/test/unit.test.ts
new file mode 100644
index 0000000..33b89b5
--- /dev/null
+++ b/packages/builder/test/unit.test.ts
@@ -0,0 +1,630 @@
+import { describe, expect, test } from 'vitest';
+import { buildPushHTTPRequest } from '../lib/main.js';
+import type {
+ BuilderOptions,
+ PushMessage,
+ PushSubscription,
+} from '../lib/types.js';
+import { subscriptions } from './fixtures/fixtures.js';
+import { vapidKeys } from './fixtures/vapid.js';
+
+/**
+ * Valid test data for reuse across tests
+ */
+const validSubscription: PushSubscription = subscriptions.chrome;
+
+const validMessage: PushMessage = {
+ payload: { title: 'Test', body: 'Test message' },
+ adminContact: 'mailto:test@example.com',
+ options: { ttl: 3600 },
+};
+
+const validPrivateJWK = vapidKeys.privateJWK;
+
+/**
+ * Helper to create BuilderOptions with overrides
+ */
+const createOptions = (
+ overrides: Partial = {},
+): BuilderOptions => ({
+ privateJWK: validPrivateJWK,
+ subscription: validSubscription,
+ message: validMessage,
+ ...overrides,
+});
+
+// ============================================================================
+// JWK Validation Tests
+// ============================================================================
+describe('JWK Validation', () => {
+ test('accepts valid JWK as string', async () => {
+ const result = await buildPushHTTPRequest(createOptions());
+ expect(result.endpoint).toBe(validSubscription.endpoint);
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ expect(result.headers).toBeDefined();
+ });
+
+ test('accepts valid JWK as object', async () => {
+ const jwkObject = JSON.parse(validPrivateJWK);
+ const result = await buildPushHTTPRequest(
+ createOptions({ privateJWK: jwkObject }),
+ );
+ expect(result.endpoint).toBe(validSubscription.endpoint);
+ });
+
+ test('rejects invalid JSON string', async () => {
+ await expect(
+ buildPushHTTPRequest(createOptions({ privateJWK: 'not valid json' })),
+ ).rejects.toThrow('Invalid privateJWK: failed to parse JSON string');
+ });
+
+ test('rejects JWK with wrong kty', async () => {
+ const invalidJWK = { ...JSON.parse(validPrivateJWK), kty: 'RSA' };
+ await expect(
+ buildPushHTTPRequest(createOptions({ privateJWK: invalidJWK })),
+ ).rejects.toThrow("Invalid JWK: 'kty' must be 'EC', received 'RSA'");
+ });
+
+ test('rejects JWK with wrong curve', async () => {
+ const invalidJWK = { ...JSON.parse(validPrivateJWK), crv: 'P-384' };
+ await expect(
+ buildPushHTTPRequest(createOptions({ privateJWK: invalidJWK })),
+ ).rejects.toThrow("Invalid JWK: 'crv' must be 'P-256', received 'P-384'");
+ });
+
+ test('rejects JWK missing x coordinate', async () => {
+ const invalidJWK = JSON.parse(validPrivateJWK);
+ invalidJWK.x = undefined;
+ await expect(
+ buildPushHTTPRequest(createOptions({ privateJWK: invalidJWK })),
+ ).rejects.toThrow("Invalid JWK: missing or invalid 'x' coordinate");
+ });
+
+ test('rejects JWK missing y coordinate', async () => {
+ const invalidJWK = JSON.parse(validPrivateJWK);
+ invalidJWK.y = undefined;
+ await expect(
+ buildPushHTTPRequest(createOptions({ privateJWK: invalidJWK })),
+ ).rejects.toThrow("Invalid JWK: missing or invalid 'y' coordinate");
+ });
+
+ test('rejects JWK missing d (private key)', async () => {
+ const invalidJWK = JSON.parse(validPrivateJWK);
+ invalidJWK.d = undefined;
+ await expect(
+ buildPushHTTPRequest(createOptions({ privateJWK: invalidJWK })),
+ ).rejects.toThrow("Invalid JWK: missing or invalid 'd' (private key)");
+ });
+
+ test('rejects JWK with missing kty', async () => {
+ const invalidJWK = JSON.parse(validPrivateJWK);
+ invalidJWK.kty = undefined;
+ await expect(
+ buildPushHTTPRequest(createOptions({ privateJWK: invalidJWK })),
+ ).rejects.toThrow("Invalid JWK: 'kty' must be 'EC', received 'undefined'");
+ });
+});
+
+// ============================================================================
+// Endpoint Validation Tests
+// ============================================================================
+describe('Endpoint Validation', () => {
+ test('accepts valid HTTPS endpoint', async () => {
+ const result = await buildPushHTTPRequest(createOptions());
+ expect(result.endpoint).toBe(validSubscription.endpoint);
+ });
+
+ test('rejects HTTP endpoint', async () => {
+ const httpSubscription: PushSubscription = {
+ ...validSubscription,
+ endpoint: 'http://example.com/push',
+ };
+ await expect(
+ buildPushHTTPRequest(createOptions({ subscription: httpSubscription })),
+ ).rejects.toThrow("push endpoints must use HTTPS, received 'http:'");
+ });
+
+ test('rejects invalid URL', async () => {
+ const invalidSubscription: PushSubscription = {
+ ...validSubscription,
+ endpoint: 'not-a-valid-url',
+ };
+ await expect(
+ buildPushHTTPRequest(
+ createOptions({ subscription: invalidSubscription }),
+ ),
+ ).rejects.toThrow('is not a valid URL');
+ });
+
+ test('rejects empty endpoint', async () => {
+ const emptySubscription: PushSubscription = {
+ ...validSubscription,
+ endpoint: '',
+ };
+ await expect(
+ buildPushHTTPRequest(createOptions({ subscription: emptySubscription })),
+ ).rejects.toThrow('is not a valid URL');
+ });
+});
+
+// ============================================================================
+// Subscription Keys Validation Tests
+// ============================================================================
+describe('Subscription Keys Validation', () => {
+ test('accepts valid p256dh and auth keys', async () => {
+ const result = await buildPushHTTPRequest(createOptions());
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('rejects invalid p256dh key (wrong length)', async () => {
+ const invalidSubscription: PushSubscription = {
+ ...validSubscription,
+ keys: {
+ ...validSubscription.keys,
+ p256dh: 'AAAA', // Too short - will decode to ~3 bytes instead of 65
+ },
+ };
+ await expect(
+ buildPushHTTPRequest(
+ createOptions({ subscription: invalidSubscription }),
+ ),
+ ).rejects.toThrow('Invalid p256dh key: expected 65 bytes');
+ });
+
+ test('rejects invalid auth key (wrong length)', async () => {
+ const invalidSubscription: PushSubscription = {
+ ...validSubscription,
+ keys: {
+ ...validSubscription.keys,
+ auth: 'AAAA', // Too short - will decode to ~3 bytes instead of 16
+ },
+ };
+ await expect(
+ buildPushHTTPRequest(
+ createOptions({ subscription: invalidSubscription }),
+ ),
+ ).rejects.toThrow('Incorrect auth length, expected 16 bytes');
+ });
+
+ test('rejects empty p256dh key', async () => {
+ const invalidSubscription: PushSubscription = {
+ ...validSubscription,
+ keys: {
+ ...validSubscription.keys,
+ p256dh: '',
+ },
+ };
+ await expect(
+ buildPushHTTPRequest(
+ createOptions({ subscription: invalidSubscription }),
+ ),
+ ).rejects.toThrow();
+ });
+
+ test('rejects empty auth key', async () => {
+ const invalidSubscription: PushSubscription = {
+ ...validSubscription,
+ keys: {
+ ...validSubscription.keys,
+ auth: '',
+ },
+ };
+ await expect(
+ buildPushHTTPRequest(
+ createOptions({ subscription: invalidSubscription }),
+ ),
+ ).rejects.toThrow('Invalid input');
+ });
+});
+
+// ============================================================================
+// TTL Validation Tests
+// ============================================================================
+describe('TTL Validation', () => {
+ test('accepts TTL within 24 hours', async () => {
+ const message: PushMessage = {
+ ...validMessage,
+ options: { ttl: 3600 }, // 1 hour
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.headers).toBeDefined();
+ });
+
+ test('accepts TTL of exactly 24 hours', async () => {
+ const message: PushMessage = {
+ ...validMessage,
+ options: { ttl: 24 * 60 * 60 }, // 24 hours
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.headers).toBeDefined();
+ });
+
+ test('rejects TTL exceeding 24 hours', async () => {
+ const message: PushMessage = {
+ ...validMessage,
+ options: { ttl: 24 * 60 * 60 + 1 }, // 24 hours + 1 second
+ };
+ await expect(
+ buildPushHTTPRequest(createOptions({ message })),
+ ).rejects.toThrow('TTL must be less than 24 hours');
+ });
+
+ test('uses default TTL when not specified', async () => {
+ const message: PushMessage = {
+ payload: { title: 'Test' },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ // Default TTL should be 24 hours = 86400 seconds
+ expect(getHeaderValue('TTL')).toBe('86400');
+ });
+
+ test('uses default TTL when TTL is 0', async () => {
+ const message: PushMessage = {
+ ...validMessage,
+ options: { ttl: 0 },
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ expect(getHeaderValue('TTL')).toBe('86400');
+ });
+});
+
+// ============================================================================
+// Payload Tests
+// ============================================================================
+describe('Payload Handling', () => {
+ test('encrypts simple payload', async () => {
+ const result = await buildPushHTTPRequest(createOptions());
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ expect(result.body.byteLength).toBeGreaterThan(0);
+ });
+
+ test('encrypts empty object payload', async () => {
+ const message: PushMessage = {
+ payload: {},
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('encrypts payload with unicode characters', async () => {
+ const message: PushMessage = {
+ payload: { title: 'δ½ ε₯½δΈη', body: 'ππβ¨' },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('encrypts payload with special characters', async () => {
+ const message: PushMessage = {
+ payload: {
+ title: 'Test ',
+ body: 'Line1\nLine2\tTabbed',
+ },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('encrypts large payload (under limit)', async () => {
+ // Create a payload close to but under the 4076 byte limit
+ const largeData = 'x'.repeat(3000);
+ const message: PushMessage = {
+ payload: { data: largeData },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('rejects payload exceeding size limit', async () => {
+ // Create a payload that exceeds the 4076 byte limit
+ const hugeData = 'x'.repeat(5000);
+ const message: PushMessage = {
+ payload: { data: hugeData },
+ adminContact: 'mailto:test@example.com',
+ };
+ await expect(
+ buildPushHTTPRequest(createOptions({ message })),
+ ).rejects.toThrow('Payload too large');
+ });
+});
+
+// ============================================================================
+// Headers Tests
+// ============================================================================
+describe('Headers Construction', () => {
+ test('includes required headers', async () => {
+ const result = await buildPushHTTPRequest(createOptions());
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ expect(getHeaderValue('Authorization')).toMatch(/^vapid t=.+, k=.+$/);
+ expect(getHeaderValue('Content-Type')).toBe('application/octet-stream');
+ expect(getHeaderValue('Content-Encoding')).toBe('aesgcm');
+ expect(getHeaderValue('TTL')).toBeDefined();
+ expect(getHeaderValue('Encryption')).toMatch(/^salt=/);
+ expect(getHeaderValue('Crypto-Key')).toMatch(/^dh=/);
+ });
+
+ test('includes optional Topic header when specified', async () => {
+ const message: PushMessage = {
+ ...validMessage,
+ options: { ...validMessage.options, topic: 'test-topic' },
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ expect(getHeaderValue('Topic')).toBe('test-topic');
+ });
+
+ test('includes optional Urgency header when specified', async () => {
+ const message: PushMessage = {
+ ...validMessage,
+ options: { ...validMessage.options, urgency: 'high' },
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ expect(getHeaderValue('Urgency')).toBe('high');
+ });
+
+ test('omits Topic header when not specified', async () => {
+ const message: PushMessage = {
+ payload: validMessage.payload,
+ adminContact: validMessage.adminContact,
+ options: { ttl: 3600 }, // No topic
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ expect(getHeaderValue('Topic')).toBeNull();
+ });
+
+ test('omits Urgency header when not specified', async () => {
+ const message: PushMessage = {
+ payload: validMessage.payload,
+ adminContact: validMessage.adminContact,
+ options: { ttl: 3600 }, // No urgency
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ expect(getHeaderValue('Urgency')).toBeNull();
+ });
+});
+
+// ============================================================================
+// VAPID Authorization Tests
+// ============================================================================
+describe('VAPID Authorization', () => {
+ test('generates valid VAPID authorization header', async () => {
+ const result = await buildPushHTTPRequest(createOptions());
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ const authHeader = getHeaderValue('Authorization');
+ expect(authHeader).toMatch(
+ /^vapid t=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+, k=[A-Za-z0-9_-]+$/,
+ );
+ });
+
+ test('JWT contains correct audience', async () => {
+ const result = await buildPushHTTPRequest(createOptions());
+
+ const getHeaderValue = (name: string): string | null => {
+ if (result.headers instanceof Headers) {
+ return result.headers.get(name);
+ }
+ return result.headers[name] || null;
+ };
+
+ const authHeader = getHeaderValue('Authorization');
+ expect(authHeader).not.toBeNull();
+
+ const tokenMatch = authHeader?.match(
+ /t=([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/,
+ );
+ expect(tokenMatch).not.toBeNull();
+
+ const [, payload] = tokenMatch?.[1].split('.') ?? [];
+ const decodedPayload = JSON.parse(
+ Buffer.from(
+ payload.replace(/-/g, '+').replace(/_/g, '/'),
+ 'base64',
+ ).toString(),
+ );
+
+ expect(decodedPayload.aud).toBe('https://fcm.googleapis.com');
+ expect(decodedPayload.sub).toBe(validMessage.adminContact);
+ expect(decodedPayload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000));
+ });
+});
+
+// ============================================================================
+// Multiple Subscriptions Tests
+// ============================================================================
+describe('Multiple Subscription Types', () => {
+ test('handles Chrome/FCM subscription', async () => {
+ const result = await buildPushHTTPRequest(
+ createOptions({
+ subscription: subscriptions.chrome,
+ }),
+ );
+ expect(result.endpoint).toContain('fcm.googleapis.com');
+ });
+
+ test('handles Edge/WNS subscription', async () => {
+ const result = await buildPushHTTPRequest(
+ createOptions({
+ subscription: subscriptions.edge,
+ }),
+ );
+ expect(result.endpoint).toContain('notify.windows.com');
+ });
+});
+
+// ============================================================================
+// Consistency Tests
+// ============================================================================
+describe('Output Consistency', () => {
+ test('produces different encrypted bodies for same input (due to random salt)', async () => {
+ const options = createOptions();
+ const result1 = await buildPushHTTPRequest(options);
+ const result2 = await buildPushHTTPRequest(options);
+
+ // Bodies should be different due to random salt and padding
+ const body1 = new Uint8Array(result1.body);
+ const body2 = new Uint8Array(result2.body);
+
+ // At least some bytes should differ
+ let hasDifference = false;
+ for (let i = 0; i < Math.min(body1.length, body2.length); i++) {
+ if (body1[i] !== body2[i]) {
+ hasDifference = true;
+ break;
+ }
+ }
+ expect(hasDifference).toBe(true);
+ });
+
+ test('produces consistent endpoint', async () => {
+ const options = createOptions();
+ const result1 = await buildPushHTTPRequest(options);
+ const result2 = await buildPushHTTPRequest(options);
+
+ expect(result1.endpoint).toBe(result2.endpoint);
+ });
+});
+
+// ============================================================================
+// Edge Cases Tests
+// ============================================================================
+describe('Edge Cases', () => {
+ test('handles null values in payload', async () => {
+ const message: PushMessage = {
+ payload: { title: 'Test', nullValue: null },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('handles nested objects in payload', async () => {
+ const message: PushMessage = {
+ payload: {
+ title: 'Test',
+ data: {
+ nested: {
+ deeply: {
+ value: 'test',
+ },
+ },
+ },
+ },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('handles arrays in payload', async () => {
+ const message: PushMessage = {
+ payload: {
+ title: 'Test',
+ items: [1, 2, 3, 'four', { five: 5 }],
+ },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('handles boolean values in payload', async () => {
+ const message: PushMessage = {
+ payload: {
+ isTrue: true,
+ isFalse: false,
+ },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('handles numeric values in payload', async () => {
+ const message: PushMessage = {
+ payload: {
+ integer: 42,
+ float: 3.14,
+ negative: -100,
+ zero: 0,
+ },
+ adminContact: 'mailto:test@example.com',
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+
+ test('handles very long adminContact', async () => {
+ const message: PushMessage = {
+ payload: { title: 'Test' },
+ adminContact: `mailto:${'a'.repeat(200)}@example.com`,
+ };
+ const result = await buildPushHTTPRequest(createOptions({ message }));
+ expect(result.body).toBeInstanceOf(ArrayBuffer);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c58b92f..7c5922f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,6 +39,22 @@ importers:
specifier: ^3.1.2
version: 3.1.2(@types/node@22.14.1)
+ sites/test:
+ dependencies:
+ '@pushforge/builder':
+ specifier: workspace:*
+ version: link:../../packages/builder
+ devDependencies:
+ '@cloudflare/workers-types':
+ specifier: ^4.20250124.0
+ version: 4.20260128.0
+ typescript:
+ specifier: ^5.7.3
+ version: 5.8.3
+ wrangler:
+ specifier: ^4.61.0
+ version: 4.61.0(@cloudflare/workers-types@4.20260128.0)
+
packages:
'@babel/code-frame@7.26.2':
@@ -102,163 +118,516 @@ packages:
cpu: [x64]
os: [win32]
+ '@cloudflare/kv-asset-handler@0.4.2':
+ resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@cloudflare/unenv-preset@2.11.0':
+ resolution: {integrity: sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==}
+ peerDependencies:
+ unenv: 2.0.0-rc.24
+ workerd: ^1.20260115.0
+ peerDependenciesMeta:
+ workerd:
+ optional: true
+
+ '@cloudflare/workerd-darwin-64@1.20260124.0':
+ resolution: {integrity: sha512-VuqscLhiiVIf7t/dcfkjtT0LKJH+a06KUFwFTHgdTcqyLbFZ44u1SLpOONu5fyva4A9MdaKh9a+Z/tBC1d76nw==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@cloudflare/workerd-darwin-arm64@1.20260124.0':
+ resolution: {integrity: sha512-PfnjoFooPgRKFUIZcEP9irnn5Y7OgXinjM+IMlKTdEyLWjMblLsbsqAgydf75+ii0715xAeUlWQjZrWdyOZjMw==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@cloudflare/workerd-linux-64@1.20260124.0':
+ resolution: {integrity: sha512-KSkZl4kwcWeFXI7qsaLlMnKwjgdZwI0OEARjyZpiHCxJCqAqla9XxQKNDscL2Z3qUflIo30i+uteGbFrhzuVGQ==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [linux]
+
+ '@cloudflare/workerd-linux-arm64@1.20260124.0':
+ resolution: {integrity: sha512-61xjSUNk745EVV4vXZP0KGyLCatcmamfBB+dcdQ8kDr6PrNU4IJ1kuQFSJdjybyDhJRm4TpGVywq+9hREuF7xA==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@cloudflare/workerd-windows-64@1.20260124.0':
+ resolution: {integrity: sha512-j9O11pwQQV6Vi3peNrJoyIas3SrZHlPj0Ah+z1hDW9o1v35euVBQJw/PuzjPOXxTFUlGQoMJdfzPsO9xP86g7A==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [win32]
+
+ '@cloudflare/workers-types@4.20260128.0':
+ resolution: {integrity: sha512-oid8qPnF4K5Wmgf66bUUrGycwL8BOCGm9ptQOoQNR/jhY5TmDObLtPjJm+BmDklkpAkaM1FnqKY9lo+FNo78AA==}
+
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
+ '@cspotcode/source-map-support@0.8.1':
+ resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+ engines: {node: '>=12'}
+
+ '@emnapi/runtime@1.8.1':
+ resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
+
'@esbuild/aix-ppc64@0.25.2':
resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
+ '@esbuild/aix-ppc64@0.27.0':
+ resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/android-arm64@0.25.2':
resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm64@0.27.0':
+ resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm@0.25.2':
resolution: {integrity: sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
+ '@esbuild/android-arm@0.27.0':
+ resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-x64@0.25.2':
resolution: {integrity: sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
+ '@esbuild/android-x64@0.27.0':
+ resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/darwin-arm64@0.25.2':
resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-arm64@0.27.0':
+ resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.25.2':
resolution: {integrity: sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
+ '@esbuild/darwin-x64@0.27.0':
+ resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/freebsd-arm64@0.25.2':
resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-arm64@0.27.0':
+ resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.25.2':
resolution: {integrity: sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.27.0':
+ resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/linux-arm64@0.25.2':
resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm64@0.27.0':
+ resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm@0.25.2':
resolution: {integrity: sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
+ '@esbuild/linux-arm@0.27.0':
+ resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-ia32@0.25.2':
resolution: {integrity: sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-ia32@0.27.0':
+ resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-loong64@0.25.2':
resolution: {integrity: sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-loong64@0.27.0':
+ resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.25.2':
resolution: {integrity: sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-mips64el@0.27.0':
+ resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.25.2':
resolution: {integrity: sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-ppc64@0.27.0':
+ resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.25.2':
resolution: {integrity: sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-riscv64@0.27.0':
+ resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-s390x@0.25.2':
resolution: {integrity: sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-s390x@0.27.0':
+ resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-x64@0.25.2':
resolution: {integrity: sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
+ '@esbuild/linux-x64@0.27.0':
+ resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/netbsd-arm64@0.25.2':
resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
+ '@esbuild/netbsd-arm64@0.27.0':
+ resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.25.2':
resolution: {integrity: sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.27.0':
+ resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/openbsd-arm64@0.25.2':
resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
+ '@esbuild/openbsd-arm64@0.27.0':
+ resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.25.2':
resolution: {integrity: sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.27.0':
+ resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.0':
+ resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
'@esbuild/sunos-x64@0.25.2':
resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
+ '@esbuild/sunos-x64@0.27.0':
+ resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/win32-arm64@0.25.2':
resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
+ '@esbuild/win32-arm64@0.27.0':
+ resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-ia32@0.25.2':
resolution: {integrity: sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-ia32@0.27.0':
+ resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/win32-x64@0.25.2':
resolution: {integrity: sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
+ '@esbuild/win32-x64@0.27.0':
+ resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@img/colour@1.0.0':
+ resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+ '@jridgewell/trace-mapping@0.3.9':
+ resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -385,6 +754,15 @@ packages:
resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==}
engines: {node: '>=12'}
+ '@poppinss/colors@4.1.6':
+ resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
+
+ '@poppinss/dumper@0.6.5':
+ resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
+
+ '@poppinss/exception@1.2.3':
+ resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
+
'@rollup/rollup-android-arm-eabi@4.40.0':
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
cpu: [arm]
@@ -544,6 +922,10 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
+ '@sindresorhus/is@7.2.0':
+ resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
+ engines: {node: '>=18'}
+
'@sindresorhus/merge-streams@2.3.0':
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
engines: {node: '>=18'}
@@ -552,6 +934,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
+ '@speed-highlight/core@1.2.14':
+ resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==}
+
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -644,6 +1029,9 @@ packages:
before-after-hook@3.0.2:
resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==}
+ blake3-wasm@2.1.5:
+ resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
+
bottleneck@2.19.5:
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
@@ -762,6 +1150,10 @@ packages:
resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==}
engines: {node: '>=12'}
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -802,6 +1194,10 @@ packages:
deprecation@2.3.1:
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -834,6 +1230,9 @@ packages:
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
+ error-stack-parser-es@1.0.5:
+ resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
+
es-module-lexer@1.6.0:
resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==}
@@ -842,6 +1241,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ esbuild@0.27.0:
+ resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1129,6 +1533,10 @@ packages:
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
engines: {'0': node >= 0.2.0}
+ kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -1206,6 +1614,11 @@ packages:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
+ miniflare@4.20260124.0:
+ resolution: {integrity: sha512-Co8onUh+POwOuLty4myQg+Nzg9/xZ5eAJc1oqYBzRovHd/XIpb5WAnRVaubcfAQJ85awWtF3yXUHCDx6cIaN3w==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -1404,6 +1817,9 @@ packages:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
+ path-to-regexp@6.3.0:
+ resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
+
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -1524,6 +1940,15 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1621,6 +2046,10 @@ packages:
resolution: {integrity: sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==}
engines: {node: '>=18'}
+ supports-color@10.2.2:
+ resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
+ engines: {node: '>=18'}
+
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
@@ -1692,6 +2121,9 @@ packages:
resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==}
engines: {node: '>= 0.4'}
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
type-fest@1.4.0:
resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==}
engines: {node: '>=10'}
@@ -1717,6 +2149,13 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici@7.18.2:
+ resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
+ engines: {node: '>=20.18.1'}
+
+ unenv@2.0.0-rc.24:
+ resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
+
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
engines: {node: '>=4'}
@@ -1839,6 +2278,21 @@ packages:
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
+ workerd@1.20260124.0:
+ resolution: {integrity: sha512-JN6voV/fUQK342a39Rl+20YVmtIXZVbpxc7V/m809lUnlTGPy4aa5MI7PMoc+9qExgAEOw9cojvN5zOfqmMWLg==}
+ engines: {node: '>=16'}
+ hasBin: true
+
+ wrangler@4.61.0:
+ resolution: {integrity: sha512-Kb8NMe1B/HM7/ds3hU+fcV1U7T996vRKJ0UU/qqgNUMwdemTRA+sSaH3mQvQslIBbprHHU81s0huA6fDIcwiaQ==}
+ engines: {node: '>=20.0.0'}
+ hasBin: true
+ peerDependencies:
+ '@cloudflare/workers-types': ^4.20260124.0
+ peerDependenciesMeta:
+ '@cloudflare/workers-types':
+ optional: true
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -1846,6 +2300,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ ws@8.18.0:
+ resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -1874,6 +2340,12 @@ packages:
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
engines: {node: '>=18'}
+ youch-core@0.3.3:
+ resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
+
+ youch@4.1.0-beta.10:
+ resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
+
snapshots:
'@babel/code-frame@7.26.2':
@@ -1919,86 +2391,301 @@ snapshots:
'@biomejs/cli-win32-x64@1.9.4':
optional: true
+ '@cloudflare/kv-asset-handler@0.4.2': {}
+
+ '@cloudflare/unenv-preset@2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260124.0)':
+ dependencies:
+ unenv: 2.0.0-rc.24
+ optionalDependencies:
+ workerd: 1.20260124.0
+
+ '@cloudflare/workerd-darwin-64@1.20260124.0':
+ optional: true
+
+ '@cloudflare/workerd-darwin-arm64@1.20260124.0':
+ optional: true
+
+ '@cloudflare/workerd-linux-64@1.20260124.0':
+ optional: true
+
+ '@cloudflare/workerd-linux-arm64@1.20260124.0':
+ optional: true
+
+ '@cloudflare/workerd-windows-64@1.20260124.0':
+ optional: true
+
+ '@cloudflare/workers-types@4.20260128.0': {}
+
'@colors/colors@1.5.0':
optional: true
+ '@cspotcode/source-map-support@0.8.1':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.9
+
+ '@emnapi/runtime@1.8.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@esbuild/aix-ppc64@0.25.2':
optional: true
+ '@esbuild/aix-ppc64@0.27.0':
+ optional: true
+
'@esbuild/android-arm64@0.25.2':
optional: true
+ '@esbuild/android-arm64@0.27.0':
+ optional: true
+
'@esbuild/android-arm@0.25.2':
optional: true
+ '@esbuild/android-arm@0.27.0':
+ optional: true
+
'@esbuild/android-x64@0.25.2':
optional: true
+ '@esbuild/android-x64@0.27.0':
+ optional: true
+
'@esbuild/darwin-arm64@0.25.2':
optional: true
+ '@esbuild/darwin-arm64@0.27.0':
+ optional: true
+
'@esbuild/darwin-x64@0.25.2':
optional: true
+ '@esbuild/darwin-x64@0.27.0':
+ optional: true
+
'@esbuild/freebsd-arm64@0.25.2':
optional: true
+ '@esbuild/freebsd-arm64@0.27.0':
+ optional: true
+
'@esbuild/freebsd-x64@0.25.2':
optional: true
+ '@esbuild/freebsd-x64@0.27.0':
+ optional: true
+
'@esbuild/linux-arm64@0.25.2':
optional: true
+ '@esbuild/linux-arm64@0.27.0':
+ optional: true
+
'@esbuild/linux-arm@0.25.2':
optional: true
+ '@esbuild/linux-arm@0.27.0':
+ optional: true
+
'@esbuild/linux-ia32@0.25.2':
optional: true
+ '@esbuild/linux-ia32@0.27.0':
+ optional: true
+
'@esbuild/linux-loong64@0.25.2':
optional: true
+ '@esbuild/linux-loong64@0.27.0':
+ optional: true
+
'@esbuild/linux-mips64el@0.25.2':
optional: true
+ '@esbuild/linux-mips64el@0.27.0':
+ optional: true
+
'@esbuild/linux-ppc64@0.25.2':
optional: true
+ '@esbuild/linux-ppc64@0.27.0':
+ optional: true
+
'@esbuild/linux-riscv64@0.25.2':
optional: true
+ '@esbuild/linux-riscv64@0.27.0':
+ optional: true
+
'@esbuild/linux-s390x@0.25.2':
optional: true
+ '@esbuild/linux-s390x@0.27.0':
+ optional: true
+
'@esbuild/linux-x64@0.25.2':
optional: true
+ '@esbuild/linux-x64@0.27.0':
+ optional: true
+
'@esbuild/netbsd-arm64@0.25.2':
optional: true
+ '@esbuild/netbsd-arm64@0.27.0':
+ optional: true
+
'@esbuild/netbsd-x64@0.25.2':
optional: true
+ '@esbuild/netbsd-x64@0.27.0':
+ optional: true
+
'@esbuild/openbsd-arm64@0.25.2':
optional: true
+ '@esbuild/openbsd-arm64@0.27.0':
+ optional: true
+
'@esbuild/openbsd-x64@0.25.2':
optional: true
+ '@esbuild/openbsd-x64@0.27.0':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.0':
+ optional: true
+
'@esbuild/sunos-x64@0.25.2':
optional: true
+ '@esbuild/sunos-x64@0.27.0':
+ optional: true
+
'@esbuild/win32-arm64@0.25.2':
optional: true
+ '@esbuild/win32-arm64@0.27.0':
+ optional: true
+
'@esbuild/win32-ia32@0.25.2':
optional: true
+ '@esbuild/win32-ia32@0.27.0':
+ optional: true
+
'@esbuild/win32-x64@0.25.2':
optional: true
+ '@esbuild/win32-x64@0.27.0':
+ optional: true
+
+ '@img/colour@1.0.0': {}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.8.1
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
'@jridgewell/sourcemap-codec@1.5.0': {}
+ '@jridgewell/trace-mapping@0.3.9':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.0
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2148,6 +2835,18 @@ snapshots:
'@pnpm/network.ca-file': 1.0.2
config-chain: 1.1.13
+ '@poppinss/colors@4.1.6':
+ dependencies:
+ kleur: 4.1.5
+
+ '@poppinss/dumper@0.6.5':
+ dependencies:
+ '@poppinss/colors': 4.1.6
+ '@sindresorhus/is': 7.2.0
+ supports-color: 10.2.2
+
+ '@poppinss/exception@1.2.3': {}
+
'@rollup/rollup-android-arm-eabi@4.40.0':
optional: true
@@ -2351,10 +3050,14 @@ snapshots:
'@sindresorhus/is@4.6.0': {}
+ '@sindresorhus/is@7.2.0': {}
+
'@sindresorhus/merge-streams@2.3.0': {}
'@sindresorhus/merge-streams@4.0.0': {}
+ '@speed-highlight/core@1.2.14': {}
+
'@types/estree@1.0.7': {}
'@types/node@22.14.1':
@@ -2445,6 +3148,8 @@ snapshots:
before-after-hook@3.0.2: {}
+ blake3-wasm@2.1.5: {}
+
bottleneck@2.19.5: {}
braces@3.0.3:
@@ -2574,6 +3279,8 @@ snapshots:
convert-hrtime@5.0.0: {}
+ cookie@1.1.1: {}
+
core-util-is@1.0.3: {}
cosmiconfig@9.0.0(typescript@5.8.3):
@@ -2605,6 +3312,8 @@ snapshots:
deprecation@2.3.1: {}
+ detect-libc@2.1.2: {}
+
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -2634,6 +3343,8 @@ snapshots:
dependencies:
is-arrayish: 0.2.1
+ error-stack-parser-es@1.0.5: {}
+
es-module-lexer@1.6.0: {}
esbuild@0.25.2:
@@ -2664,6 +3375,35 @@ snapshots:
'@esbuild/win32-ia32': 0.25.2
'@esbuild/win32-x64': 0.25.2
+ esbuild@0.27.0:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.0
+ '@esbuild/android-arm': 0.27.0
+ '@esbuild/android-arm64': 0.27.0
+ '@esbuild/android-x64': 0.27.0
+ '@esbuild/darwin-arm64': 0.27.0
+ '@esbuild/darwin-x64': 0.27.0
+ '@esbuild/freebsd-arm64': 0.27.0
+ '@esbuild/freebsd-x64': 0.27.0
+ '@esbuild/linux-arm': 0.27.0
+ '@esbuild/linux-arm64': 0.27.0
+ '@esbuild/linux-ia32': 0.27.0
+ '@esbuild/linux-loong64': 0.27.0
+ '@esbuild/linux-mips64el': 0.27.0
+ '@esbuild/linux-ppc64': 0.27.0
+ '@esbuild/linux-riscv64': 0.27.0
+ '@esbuild/linux-s390x': 0.27.0
+ '@esbuild/linux-x64': 0.27.0
+ '@esbuild/netbsd-arm64': 0.27.0
+ '@esbuild/netbsd-x64': 0.27.0
+ '@esbuild/openbsd-arm64': 0.27.0
+ '@esbuild/openbsd-x64': 0.27.0
+ '@esbuild/openharmony-arm64': 0.27.0
+ '@esbuild/sunos-x64': 0.27.0
+ '@esbuild/win32-arm64': 0.27.0
+ '@esbuild/win32-ia32': 0.27.0
+ '@esbuild/win32-x64': 0.27.0
+
escalade@3.2.0: {}
escape-string-regexp@1.0.5: {}
@@ -2946,6 +3686,8 @@ snapshots:
jsonparse@1.3.1: {}
+ kleur@4.1.5: {}
+
lines-and-columns@1.2.4: {}
load-json-file@4.0.0:
@@ -3010,6 +3752,18 @@ snapshots:
mimic-fn@4.0.0: {}
+ miniflare@4.20260124.0:
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ sharp: 0.34.5
+ undici: 7.18.2
+ workerd: 1.20260124.0
+ ws: 8.18.0
+ youch: 4.1.0-beta.10
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
minimist@1.2.8: {}
ms@2.1.3: {}
@@ -3122,6 +3876,8 @@ snapshots:
path-key@4.0.0: {}
+ path-to-regexp@6.3.0: {}
+
path-type@4.0.0: {}
path-type@6.0.0: {}
@@ -3283,6 +4039,39 @@ snapshots:
semver@7.7.1: {}
+ semver@7.7.3: {}
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.0.0
+ detect-libc: 2.1.2
+ semver: 7.7.3
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -3367,6 +4156,8 @@ snapshots:
function-timeout: 1.0.2
time-span: 5.1.0
+ supports-color@10.2.2: {}
+
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
@@ -3431,6 +4222,9 @@ snapshots:
traverse@0.6.8: {}
+ tslib@2.8.1:
+ optional: true
+
type-fest@1.4.0: {}
type-fest@2.19.0: {}
@@ -3444,6 +4238,12 @@ snapshots:
undici-types@6.21.0: {}
+ undici@7.18.2: {}
+
+ unenv@2.0.0-rc.24:
+ dependencies:
+ pathe: 2.0.3
+
unicode-emoji-modifier-base@1.0.0: {}
unicorn-magic@0.1.0: {}
@@ -3552,6 +4352,31 @@ snapshots:
wordwrap@1.0.0: {}
+ workerd@1.20260124.0:
+ optionalDependencies:
+ '@cloudflare/workerd-darwin-64': 1.20260124.0
+ '@cloudflare/workerd-darwin-arm64': 1.20260124.0
+ '@cloudflare/workerd-linux-64': 1.20260124.0
+ '@cloudflare/workerd-linux-arm64': 1.20260124.0
+ '@cloudflare/workerd-windows-64': 1.20260124.0
+
+ wrangler@4.61.0(@cloudflare/workers-types@4.20260128.0):
+ dependencies:
+ '@cloudflare/kv-asset-handler': 0.4.2
+ '@cloudflare/unenv-preset': 2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260124.0)
+ blake3-wasm: 2.1.5
+ esbuild: 0.27.0
+ miniflare: 4.20260124.0
+ path-to-regexp: 6.3.0
+ unenv: 2.0.0-rc.24
+ workerd: 1.20260124.0
+ optionalDependencies:
+ '@cloudflare/workers-types': 4.20260128.0
+ fsevents: 2.3.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -3560,6 +4385,8 @@ snapshots:
wrappy@1.0.2: {}
+ ws@8.18.0: {}
+
xtend@4.0.2: {}
y18n@5.0.8: {}
@@ -3589,3 +4416,16 @@ snapshots:
yargs-parser: 21.1.1
yoctocolors@2.1.1: {}
+
+ youch-core@0.3.3:
+ dependencies:
+ '@poppinss/exception': 1.2.3
+ error-stack-parser-es: 1.0.5
+
+ youch@4.1.0-beta.10:
+ dependencies:
+ '@poppinss/colors': 4.1.6
+ '@poppinss/dumper': 0.6.5
+ '@speed-highlight/core': 1.2.14
+ cookie: 1.1.1
+ youch-core: 0.3.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 5cc9d8b..446c998 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,3 @@
packages:
- - 'packages/*'
\ No newline at end of file
+ - 'packages/*'
+ - 'sites/*'
\ No newline at end of file