@usepapier/browser 0.0.0-stage → 0.1.0
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/LICENSE +49 -0
- package/README.md +70 -2
- package/build/index.d.ts +55 -0
- package/build/index.js +139 -0
- package/build/index.js.map +1 -0
- package/build/service-worker.iife.js +1 -0
- package/package.json +51 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Papier SDK License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Papier. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This license applies to the Papier software packages that carry it, including
|
|
6
|
+
@usepapier/client, @usepapier/browser and @usepapier/cli (the "Software").
|
|
7
|
+
|
|
8
|
+
1. Grant. Subject to these terms, Papier grants you a non-exclusive,
|
|
9
|
+
non-transferable, revocable, royalty-free license to install and use the
|
|
10
|
+
Software, and to include unmodified copies of it in applications you build,
|
|
11
|
+
solely to access and use the Papier service (https://usepapier.io) under a
|
|
12
|
+
valid Papier account and the Papier terms of service.
|
|
13
|
+
|
|
14
|
+
2. Redistribution in your applications. You may distribute the Software as part
|
|
15
|
+
of your own application, in object or bundled form, provided the application
|
|
16
|
+
is not itself a software development kit, library or service that exposes or
|
|
17
|
+
substitutes for the Software or the Papier service.
|
|
18
|
+
|
|
19
|
+
3. Restrictions. You may not: (a) copy, modify, or create derivative works of
|
|
20
|
+
the Software, except to the extent applicable law grants that right
|
|
21
|
+
regardless of this restriction; (b) use the Software, or any interface it
|
|
22
|
+
documents, to build or operate a product or service that competes with
|
|
23
|
+
Papier or that emulates the Papier service; (c) sell, sublicense, rent, or
|
|
24
|
+
offer the Software as a standalone product; (d) remove or alter copyright,
|
|
25
|
+
license or attribution notices; (e) use the Software to circumvent Papier
|
|
26
|
+
plan limits, quotas, authentication, or other access controls.
|
|
27
|
+
|
|
28
|
+
4. Third-party components. The Software may include or depend on third-party
|
|
29
|
+
components that are licensed under their own terms. Those terms apply to
|
|
30
|
+
those components, and nothing in this license restricts rights granted by
|
|
31
|
+
them.
|
|
32
|
+
|
|
33
|
+
5. Ownership. Papier and its licensors retain all right, title and interest in
|
|
34
|
+
the Software. No rights are granted except those expressly stated here.
|
|
35
|
+
|
|
36
|
+
6. Termination. This license ends automatically if you breach it or when your
|
|
37
|
+
right to use the Papier service ends. On termination you must stop using the
|
|
38
|
+
Software and delete your copies.
|
|
39
|
+
|
|
40
|
+
7. No warranty. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
41
|
+
EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
42
|
+
PARTICULAR PURPOSE AND NON-INFRINGEMENT.
|
|
43
|
+
|
|
44
|
+
8. Limitation of liability. TO THE MAXIMUM EXTENT PERMITTED BY LAW, PAPIER IS
|
|
45
|
+
NOT LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES, OR FOR
|
|
46
|
+
LOSS OF DATA OR PROFITS, ARISING FROM USE OF THE SOFTWARE. Use of the Papier
|
|
47
|
+
service is governed separately by the Papier terms of service.
|
|
48
|
+
|
|
49
|
+
Questions: https://usepapier.io
|
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @usepapier/browser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Web Push for the browser, using a **publishable key**. It registers and removes push subscriptions
|
|
4
|
+
from the origins you allow and cannot send anything. Sending happens on your server with
|
|
5
|
+
[`@usepapier/client`](https://www.npmjs.com/package/@usepapier/client).
|
|
6
|
+
|
|
7
|
+
- No credentials in the page: the key is public and limited to one project, one environment and your
|
|
8
|
+
allowed origins.
|
|
9
|
+
- Binds a device to a signed-in user only with a short-lived token your server issues.
|
|
10
|
+
- Handles permission, subscription, push-key rotation and unsupported browsers with an explicit state.
|
|
11
|
+
- Small, dependency-free, ESM.
|
|
12
|
+
|
|
13
|
+
Documentation: <https://usepapier.io/docs/>
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm add @usepapier/browser
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createPapierBrowser } from "@usepapier/browser";
|
|
25
|
+
|
|
26
|
+
const papier = createPapierBrowser({ publishableKey: "papier_pk_..." });
|
|
27
|
+
|
|
28
|
+
// 1. Ask your server for a short-lived token that binds this browser to the signed-in user.
|
|
29
|
+
// (Server: `await papier.push.issueUserToken({ userId })`.)
|
|
30
|
+
// 2. Subscribe from a user gesture, such as a button click.
|
|
31
|
+
button.addEventListener("click", async () => {
|
|
32
|
+
const { token } = await fetch("/api/papier/push-token", { method: "POST" }).then((r) => r.json());
|
|
33
|
+
await papier.push.subscribe({ userToken: token });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 3. Keep the subscription current on page load (handles push key rotation).
|
|
37
|
+
await papier.push.refresh();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Service worker
|
|
41
|
+
|
|
42
|
+
Copy `node_modules/@usepapier/browser/build/service-worker.iife.js` to a file served from your
|
|
43
|
+
site's root as `/papier-sw.js` (or pass `serviceWorkerPath`). It shows the notification and opens
|
|
44
|
+
only same-origin links.
|
|
45
|
+
|
|
46
|
+
## Setup in Papier
|
|
47
|
+
|
|
48
|
+
1. Create a **Browser (publishable)** API key for one project and environment, and list your site's
|
|
49
|
+
origins (`https://app.example.com`; `http://localhost:3000` for development).
|
|
50
|
+
2. Enable push delivery through Papier for that environment.
|
|
51
|
+
3. Send from your server with a secret key.
|
|
52
|
+
|
|
53
|
+
## Security model
|
|
54
|
+
|
|
55
|
+
- The key is public. It is accepted only from the allowlisted origins and only for subscription
|
|
56
|
+
registration. Never use a secret key (`papier_...`) in a browser; the SDK refuses it.
|
|
57
|
+
- Without a user token a device is anonymous. Papier will not let a page bind a device to a user ID
|
|
58
|
+
the server did not authorise.
|
|
59
|
+
- No cookies are sent and nothing is stored in `localStorage`; state is read from the browser.
|
|
60
|
+
- iOS and iPadOS only deliver Web Push to sites added to the Home Screen.
|
|
61
|
+
|
|
62
|
+
## States and errors
|
|
63
|
+
|
|
64
|
+
`getState()` returns `unsupported`, `denied`, `prompt`, `unsubscribed` or `subscribed`. Failures are
|
|
65
|
+
`PapierBrowserError` with a `code`: `unsupported`, `permission-denied`, `invalid-key`,
|
|
66
|
+
`origin-not-allowed`, `invalid-user-token`, `rate-limited`, `service-unavailable`,
|
|
67
|
+
`service-worker`, `network`, `rejected`.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
Use of this package is governed by the Papier SDK License in `LICENSE` and your Papier terms of service.
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/push.d.ts
|
|
2
|
+
type PushState =
|
|
3
|
+
/** No service worker, Push API or Notification API. */
|
|
4
|
+
"unsupported" | "denied" |
|
|
5
|
+
/** Permission has not been requested yet. */
|
|
6
|
+
"prompt" | "unsubscribed" | "subscribed";
|
|
7
|
+
interface SubscribeOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Binds this device to an end user. Mint it on your server with `issueUserToken`; pass the string,
|
|
10
|
+
* or a function that fetches it, so a stale token is never reused.
|
|
11
|
+
*/
|
|
12
|
+
userToken?: string | (() => string | Promise<string>);
|
|
13
|
+
/** Where your site serves the Papier service worker. Defaults to `/papier-sw.js`. */
|
|
14
|
+
serviceWorkerPath?: string;
|
|
15
|
+
}
|
|
16
|
+
/** `bound` is true once the device is tied to an end user through a user token. */
|
|
17
|
+
interface RegisteredDevice {
|
|
18
|
+
id: string;
|
|
19
|
+
bound: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface PapierPush {
|
|
22
|
+
isSupported(): boolean;
|
|
23
|
+
getState(): Promise<PushState>;
|
|
24
|
+
/** Asks for permission, subscribes and registers the device. Call it from a user gesture. */
|
|
25
|
+
subscribe(options?: SubscribeOptions): Promise<RegisteredDevice>;
|
|
26
|
+
/** Re-registers an existing subscription and resubscribes when Papier rotated its push key. */
|
|
27
|
+
refresh(options?: SubscribeOptions): Promise<PushState>;
|
|
28
|
+
unsubscribe(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/errors.d.ts
|
|
32
|
+
type PapierBrowserErrorCode = "unsupported" | "permission-denied" | "invalid-key" | "origin-not-allowed" | "invalid-user-token" | "rate-limited" | "service-unavailable" | "service-worker" | "network" | "rejected";
|
|
33
|
+
/** Every failure the SDK reports. `message` never contains keys, endpoints or tokens. */
|
|
34
|
+
export declare class PapierBrowserError extends Error {
|
|
35
|
+
readonly code: PapierBrowserErrorCode;
|
|
36
|
+
readonly status?: number | undefined;
|
|
37
|
+
readonly name = "PapierBrowserError";
|
|
38
|
+
constructor(code: PapierBrowserErrorCode, message: string, status?: number | undefined);
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/index.d.ts
|
|
42
|
+
export interface PapierBrowserOptions {
|
|
43
|
+
/** A `papier_pk_...` key. Secret keys are rejected: they must never run in a browser. */
|
|
44
|
+
publishableKey: string;
|
|
45
|
+
/** Defaults to Papier's hosted API. */
|
|
46
|
+
apiUrl?: string;
|
|
47
|
+
fetch?: typeof fetch;
|
|
48
|
+
}
|
|
49
|
+
export interface PapierBrowser {
|
|
50
|
+
push: PapierPush;
|
|
51
|
+
}
|
|
52
|
+
export declare function createPapierBrowser(options: PapierBrowserOptions): PapierBrowser;
|
|
53
|
+
//#endregion
|
|
54
|
+
export type { PapierBrowserErrorCode, PapierPush, PushState, RegisteredDevice, SubscribeOptions };
|
|
55
|
+
//# sourceMappingURL=index.d.ts.map
|
package/build/index.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/** Every failure the SDK reports. `message` never contains keys, endpoints or tokens. */
|
|
3
|
+
var PapierBrowserError = class extends Error {
|
|
4
|
+
code;
|
|
5
|
+
status;
|
|
6
|
+
name = "PapierBrowserError";
|
|
7
|
+
constructor(code, message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/vapid.ts
|
|
16
|
+
/** Web Push expects the VAPID public key as bytes, but the API serves it as base64url. */
|
|
17
|
+
function base64UrlToBytes(value) {
|
|
18
|
+
const padded = value.replace(/-/gu, "+").replace(/_/gu, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
19
|
+
const binary = atob(padded);
|
|
20
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
21
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
22
|
+
return bytes;
|
|
23
|
+
}
|
|
24
|
+
function bytesEqual(a, b) {
|
|
25
|
+
if (!a || a.byteLength !== b.byteLength) return false;
|
|
26
|
+
return new Uint8Array(a).every((byte, index) => byte === b[index]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/push.ts
|
|
31
|
+
const DEFAULT_WORKER_PATH = "/papier-sw.js";
|
|
32
|
+
function createPush({ publishableKey, apiUrl, fetch: request }) {
|
|
33
|
+
const supported = () => typeof navigator !== "undefined" && "serviceWorker" in navigator && typeof PushManager !== "undefined" && typeof Notification !== "undefined";
|
|
34
|
+
async function call(path, init = {}) {
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await request(`${apiUrl}/api/public${path}`, {
|
|
38
|
+
method: init.method ?? "GET",
|
|
39
|
+
headers: {
|
|
40
|
+
authorization: `Bearer ${publishableKey}`,
|
|
41
|
+
...init.body ? { "content-type": "application/json" } : {}
|
|
42
|
+
},
|
|
43
|
+
...init.body ? { body: JSON.stringify(init.body) } : {},
|
|
44
|
+
credentials: "omit"
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
throw new PapierBrowserError("network", "Could not reach Papier");
|
|
48
|
+
}
|
|
49
|
+
if (response.ok) return response;
|
|
50
|
+
const message = (await response.json().catch(() => ({}))).message ?? `Papier request failed (HTTP ${response.status})`;
|
|
51
|
+
if (response.status === 401) throw new PapierBrowserError("invalid-key", message, 401);
|
|
52
|
+
if (response.status === 403) throw new PapierBrowserError(/origin/iu.test(message) ? "origin-not-allowed" : "invalid-user-token", message, 403);
|
|
53
|
+
if (response.status === 429) throw new PapierBrowserError("rate-limited", message, 429);
|
|
54
|
+
if (response.status === 503) throw new PapierBrowserError("service-unavailable", message, 503);
|
|
55
|
+
throw new PapierBrowserError("rejected", message, response.status);
|
|
56
|
+
}
|
|
57
|
+
async function registration(path) {
|
|
58
|
+
try {
|
|
59
|
+
await navigator.serviceWorker.register(path);
|
|
60
|
+
return await navigator.serviceWorker.ready;
|
|
61
|
+
} catch {
|
|
62
|
+
throw new PapierBrowserError("service-worker", `Could not register the service worker at ${path}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The page's registration is found by scope, so it does not depend on the worker's file name. */
|
|
66
|
+
async function existing() {
|
|
67
|
+
return (await navigator.serviceWorker.getRegistration())?.pushManager.getSubscription() ?? null;
|
|
68
|
+
}
|
|
69
|
+
async function userToken(option) {
|
|
70
|
+
return typeof option === "function" ? option() : option;
|
|
71
|
+
}
|
|
72
|
+
async function register(subscription, options) {
|
|
73
|
+
const token = await userToken(options?.userToken);
|
|
74
|
+
return await (await call("/push/subscriptions", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
body: {
|
|
77
|
+
subscription: subscription.toJSON(),
|
|
78
|
+
...token ? { userToken: token } : {}
|
|
79
|
+
}
|
|
80
|
+
})).json();
|
|
81
|
+
}
|
|
82
|
+
async function subscription(registered) {
|
|
83
|
+
const key = base64UrlToBytes((await (await call("/push/config")).json()).vapidPublicKey);
|
|
84
|
+
const current = await registered.pushManager.getSubscription();
|
|
85
|
+
if (current && bytesEqual(current.options.applicationServerKey, key)) return current;
|
|
86
|
+
await current?.unsubscribe();
|
|
87
|
+
return registered.pushManager.subscribe({
|
|
88
|
+
userVisibleOnly: true,
|
|
89
|
+
applicationServerKey: key
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
isSupported: supported,
|
|
94
|
+
async getState() {
|
|
95
|
+
if (!supported()) return "unsupported";
|
|
96
|
+
if (Notification.permission === "denied") return "denied";
|
|
97
|
+
if (Notification.permission === "default") return "prompt";
|
|
98
|
+
return await existing() ? "subscribed" : "unsubscribed";
|
|
99
|
+
},
|
|
100
|
+
async subscribe(options) {
|
|
101
|
+
if (!supported()) throw new PapierBrowserError("unsupported", "This browser does not support Web Push");
|
|
102
|
+
if ((Notification.permission === "default" ? await Notification.requestPermission() : Notification.permission) !== "granted") throw new PapierBrowserError("permission-denied", "Notification permission was not granted");
|
|
103
|
+
return register(await subscription(await registration(options?.serviceWorkerPath ?? DEFAULT_WORKER_PATH)), options);
|
|
104
|
+
},
|
|
105
|
+
async refresh(options) {
|
|
106
|
+
if (!supported()) return "unsupported";
|
|
107
|
+
if (Notification.permission !== "granted") return this.getState();
|
|
108
|
+
if (!await existing()) return "unsubscribed";
|
|
109
|
+
await register(await subscription(await registration(options?.serviceWorkerPath ?? DEFAULT_WORKER_PATH)), options);
|
|
110
|
+
return "subscribed";
|
|
111
|
+
},
|
|
112
|
+
async unsubscribe() {
|
|
113
|
+
if (!supported()) return;
|
|
114
|
+
const current = await existing();
|
|
115
|
+
if (!current) return;
|
|
116
|
+
await call("/push/subscriptions", {
|
|
117
|
+
method: "DELETE",
|
|
118
|
+
body: { endpoint: current.endpoint }
|
|
119
|
+
});
|
|
120
|
+
await current.unsubscribe();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/index.ts
|
|
127
|
+
function createPapierBrowser(options) {
|
|
128
|
+
const key = options.publishableKey;
|
|
129
|
+
if (!key.startsWith("papier_pk_")) throw new PapierBrowserError("invalid-key", key.startsWith("papier_") ? "Secret API keys must never be used in a browser. Use a publishable key (papier_pk_...)." : "Expected a publishable key starting with papier_pk_");
|
|
130
|
+
return { push: createPush({
|
|
131
|
+
publishableKey: key,
|
|
132
|
+
apiUrl: (options.apiUrl ?? "https://api.usepapier.io").replace(/\/+$/u, ""),
|
|
133
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
134
|
+
}) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
//#endregion
|
|
138
|
+
export { PapierBrowserError, createPapierBrowser };
|
|
139
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/errors.ts","../src/vapid.ts","../src/push.ts","../src/index.ts"],"sourcesContent":["export type PapierBrowserErrorCode =\n | \"unsupported\"\n | \"permission-denied\"\n | \"invalid-key\"\n | \"origin-not-allowed\"\n | \"invalid-user-token\"\n | \"rate-limited\"\n | \"service-unavailable\"\n | \"service-worker\"\n | \"network\"\n | \"rejected\";\n\n/** Every failure the SDK reports. `message` never contains keys, endpoints or tokens. */\nexport class PapierBrowserError extends Error {\n readonly name = \"PapierBrowserError\";\n constructor(\n readonly code: PapierBrowserErrorCode,\n message: string,\n readonly status?: number,\n ) {\n super(message);\n }\n}\n","/** Web Push expects the VAPID public key as bytes, but the API serves it as base64url. */\nexport function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {\n const padded = value\n .replace(/-/gu, \"+\")\n .replace(/_/gu, \"/\")\n .padEnd(Math.ceil(value.length / 4) * 4, \"=\");\n const binary = atob(padded);\n const bytes = new Uint8Array(new ArrayBuffer(binary.length));\n for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);\n return bytes;\n}\n\nexport function bytesEqual(a: ArrayBuffer | null | undefined, b: Uint8Array): boolean {\n if (!a || a.byteLength !== b.byteLength) return false;\n const view = new Uint8Array(a);\n return view.every((byte, index) => byte === b[index]);\n}\n","import { PapierBrowserError } from \"./errors\";\nimport { base64UrlToBytes, bytesEqual } from \"./vapid\";\n\nexport type PushState =\n /** No service worker, Push API or Notification API. */\n | \"unsupported\"\n | \"denied\"\n /** Permission has not been requested yet. */\n | \"prompt\"\n | \"unsubscribed\"\n | \"subscribed\";\n\nexport interface SubscribeOptions {\n /**\n * Binds this device to an end user. Mint it on your server with `issueUserToken`; pass the string,\n * or a function that fetches it, so a stale token is never reused.\n */\n userToken?: string | (() => string | Promise<string>);\n /** Where your site serves the Papier service worker. Defaults to `/papier-sw.js`. */\n serviceWorkerPath?: string;\n}\n\n/** `bound` is true once the device is tied to an end user through a user token. */\nexport interface RegisteredDevice {\n id: string;\n bound: boolean;\n}\n\nexport interface PapierPush {\n isSupported(): boolean;\n getState(): Promise<PushState>;\n /** Asks for permission, subscribes and registers the device. Call it from a user gesture. */\n subscribe(options?: SubscribeOptions): Promise<RegisteredDevice>;\n /** Re-registers an existing subscription and resubscribes when Papier rotated its push key. */\n refresh(options?: SubscribeOptions): Promise<PushState>;\n unsubscribe(): Promise<void>;\n}\n\nexport interface PushDependencies {\n publishableKey: string;\n apiUrl: string;\n fetch: typeof fetch;\n}\n\nconst DEFAULT_WORKER_PATH = \"/papier-sw.js\";\n\ninterface ApiFailure {\n code?: string;\n message?: string;\n}\n\nexport function createPush({\n publishableKey,\n apiUrl,\n fetch: request,\n}: PushDependencies): PapierPush {\n const supported = () =>\n typeof navigator !== \"undefined\" &&\n \"serviceWorker\" in navigator &&\n typeof PushManager !== \"undefined\" &&\n typeof Notification !== \"undefined\";\n\n async function call(path: string, init: { method?: string; body?: unknown } = {}) {\n let response: Response;\n try {\n response = await request(`${apiUrl}/api/public${path}`, {\n method: init.method ?? \"GET\",\n headers: {\n authorization: `Bearer ${publishableKey}`,\n ...(init.body ? { \"content-type\": \"application/json\" } : {}),\n },\n ...(init.body ? { body: JSON.stringify(init.body) } : {}),\n // The key is the credential: never send or accept cookies.\n credentials: \"omit\",\n });\n } catch {\n throw new PapierBrowserError(\"network\", \"Could not reach Papier\");\n }\n if (response.ok) return response;\n\n const failure = (await response.json().catch(() => ({}))) as ApiFailure;\n const message = failure.message ?? `Papier request failed (HTTP ${response.status})`;\n if (response.status === 401) throw new PapierBrowserError(\"invalid-key\", message, 401);\n if (response.status === 403) {\n throw new PapierBrowserError(\n /origin/iu.test(message) ? \"origin-not-allowed\" : \"invalid-user-token\",\n message,\n 403,\n );\n }\n if (response.status === 429) throw new PapierBrowserError(\"rate-limited\", message, 429);\n if (response.status === 503) throw new PapierBrowserError(\"service-unavailable\", message, 503);\n throw new PapierBrowserError(\"rejected\", message, response.status);\n }\n\n async function registration(path: string) {\n try {\n await navigator.serviceWorker.register(path);\n return await navigator.serviceWorker.ready;\n } catch {\n throw new PapierBrowserError(\n \"service-worker\",\n `Could not register the service worker at ${path}`,\n );\n }\n }\n\n /** The page's registration is found by scope, so it does not depend on the worker's file name. */\n async function existing() {\n const found = await navigator.serviceWorker.getRegistration();\n return found?.pushManager.getSubscription() ?? null;\n }\n\n async function userToken(option: SubscribeOptions[\"userToken\"]) {\n return typeof option === \"function\" ? option() : option;\n }\n\n async function register(\n subscription: PushSubscription,\n options: SubscribeOptions | undefined,\n ): Promise<RegisteredDevice> {\n const token = await userToken(options?.userToken);\n const response = await call(\"/push/subscriptions\", {\n method: \"POST\",\n body: {\n subscription: subscription.toJSON(),\n ...(token ? { userToken: token } : {}),\n },\n });\n return (await response.json()) as RegisteredDevice;\n }\n\n async function subscription(registered: ServiceWorkerRegistration) {\n const config = (await (await call(\"/push/config\")).json()) as { vapidPublicKey: string };\n const key = base64UrlToBytes(config.vapidPublicKey);\n const current = await registered.pushManager.getSubscription();\n if (current && bytesEqual(current.options.applicationServerKey, key)) return current;\n // A subscription made with a retired push key can never receive messages again.\n await current?.unsubscribe();\n return registered.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });\n }\n\n return {\n isSupported: supported,\n\n async getState() {\n if (!supported()) return \"unsupported\";\n if (Notification.permission === \"denied\") return \"denied\";\n if (Notification.permission === \"default\") return \"prompt\";\n return (await existing()) ? \"subscribed\" : \"unsubscribed\";\n },\n\n async subscribe(options) {\n if (!supported()) {\n throw new PapierBrowserError(\"unsupported\", \"This browser does not support Web Push\");\n }\n const permission =\n Notification.permission === \"default\"\n ? await Notification.requestPermission()\n : Notification.permission;\n if (permission !== \"granted\") {\n throw new PapierBrowserError(\n \"permission-denied\",\n \"Notification permission was not granted\",\n );\n }\n const registered = await registration(options?.serviceWorkerPath ?? DEFAULT_WORKER_PATH);\n return register(await subscription(registered), options);\n },\n\n async refresh(options) {\n if (!supported()) return \"unsupported\";\n if (Notification.permission !== \"granted\") return this.getState();\n if (!(await existing())) return \"unsubscribed\";\n const registered = await registration(options?.serviceWorkerPath ?? DEFAULT_WORKER_PATH);\n await register(await subscription(registered), options);\n return \"subscribed\";\n },\n\n async unsubscribe() {\n if (!supported()) return;\n const current = await existing();\n if (!current) return;\n // Tell Papier first: if the network fails the device must not be silently orphaned server-side.\n await call(\"/push/subscriptions\", { method: \"DELETE\", body: { endpoint: current.endpoint } });\n await current.unsubscribe();\n },\n };\n}\n","import { PapierBrowserError } from \"./errors\";\nimport { createPush, type PapierPush } from \"./push\";\n\nexport { PapierBrowserError, type PapierBrowserErrorCode } from \"./errors\";\nexport type { PapierPush, PushState, RegisteredDevice, SubscribeOptions } from \"./push\";\n\nexport interface PapierBrowserOptions {\n /** A `papier_pk_...` key. Secret keys are rejected: they must never run in a browser. */\n publishableKey: string;\n /** Defaults to Papier's hosted API. */\n apiUrl?: string;\n fetch?: typeof fetch;\n}\n\nexport interface PapierBrowser {\n push: PapierPush;\n}\n\nexport function createPapierBrowser(options: PapierBrowserOptions): PapierBrowser {\n const key = options.publishableKey;\n if (!key.startsWith(\"papier_pk_\")) {\n throw new PapierBrowserError(\n \"invalid-key\",\n key.startsWith(\"papier_\")\n ? \"Secret API keys must never be used in a browser. Use a publishable key (papier_pk_...).\"\n : \"Expected a publishable key starting with papier_pk_\",\n );\n }\n return {\n push: createPush({\n publishableKey: key,\n apiUrl: (options.apiUrl ?? \"https://api.usepapier.io\").replace(/\\/+$/u, \"\"),\n fetch: options.fetch ?? globalThis.fetch.bind(globalThis),\n }),\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){function e(e){let t;try{t=e.data?.json()}catch{return}if(!t||typeof t!=`object`)return;let{title:n,body:r}=t;if(!(typeof n!=`string`||!n.trim()||typeof r!=`string`))return t}self.addEventListener(`push`,t=>{let n=e(t);if(!n)return;let r=n.data&&typeof n.data==`object`&&!Array.isArray(n.data)?n.data:{};t.waitUntil(self.registration.showNotification(n.title,{body:n.body,...typeof n.image==`string`&&n.image.startsWith(`https://`)?{image:n.image}:{},...typeof r.notificationId==`string`?{tag:r.notificationId}:{},data:{...r,url:typeof n.url==`string`?n.url:`/`}}))}),self.addEventListener(`notificationclick`,e=>{e.notification.close(),e.waitUntil((async()=>{let t;try{t=new URL(String(e.notification.data?.url??`/`),self.location.origin)}catch{return}if(t.origin!==self.location.origin||![`https:`,`http:`].includes(t.protocol))return;let n=await self.clients.matchAll({type:`window`,includeUncontrolled:!0});for(let e of n)if(new URL(e.url).origin===t.origin){let n=await e.navigate(t.href);if(n){await n.focus();return}}await self.clients.openWindow(t.href)})())})})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usepapier/browser",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Papier browser SDK: Web Push subscriptions with a publishable key",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./build/index.d.ts",
|
|
10
|
+
"default": "./build/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./service-worker": "./build/service-worker.iife.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"build",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"pack": "NODE_ENV=production tsdown",
|
|
21
|
+
"tsc": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.worker.json",
|
|
22
|
+
"lint": "oxlint . --type-aware",
|
|
23
|
+
"lint:fix": "oxlint . --fix --type-aware",
|
|
24
|
+
"format": "oxfmt \"**/*.{mjs,ts,md,json}\" --check",
|
|
25
|
+
"format:fix": "oxfmt \"**/*.{mjs,ts,md,json}\" --write",
|
|
26
|
+
"test": "vitest run --config vitest.unit.config.ts",
|
|
27
|
+
"clean": "rimraf build tsconfig.tsbuildinfo"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"papier",
|
|
31
|
+
"web-push",
|
|
32
|
+
"push-notifications",
|
|
33
|
+
"service-worker",
|
|
34
|
+
"browser",
|
|
35
|
+
"vapid",
|
|
36
|
+
"notifications",
|
|
37
|
+
"sdk",
|
|
38
|
+
"typescript"
|
|
39
|
+
],
|
|
40
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@papier/tsconfig": "workspace:*",
|
|
46
|
+
"@types/node": "^26.6.5",
|
|
47
|
+
"rimraf": "^6.1.3",
|
|
48
|
+
"tsdown": "^0.23.1",
|
|
49
|
+
"vitest": "^5.0.5"
|
|
50
|
+
},
|
|
51
|
+
"author": "Papier",
|
|
52
|
+
"homepage": "https://usepapier.io/docs/"
|
|
53
|
+
}
|