1import webPush, { type PushSubscription } from "web-push";2import type { Env } from "./env.ts";34export interface WebPushMessage {5 eventId: string;6 title: string;7 body: string;8 path?: string;9 tag?: string;10}1112export interface WebPushResult {13 status: number;14 retryAfter: number | null;15}1617export interface WebPushRequest {18 endpoint: URL;19 headers: Record<string, string>;20 body: Uint8Array;21}2223function configuredOrigins(env: Env): URL[] {24 const raw = env.WEB_PUSH_ORIGINS?.trim();25 if (!raw) throw new Error("web push origins are not configured");26 return raw.split(",").map((value) => {27 const url = new URL(value.trim());28 if (29 url.protocol !== "https:" || url.username || url.password || url.hash ||30 url.pathname !== "/" || url.search || url.port31 ) {32 throw new Error("invalid web push origin configuration");33 }34 return url;35 });36}3738function isIpLiteral(hostname: string): boolean {39 return /^\[.*\]$/.test(hostname) || /^\d+(?:\.\d+){3}$/.test(hostname) ||40 /^0x/i.test(hostname) || /^\d+$/.test(hostname);41}4243export function validatePushEndpoint(env: Env, endpoint: string): URL {44 let url: URL;45 try {46 url = new URL(endpoint);47 } catch {48 throw new Error("invalid push subscription endpoint");49 }50 if (51 url.protocol !== "https:" || url.username || url.password || url.hash ||52 url.port || isIpLiteral(url.hostname)53 ) {54 throw new Error("invalid push subscription endpoint");55 }56 const publicBase = new URL(env.PUBLIC_BASE);57 if (url.hostname === publicBase.hostname) {58 throw new Error("push subscription endpoint cannot target this deployment");59 }60 const allowed = configuredOrigins(env).some((origin) =>61 origin.origin === url.origin62 );63 if (!allowed) throw new Error("push subscription provider is not allowed");64 return url;65}6667export function buildWebPushRequest(68 env: Env,69 subscription: PushSubscription,70 message: WebPushMessage,71): WebPushRequest {72 const endpoint = validatePushEndpoint(env, subscription.endpoint);73 if (!env.VAPID_PUBLIC_KEY || !env.VAPID_PRIVATE_KEY || !env.VAPID_SUBJECT) {74 throw new Error("VAPID is not configured");75 }76 const details = webPush.generateRequestDetails(77 subscription,78 JSON.stringify(message),79 {80 TTL: 6 * 60 * 60,81 urgency: "normal",82 contentEncoding: "aes128gcm",83 vapidDetails: {84 subject: env.VAPID_SUBJECT,85 publicKey: env.VAPID_PUBLIC_KEY,86 privateKey: env.VAPID_PRIVATE_KEY,87 },88 },89 );90 if (!details.body) throw new Error("web push payload was not generated");91 return {92 endpoint,93 headers: details.headers,94 body: new Uint8Array(details.body),95 };96}9798export async function sendWebPush(99 env: Env,100 subscription: PushSubscription,101 message: WebPushMessage,102): Promise<WebPushResult> {103 const request = buildWebPushRequest(env, subscription, message);104 const response = await fetch(request.endpoint, {105 method: "POST",106 headers: request.headers,107 body: request.body,108 redirect: "manual",109 signal: AbortSignal.timeout(20_000),110 });111 const retryAfterHeader = response.headers.get("retry-after");112 const retryAfter = retryAfterHeader && /^\d+$/.test(retryAfterHeader)113 ? Math.min(Number(retryAfterHeader), 6 * 60 * 60)114 : null;115 return { status: response.status, retryAfter };116}