@vouchington/utils 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/ai-moderation.d.mts +34 -0
- package/dist/ai-moderation.mjs +54 -0
- package/dist/bedrock-embedding-batches.d.mts +53 -0
- package/dist/bedrock-embedding-batches.mjs +51 -0
- package/dist/bedrock-embeddings.d.mts +62 -0
- package/dist/bedrock-embeddings.mjs +106 -0
- package/dist/json-ld-profile.d.mts +12 -0
- package/dist/json-ld-profile.mjs +31 -0
- package/dist/json-ld.d.mts +57 -0
- package/dist/json-ld.mjs +117 -0
- package/dist/seo-excerpt.d.mts +2 -0
- package/dist/seo-excerpt.mjs +33 -0
- package/dist/seo-metadata.d.mts +94 -0
- package/dist/seo-metadata.mjs +97 -0
- package/dist/speculation-rules.d.mts +11 -0
- package/dist/speculation-rules.mjs +36 -0
- package/dist/web-push-binding.d.mts +51 -0
- package/dist/web-push-binding.mjs +67 -0
- package/dist/web-push-vapid.d.mts +26 -0
- package/dist/web-push-vapid.mjs +86 -0
- package/dist/web-push.d.mts +36 -0
- package/dist/web-push.mjs +114 -0
- package/package.json +46 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export interface SeoSite {
|
|
2
|
+
siteName: string;
|
|
3
|
+
/** Absolute origin, e.g. `https://example.com`. */
|
|
4
|
+
origin: string;
|
|
5
|
+
description: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SeoRobots {
|
|
8
|
+
index: false;
|
|
9
|
+
follow: false;
|
|
10
|
+
googleBot: {
|
|
11
|
+
index: false;
|
|
12
|
+
follow: false;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface SeoImage extends ImageSize {
|
|
16
|
+
url: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SeoOpenGraph {
|
|
19
|
+
type: 'website' | 'article';
|
|
20
|
+
siteName: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
description: string;
|
|
23
|
+
locale?: string;
|
|
24
|
+
url: string;
|
|
25
|
+
images?: SeoImage[];
|
|
26
|
+
publishedTime?: string;
|
|
27
|
+
modifiedTime?: string;
|
|
28
|
+
authors?: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface SeoTwitter {
|
|
31
|
+
card: 'summary_large_image';
|
|
32
|
+
title?: string;
|
|
33
|
+
description: string;
|
|
34
|
+
images?: string[];
|
|
35
|
+
}
|
|
36
|
+
export type SeoAlternateTypes = Record<string, string | {
|
|
37
|
+
url: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
}[]>;
|
|
40
|
+
/** Assignable to Next.js `Metadata` (the shapes below are subsets of its OpenGraph/Twitter types). */
|
|
41
|
+
export interface SeoMetadata {
|
|
42
|
+
metadataBase: URL;
|
|
43
|
+
title?: string | {
|
|
44
|
+
default: string;
|
|
45
|
+
template: string;
|
|
46
|
+
};
|
|
47
|
+
description: string;
|
|
48
|
+
alternates?: {
|
|
49
|
+
canonical: string;
|
|
50
|
+
types?: SeoAlternateTypes;
|
|
51
|
+
};
|
|
52
|
+
robots?: SeoRobots | undefined;
|
|
53
|
+
openGraph?: SeoOpenGraph;
|
|
54
|
+
twitter?: SeoTwitter;
|
|
55
|
+
}
|
|
56
|
+
export interface ImageSize {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
}
|
|
60
|
+
export interface PageMetadataOptions {
|
|
61
|
+
title?: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
path?: string;
|
|
64
|
+
noIndex?: boolean;
|
|
65
|
+
/** Absolute image URL. `null` omits images; undefined uses `fallbackImageUrl`. */
|
|
66
|
+
imageUrl?: string | null;
|
|
67
|
+
fallbackImageUrl?: string;
|
|
68
|
+
type?: 'website' | 'article';
|
|
69
|
+
publishedTime?: string;
|
|
70
|
+
modifiedTime?: string;
|
|
71
|
+
authors?: string[];
|
|
72
|
+
rssUrl?: string;
|
|
73
|
+
/** Resolved by the caller (for example `en_US`). */
|
|
74
|
+
openGraphLocale?: string | null;
|
|
75
|
+
/** Resolved by the caller; ignored for noindex pages. */
|
|
76
|
+
markdownAlternatePath?: string | null;
|
|
77
|
+
imageSize?: ImageSize;
|
|
78
|
+
}
|
|
79
|
+
export declare function buildAbsoluteUrl(origin: string, path?: string): string;
|
|
80
|
+
export declare function createRobotsMetadata(noIndex: boolean): SeoRobots | undefined;
|
|
81
|
+
export { createExcerpt } from './seo-excerpt.mts';
|
|
82
|
+
/** Returns `${path}.md` when `path` is `/prefix/id` for a listed prefix. */
|
|
83
|
+
export declare function getMarkdownAlternatePath(path: string, prefixes: ReadonlySet<string>): string | null;
|
|
84
|
+
export declare function createNoIndexMetadata(site: SeoSite, title?: string): SeoMetadata;
|
|
85
|
+
export declare function createPageMetadata(site: SeoSite, options: PageMetadataOptions): SeoMetadata;
|
|
86
|
+
/** Root layout metadata; icons are passed through untouched. */
|
|
87
|
+
export declare function createRootMetadata<Icons = never>(site: SeoSite, options: {
|
|
88
|
+
imageUrl: string;
|
|
89
|
+
openGraphLocale?: string | null;
|
|
90
|
+
icons?: Icons;
|
|
91
|
+
imageSize?: ImageSize;
|
|
92
|
+
}): SeoMetadata & {
|
|
93
|
+
icons?: Icons;
|
|
94
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const DEFAULT_IMAGE_SIZE = { width: 1200, height: 630 };
|
|
2
|
+
export function buildAbsoluteUrl(origin, path = '/') {
|
|
3
|
+
return new URL(path, origin).toString();
|
|
4
|
+
}
|
|
5
|
+
export function createRobotsMetadata(noIndex) {
|
|
6
|
+
if (!noIndex)
|
|
7
|
+
return undefined;
|
|
8
|
+
return { index: false, follow: false, googleBot: { index: false, follow: false } };
|
|
9
|
+
}
|
|
10
|
+
export { createExcerpt } from './seo-excerpt.mjs';
|
|
11
|
+
/** Returns `${path}.md` when `path` is `/prefix/id` for a listed prefix. */
|
|
12
|
+
export function getMarkdownAlternatePath(path, prefixes) {
|
|
13
|
+
const parts = path.split('/').filter(Boolean);
|
|
14
|
+
if (parts.length !== 2)
|
|
15
|
+
return null;
|
|
16
|
+
return prefixes.has(parts[0]) ? `${path.replace(/\/$/, '')}.md` : null;
|
|
17
|
+
}
|
|
18
|
+
export function createNoIndexMetadata(site, title) {
|
|
19
|
+
return {
|
|
20
|
+
metadataBase: new URL(site.origin),
|
|
21
|
+
...(title ? { title } : {}),
|
|
22
|
+
description: site.description,
|
|
23
|
+
robots: createRobotsMetadata(true),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function createPageMetadata(site, options) {
|
|
27
|
+
const { title, path = '/', noIndex = false, type = 'website' } = options;
|
|
28
|
+
const description = options.description ?? site.description;
|
|
29
|
+
const imageUrl = options.imageUrl === null ? undefined : (options.imageUrl ?? options.fallbackImageUrl);
|
|
30
|
+
const size = options.imageSize ?? DEFAULT_IMAGE_SIZE;
|
|
31
|
+
const markdownPath = noIndex ? null : (options.markdownAlternatePath ?? null);
|
|
32
|
+
const types = {
|
|
33
|
+
...(markdownPath
|
|
34
|
+
? { 'text/markdown': [{ url: markdownPath, title: `${title ?? site.siteName} Markdown` }] }
|
|
35
|
+
: {}),
|
|
36
|
+
...(options.rssUrl ? { 'application/rss+xml': options.rssUrl } : {}),
|
|
37
|
+
};
|
|
38
|
+
const article = type === 'article'
|
|
39
|
+
? {
|
|
40
|
+
...(options.publishedTime ? { publishedTime: options.publishedTime } : {}),
|
|
41
|
+
...(options.modifiedTime ? { modifiedTime: options.modifiedTime } : {}),
|
|
42
|
+
...(options.authors?.length ? { authors: options.authors } : {}),
|
|
43
|
+
}
|
|
44
|
+
: {};
|
|
45
|
+
return {
|
|
46
|
+
metadataBase: new URL(site.origin),
|
|
47
|
+
...(title ? { title } : {}),
|
|
48
|
+
description,
|
|
49
|
+
alternates: {
|
|
50
|
+
canonical: path,
|
|
51
|
+
...(Object.keys(types).length > 0 ? { types } : {}),
|
|
52
|
+
},
|
|
53
|
+
robots: createRobotsMetadata(noIndex),
|
|
54
|
+
openGraph: {
|
|
55
|
+
type,
|
|
56
|
+
siteName: site.siteName,
|
|
57
|
+
...(title ? { title } : {}),
|
|
58
|
+
description,
|
|
59
|
+
...(options.openGraphLocale ? { locale: options.openGraphLocale } : {}),
|
|
60
|
+
url: buildAbsoluteUrl(site.origin, path),
|
|
61
|
+
...(imageUrl ? { images: [{ url: imageUrl, ...size }] } : {}),
|
|
62
|
+
...article,
|
|
63
|
+
},
|
|
64
|
+
twitter: {
|
|
65
|
+
card: 'summary_large_image',
|
|
66
|
+
...(title ? { title } : {}),
|
|
67
|
+
description,
|
|
68
|
+
...(imageUrl ? { images: [imageUrl] } : {}),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Root layout metadata; icons are passed through untouched. */
|
|
73
|
+
export function createRootMetadata(site, options) {
|
|
74
|
+
const size = options.imageSize ?? DEFAULT_IMAGE_SIZE;
|
|
75
|
+
return {
|
|
76
|
+
metadataBase: new URL(site.origin),
|
|
77
|
+
title: { default: site.siteName, template: `%s | ${site.siteName}` },
|
|
78
|
+
description: site.description,
|
|
79
|
+
alternates: { canonical: '/' },
|
|
80
|
+
...(options.icons ? { icons: options.icons } : {}),
|
|
81
|
+
openGraph: {
|
|
82
|
+
type: 'website',
|
|
83
|
+
siteName: site.siteName,
|
|
84
|
+
title: site.siteName,
|
|
85
|
+
description: site.description,
|
|
86
|
+
...(options.openGraphLocale ? { locale: options.openGraphLocale } : {}),
|
|
87
|
+
url: buildAbsoluteUrl(site.origin, '/'),
|
|
88
|
+
images: [{ url: options.imageUrl, ...size }],
|
|
89
|
+
},
|
|
90
|
+
twitter: {
|
|
91
|
+
card: 'summary_large_image',
|
|
92
|
+
title: site.siteName,
|
|
93
|
+
description: site.description,
|
|
94
|
+
images: [options.imageUrl],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SpeculationRulesConfig {
|
|
2
|
+
excludedHrefPatterns: readonly string[];
|
|
3
|
+
excludedSelectors?: readonly string[];
|
|
4
|
+
/** Value for `expects_no_vary_search`; omitted when undefined. */
|
|
5
|
+
noVarySearchHeader?: string;
|
|
6
|
+
eagerness?: 'immediate' | 'eager' | 'moderate' | 'conservative';
|
|
7
|
+
}
|
|
8
|
+
/** Builds a prefetch speculation-rules object. Serialize it with `serializeJsonLd`-style escaping. */
|
|
9
|
+
export declare function createSpeculationRules(config: SpeculationRulesConfig): Record<string, unknown>;
|
|
10
|
+
/** Returns the origin of an http(s) URL, or null. */
|
|
11
|
+
export declare function getPreconnectOrigin(rawOrigin: string | undefined): string | null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Builds a prefetch speculation-rules object. Serialize it with `serializeJsonLd`-style escaping. */
|
|
2
|
+
export function createSpeculationRules(config) {
|
|
3
|
+
return {
|
|
4
|
+
prefetch: [
|
|
5
|
+
{
|
|
6
|
+
source: 'document',
|
|
7
|
+
where: {
|
|
8
|
+
and: [
|
|
9
|
+
{ href_matches: '/*' },
|
|
10
|
+
...config.excludedHrefPatterns.map((pattern) => ({ not: { href_matches: pattern } })),
|
|
11
|
+
...(config.excludedSelectors ?? []).map((selector) => ({
|
|
12
|
+
not: { selector_matches: selector },
|
|
13
|
+
})),
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
eagerness: config.eagerness ?? 'conservative',
|
|
17
|
+
...(config.noVarySearchHeader ? { expects_no_vary_search: config.noVarySearchHeader } : {}),
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Returns the origin of an http(s) URL, or null. */
|
|
23
|
+
export function getPreconnectOrigin(rawOrigin) {
|
|
24
|
+
const trimmed = rawOrigin?.trim();
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(trimmed);
|
|
29
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
30
|
+
return null;
|
|
31
|
+
return url.origin;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface WebPushBinding {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
subscription_id: string;
|
|
4
|
+
}
|
|
5
|
+
export type WebPushWorkerState = {
|
|
6
|
+
status: 'uninitialized';
|
|
7
|
+
} | {
|
|
8
|
+
status: 'bound';
|
|
9
|
+
binding: WebPushBinding;
|
|
10
|
+
revision: string;
|
|
11
|
+
} | {
|
|
12
|
+
status: 'reconciling';
|
|
13
|
+
binding?: WebPushBinding;
|
|
14
|
+
revision: string;
|
|
15
|
+
} | {
|
|
16
|
+
status: 'disabled';
|
|
17
|
+
binding?: WebPushBinding;
|
|
18
|
+
revision: string;
|
|
19
|
+
};
|
|
20
|
+
export interface WebPushWorkerResponse {
|
|
21
|
+
type: string;
|
|
22
|
+
version: 1;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
state?: unknown;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface WorkerRequestOptions {
|
|
28
|
+
/** Caller-owned message `type` tag shared with the service worker. */
|
|
29
|
+
messageType: string;
|
|
30
|
+
/** Caller-owned check that the acknowledged state is the one the request expects. */
|
|
31
|
+
accept?: (state: WebPushWorkerState) => boolean;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function isWebPushBinding(value: unknown): value is WebPushBinding;
|
|
35
|
+
export declare function isWebPushWorkerState(value: unknown): value is WebPushWorkerState;
|
|
36
|
+
/** Envelope check only: tag, version, `ok`, and a well-formed state. */
|
|
37
|
+
export declare function isWebPushWorkerResponse(response: WebPushWorkerResponse | undefined, messageType: string): response is WebPushWorkerResponse & {
|
|
38
|
+
state: WebPushWorkerState;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* One request/acknowledge round trip over a MessageChannel to the active service worker.
|
|
42
|
+
* `request` is sent as `{ type, version: 1, ...request }`. Which requests to send, in what
|
|
43
|
+
* order, and which acknowledged states are acceptable belong to the caller.
|
|
44
|
+
*/
|
|
45
|
+
export declare function requestWorkerState(registration: {
|
|
46
|
+
active: {
|
|
47
|
+
postMessage(message: unknown, transfer: unknown[]): void;
|
|
48
|
+
} | null;
|
|
49
|
+
}, request: Record<string, unknown>, options: WorkerRequestOptions): Promise<WebPushWorkerState>;
|
|
50
|
+
/** Runs `operation` under a named Web Lock; throws when the browser has no lock manager. */
|
|
51
|
+
export declare function withWebLock<T>(name: string, operation: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const DEFAULT_ACK_TIMEOUT_MS = 5000;
|
|
2
|
+
function isNonEmptyString(value) {
|
|
3
|
+
return typeof value === 'string' && value.length > 0;
|
|
4
|
+
}
|
|
5
|
+
export function isWebPushBinding(value) {
|
|
6
|
+
if (!value || typeof value !== 'object')
|
|
7
|
+
return false;
|
|
8
|
+
const binding = value;
|
|
9
|
+
return isNonEmptyString(binding.endpoint) && isNonEmptyString(binding.subscription_id);
|
|
10
|
+
}
|
|
11
|
+
export function isWebPushWorkerState(value) {
|
|
12
|
+
if (!value || typeof value !== 'object')
|
|
13
|
+
return false;
|
|
14
|
+
const state = value;
|
|
15
|
+
if (state.status === 'uninitialized')
|
|
16
|
+
return state.binding === undefined;
|
|
17
|
+
if (state.status === 'bound') {
|
|
18
|
+
return isWebPushBinding(state.binding) && isNonEmptyString(state.revision);
|
|
19
|
+
}
|
|
20
|
+
return ((state.status === 'reconciling' || state.status === 'disabled') &&
|
|
21
|
+
isNonEmptyString(state.revision) &&
|
|
22
|
+
(state.binding === undefined || isWebPushBinding(state.binding)));
|
|
23
|
+
}
|
|
24
|
+
/** Envelope check only: tag, version, `ok`, and a well-formed state. */
|
|
25
|
+
export function isWebPushWorkerResponse(response, messageType) {
|
|
26
|
+
return (!!response &&
|
|
27
|
+
response.type === messageType &&
|
|
28
|
+
response.version === 1 &&
|
|
29
|
+
response.ok &&
|
|
30
|
+
isWebPushWorkerState(response.state));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One request/acknowledge round trip over a MessageChannel to the active service worker.
|
|
34
|
+
* `request` is sent as `{ type, version: 1, ...request }`. Which requests to send, in what
|
|
35
|
+
* order, and which acknowledged states are acceptable belong to the caller.
|
|
36
|
+
*/
|
|
37
|
+
export function requestWorkerState(registration, request, options) {
|
|
38
|
+
const worker = registration.active;
|
|
39
|
+
if (!worker)
|
|
40
|
+
return Promise.reject(new Error('The active service worker is unavailable.'));
|
|
41
|
+
const channel = new MessageChannel();
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const timeout = setTimeout(() => {
|
|
44
|
+
channel.port1.close();
|
|
45
|
+
reject(new Error('The service worker did not acknowledge the push binding request.'));
|
|
46
|
+
}, options.timeoutMs ?? DEFAULT_ACK_TIMEOUT_MS);
|
|
47
|
+
channel.port1.onmessage = (event) => {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
channel.port1.close();
|
|
50
|
+
const response = event.data;
|
|
51
|
+
if (!isWebPushWorkerResponse(response, options.messageType) ||
|
|
52
|
+
(options.accept && !options.accept(response.state))) {
|
|
53
|
+
reject(new Error(response?.error ?? 'The service worker rejected the push binding request.'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
resolve(response.state);
|
|
57
|
+
};
|
|
58
|
+
worker.postMessage({ type: options.messageType, version: 1, ...request }, [channel.port2]);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Runs `operation` under a named Web Lock; throws when the browser has no lock manager. */
|
|
62
|
+
export async function withWebLock(name, operation) {
|
|
63
|
+
const lockManager = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
|
64
|
+
if (!lockManager)
|
|
65
|
+
throw new Error('This browser cannot safely coordinate push notifications.');
|
|
66
|
+
return lockManager.request(name, operation);
|
|
67
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface VapidKeyPair {
|
|
2
|
+
/** Uncompressed P-256 point, base64url (the browser `applicationServerKey`). */
|
|
3
|
+
publicKey: string;
|
|
4
|
+
/** Raw 32-byte private scalar, base64url. */
|
|
5
|
+
privateKey: string;
|
|
6
|
+
}
|
|
7
|
+
export interface VapidJwtInput {
|
|
8
|
+
keys: VapidKeyPair;
|
|
9
|
+
/** Push service origin, for example `getPushServiceAudience(endpoint)`. */
|
|
10
|
+
audience: string;
|
|
11
|
+
/** `mailto:` or `https:` contact URI. */
|
|
12
|
+
subject: string;
|
|
13
|
+
/** Seconds since epoch; injectable for tests. */
|
|
14
|
+
now?: number;
|
|
15
|
+
/** Lifetime in seconds; RFC 8292 caps it at 24 hours. */
|
|
16
|
+
expiresInSeconds?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function generateVapidKeys(): VapidKeyPair;
|
|
19
|
+
/** Returns whether the private scalar corresponds to the public point. */
|
|
20
|
+
export declare function vapidKeysMatch(keys: VapidKeyPair): boolean;
|
|
21
|
+
/** Accepts `mailto:user@host` or an https URL with a host. */
|
|
22
|
+
export declare function isValidVapidSubject(subject: string): boolean;
|
|
23
|
+
/** Signs an ES256 VAPID JWT (RFC 8292). */
|
|
24
|
+
export declare function createVapidJwt(input: VapidJwtInput): string;
|
|
25
|
+
/** Builds the `Authorization` header value for the `vapid` scheme. */
|
|
26
|
+
export declare function createVapidAuthorization(jwt: string, publicKey: string): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
2
|
+
import { decodeBase64Url, decodeVapidPublicKey, encodeBase64Url } from './web-push.mjs';
|
|
3
|
+
const MAX_VAPID_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
4
|
+
export function generateVapidKeys() {
|
|
5
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
6
|
+
const pub = publicKey.export({ format: 'jwk' });
|
|
7
|
+
const priv = privateKey.export({ format: 'jwk' });
|
|
8
|
+
const point = new Uint8Array([
|
|
9
|
+
0x04,
|
|
10
|
+
...decodeBase64Url(pub.x),
|
|
11
|
+
...decodeBase64Url(pub.y),
|
|
12
|
+
]);
|
|
13
|
+
return { publicKey: encodeBase64Url(point), privateKey: priv.d };
|
|
14
|
+
}
|
|
15
|
+
function toPrivateKey({ publicKey, privateKey }) {
|
|
16
|
+
const point = decodeVapidPublicKey(publicKey);
|
|
17
|
+
return createPrivateKey({
|
|
18
|
+
key: {
|
|
19
|
+
kty: 'EC',
|
|
20
|
+
crv: 'P-256',
|
|
21
|
+
x: encodeBase64Url(point.slice(1, 33)),
|
|
22
|
+
y: encodeBase64Url(point.slice(33)),
|
|
23
|
+
d: privateKey,
|
|
24
|
+
},
|
|
25
|
+
format: 'jwk',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Returns whether the private scalar corresponds to the public point. */
|
|
29
|
+
export function vapidKeysMatch(keys) {
|
|
30
|
+
try {
|
|
31
|
+
// Sign/verify rather than comparing exported points: some Node/OpenSSL builds keep the
|
|
32
|
+
// imported public coordinates instead of deriving them from the private scalar.
|
|
33
|
+
const point = decodeVapidPublicKey(keys.publicKey);
|
|
34
|
+
const publicKey = createPublicKey({
|
|
35
|
+
key: {
|
|
36
|
+
kty: 'EC',
|
|
37
|
+
crv: 'P-256',
|
|
38
|
+
x: encodeBase64Url(point.slice(1, 33)),
|
|
39
|
+
y: encodeBase64Url(point.slice(33)),
|
|
40
|
+
},
|
|
41
|
+
format: 'jwk',
|
|
42
|
+
});
|
|
43
|
+
const probe = Buffer.from('vapid-key-check');
|
|
44
|
+
const signature = sign('sha256', probe, toPrivateKey(keys));
|
|
45
|
+
return verify('sha256', probe, publicKey, signature);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Accepts `mailto:user@host` or an https URL with a host. */
|
|
52
|
+
export function isValidVapidSubject(subject) {
|
|
53
|
+
const url = URL.parse(subject);
|
|
54
|
+
if (!url)
|
|
55
|
+
return false;
|
|
56
|
+
if (url.protocol === 'https:')
|
|
57
|
+
return url.hostname.length > 0;
|
|
58
|
+
return url.protocol === 'mailto:' && /^[^@\s]+@[^@\s]+$/.test(decodeURIComponent(url.pathname));
|
|
59
|
+
}
|
|
60
|
+
/** Signs an ES256 VAPID JWT (RFC 8292). */
|
|
61
|
+
export function createVapidJwt(input) {
|
|
62
|
+
const lifetime = input.expiresInSeconds ?? MAX_VAPID_LIFETIME_SECONDS;
|
|
63
|
+
if (!(lifetime > 0) || lifetime > MAX_VAPID_LIFETIME_SECONDS) {
|
|
64
|
+
throw new RangeError('VAPID JWT lifetime must be between 1 second and 24 hours');
|
|
65
|
+
}
|
|
66
|
+
if (!isValidVapidSubject(input.subject)) {
|
|
67
|
+
throw new TypeError('VAPID subject must be a valid mailto: address or https: URL');
|
|
68
|
+
}
|
|
69
|
+
const now = input.now ?? Math.floor(Date.now() / 1000);
|
|
70
|
+
const encode = (value) => encodeBase64Url(new TextEncoder().encode(JSON.stringify(value)));
|
|
71
|
+
const signingInput = `${encode({ typ: 'JWT', alg: 'ES256' })}.${encode({
|
|
72
|
+
aud: input.audience,
|
|
73
|
+
exp: now + lifetime,
|
|
74
|
+
sub: input.subject,
|
|
75
|
+
})}`;
|
|
76
|
+
const signature = sign('sha256', Buffer.from(signingInput), {
|
|
77
|
+
key: toPrivateKey(input.keys),
|
|
78
|
+
dsaEncoding: 'ieee-p1363',
|
|
79
|
+
});
|
|
80
|
+
return `${signingInput}.${encodeBase64Url(signature)}`;
|
|
81
|
+
}
|
|
82
|
+
/** Builds the `Authorization` header value for the `vapid` scheme. */
|
|
83
|
+
export function createVapidAuthorization(jwt, publicKey) {
|
|
84
|
+
decodeVapidPublicKey(publicKey);
|
|
85
|
+
return `vapid t=${jwt}, k=${publicKey}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface WebPushSubscriptionJson {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
expirationTime: number | null;
|
|
4
|
+
keys: {
|
|
5
|
+
p256dh: string;
|
|
6
|
+
auth: string;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export interface WebPushSubscriptionOptions {
|
|
10
|
+
/** Extra endpoint policy, for example a push-service host allowlist. Defaults to any https URL. */
|
|
11
|
+
allowEndpoint?: (url: URL) => boolean;
|
|
12
|
+
maxEndpointLength?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface WebPushPayload {
|
|
15
|
+
title: string;
|
|
16
|
+
body?: string;
|
|
17
|
+
url?: string;
|
|
18
|
+
icon?: string;
|
|
19
|
+
tag?: string;
|
|
20
|
+
data?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
/** RFC 8291 section 4: the largest plaintext every push service must accept (4096 - 103). */
|
|
23
|
+
export declare const MAX_WEB_PUSH_PAYLOAD_BYTES = 3993;
|
|
24
|
+
/** Decodes unpadded or padded base64url; throws TypeError on invalid characters. */
|
|
25
|
+
export declare function decodeBase64Url(value: string): Uint8Array;
|
|
26
|
+
export declare function encodeBase64Url(bytes: Uint8Array): string;
|
|
27
|
+
/** Validates a VAPID public key (uncompressed P-256 point) and returns `applicationServerKey` bytes. */
|
|
28
|
+
export declare function decodeVapidPublicKey(key: string): Uint8Array;
|
|
29
|
+
export declare function getPushServiceAudience(endpoint: string): string;
|
|
30
|
+
/** Validates the JSON shape of `PushSubscription.toJSON()`; throws TypeError when invalid. */
|
|
31
|
+
export declare function parseWebPushSubscription(value: unknown, options?: WebPushSubscriptionOptions): WebPushSubscriptionJson;
|
|
32
|
+
export declare function isWebPushSubscription(value: unknown, options?: WebPushSubscriptionOptions): value is WebPushSubscriptionJson;
|
|
33
|
+
/** Serializes a payload to UTF-8 JSON bytes, rejecting payloads over `maxBytes`. */
|
|
34
|
+
export declare function buildWebPushPayload(payload: WebPushPayload, maxBytes?: number): Uint8Array;
|
|
35
|
+
/** Parses payload text inside a service worker; returns null for anything malformed. */
|
|
36
|
+
export declare function parseWebPushPayload(text: string): WebPushPayload | null;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** RFC 8291 section 4: the largest plaintext every push service must accept (4096 - 103). */
|
|
2
|
+
export const MAX_WEB_PUSH_PAYLOAD_BYTES = 3993;
|
|
3
|
+
const P256DH_BYTES = 65;
|
|
4
|
+
const AUTH_BYTES = 16;
|
|
5
|
+
/** Decodes unpadded or padded base64url; throws TypeError on invalid characters. */
|
|
6
|
+
export function decodeBase64Url(value) {
|
|
7
|
+
if (!/^[A-Za-z0-9_-]*={0,2}$/.test(value))
|
|
8
|
+
throw new TypeError('Invalid base64url string');
|
|
9
|
+
const base64 = value.replace(/=+$/, '').replaceAll('-', '+').replaceAll('_', '/');
|
|
10
|
+
if (base64.length % 4 === 1)
|
|
11
|
+
throw new TypeError('Invalid base64url string');
|
|
12
|
+
return Uint8Array.from(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')), (c) => c.charCodeAt(0));
|
|
13
|
+
}
|
|
14
|
+
export function encodeBase64Url(bytes) {
|
|
15
|
+
let binary = '';
|
|
16
|
+
for (const byte of bytes)
|
|
17
|
+
binary += String.fromCharCode(byte);
|
|
18
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
19
|
+
}
|
|
20
|
+
/** Validates a VAPID public key (uncompressed P-256 point) and returns `applicationServerKey` bytes. */
|
|
21
|
+
export function decodeVapidPublicKey(key) {
|
|
22
|
+
const bytes = decodeBase64Url(key);
|
|
23
|
+
if (bytes.length !== P256DH_BYTES || bytes[0] !== 0x04) {
|
|
24
|
+
throw new TypeError('VAPID public key must be an uncompressed P-256 point');
|
|
25
|
+
}
|
|
26
|
+
return bytes;
|
|
27
|
+
}
|
|
28
|
+
export function getPushServiceAudience(endpoint) {
|
|
29
|
+
return new URL(endpoint).origin;
|
|
30
|
+
}
|
|
31
|
+
function keyOfLength(value, length, name) {
|
|
32
|
+
if (typeof value !== 'string')
|
|
33
|
+
throw new TypeError(`Push subscription ${name} must be a string`);
|
|
34
|
+
if (decodeBase64Url(value).length !== length) {
|
|
35
|
+
throw new TypeError(`Push subscription ${name} has an invalid length`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
/** Validates the JSON shape of `PushSubscription.toJSON()`; throws TypeError when invalid. */
|
|
40
|
+
export function parseWebPushSubscription(value, options = {}) {
|
|
41
|
+
if (typeof value !== 'object' || value === null) {
|
|
42
|
+
throw new TypeError('Push subscription must be an object');
|
|
43
|
+
}
|
|
44
|
+
const { endpoint, expirationTime, keys } = value;
|
|
45
|
+
if (typeof endpoint !== 'string' || endpoint.length > (options.maxEndpointLength ?? 2048)) {
|
|
46
|
+
throw new TypeError('Push subscription endpoint is invalid');
|
|
47
|
+
}
|
|
48
|
+
const url = URL.parse(endpoint);
|
|
49
|
+
if (!url || url.protocol !== 'https:' || (options.allowEndpoint && !options.allowEndpoint(url))) {
|
|
50
|
+
throw new TypeError('Push subscription endpoint is not allowed');
|
|
51
|
+
}
|
|
52
|
+
if (expirationTime != null && (typeof expirationTime !== 'number' || !(expirationTime >= 0))) {
|
|
53
|
+
throw new TypeError('Push subscription expirationTime is invalid');
|
|
54
|
+
}
|
|
55
|
+
if (typeof keys !== 'object' || keys === null) {
|
|
56
|
+
throw new TypeError('Push subscription keys are required');
|
|
57
|
+
}
|
|
58
|
+
const { p256dh, auth } = keys;
|
|
59
|
+
return {
|
|
60
|
+
endpoint,
|
|
61
|
+
expirationTime: expirationTime ?? null,
|
|
62
|
+
keys: {
|
|
63
|
+
p256dh: keyOfLength(p256dh, P256DH_BYTES, 'p256dh'),
|
|
64
|
+
auth: keyOfLength(auth, AUTH_BYTES, 'auth'),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function isWebPushSubscription(value, options) {
|
|
69
|
+
try {
|
|
70
|
+
parseWebPushSubscription(value, options);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Serializes a payload to UTF-8 JSON bytes, rejecting payloads over `maxBytes`. */
|
|
78
|
+
export function buildWebPushPayload(payload, maxBytes = MAX_WEB_PUSH_PAYLOAD_BYTES) {
|
|
79
|
+
if (!payload.title)
|
|
80
|
+
throw new TypeError('Push payload title is required');
|
|
81
|
+
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
82
|
+
if (bytes.length > maxBytes)
|
|
83
|
+
throw new RangeError(`Push payload exceeds ${maxBytes} bytes`);
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
86
|
+
function optionalString(value) {
|
|
87
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Parses payload text inside a service worker; returns null for anything malformed. */
|
|
90
|
+
export function parseWebPushPayload(text) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
99
|
+
return null;
|
|
100
|
+
const record = parsed;
|
|
101
|
+
const title = optionalString(record.title);
|
|
102
|
+
if (!title)
|
|
103
|
+
return null;
|
|
104
|
+
const { data } = record;
|
|
105
|
+
const isData = typeof data === 'object' && data !== null && !Array.isArray(data);
|
|
106
|
+
return {
|
|
107
|
+
title,
|
|
108
|
+
...(optionalString(record.body) ? { body: record.body } : {}),
|
|
109
|
+
...(optionalString(record.url) ? { url: record.url } : {}),
|
|
110
|
+
...(optionalString(record.icon) ? { icon: record.icon } : {}),
|
|
111
|
+
...(optionalString(record.tag) ? { tag: record.tag } : {}),
|
|
112
|
+
...(isData ? { data: data } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|