@perkamo/browser 0.1.1 → 0.1.2
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/CHANGELOG.md
CHANGED
|
@@ -13,3 +13,9 @@
|
|
|
13
13
|
### Changed
|
|
14
14
|
|
|
15
15
|
- Add a browser-safe client, JSON-safe profile helpers, stream-token support and progress widget helpers for public web integrations.
|
|
16
|
+
|
|
17
|
+
## 0.1.2 - 2026-06-02
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Add standalone CDN browser builds for script-tag integrations and document backend-plus-frontend browser SDK setup.
|
package/README.md
CHANGED
|
@@ -5,12 +5,53 @@ Browser-safe Perkamo client and lightweight widget helpers.
|
|
|
5
5
|
Use this package only with short-lived tokens minted by your own backend. Never
|
|
6
6
|
put a Perkamo server API key in browser, mobile, or embedded widget code.
|
|
7
7
|
|
|
8
|
+
Full SDK documentation: https://www.perkamo.com/docs/v1/sdk
|
|
9
|
+
|
|
8
10
|
```bash
|
|
9
11
|
npm install @perkamo/browser
|
|
10
12
|
```
|
|
11
13
|
|
|
14
|
+
For storefronts or widgets without a bundler, load the standalone browser build
|
|
15
|
+
from the free jsDelivr npm CDN. The `@latest` npm dist-tag resolves to the
|
|
16
|
+
current published `@perkamo/browser` version:
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<script src="https://cdn.jsdelivr.net/npm/@perkamo/browser@latest/dist/perkamo-browser.global.min.js"></script>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The CDN build exposes `window.PerkamoBrowser`. For production storefronts that
|
|
23
|
+
need fully repeatable builds, pin the resolved npm version after testing. UNPKG
|
|
24
|
+
serves the same npm package as an alternative CDN:
|
|
25
|
+
`https://unpkg.com/@perkamo/browser@latest/dist/perkamo-browser.global.min.js`.
|
|
26
|
+
|
|
12
27
|
## Client
|
|
13
28
|
|
|
29
|
+
Browser integrations are always a backend plus frontend implementation. The
|
|
30
|
+
frontend calls your own backend token route first; that backend route verifies
|
|
31
|
+
the user's application session and returns a short-lived browser token.
|
|
32
|
+
`/api/perkamo/token` in the examples is your application route, not a Perkamo API
|
|
33
|
+
route.
|
|
34
|
+
|
|
35
|
+
Example customer backend route:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// createPerkamoBrowserToken represents the token-minting helper or implementation
|
|
39
|
+
// configured for your Perkamo browser-token integration.
|
|
40
|
+
app.post("/api/perkamo/token", requireUserSession, async (req, res) => {
|
|
41
|
+
const token = await createPerkamoBrowserToken({
|
|
42
|
+
userId: req.user.id,
|
|
43
|
+
tenant: "commerce-test",
|
|
44
|
+
scopes: ["profile:read", "events:write"],
|
|
45
|
+
events: ["page.viewed", "product.viewed"],
|
|
46
|
+
expiresInSeconds: 600,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
res.json({ token });
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Frontend setup with a bundler:
|
|
54
|
+
|
|
14
55
|
```ts
|
|
15
56
|
import { createPerkamoBrowserClient } from "@perkamo/browser";
|
|
16
57
|
|
|
@@ -32,6 +73,26 @@ const profile = await perkamo.getProfileJson();
|
|
|
32
73
|
document.querySelector("#points").textContent = String(profile.wallets.points ?? 0);
|
|
33
74
|
```
|
|
34
75
|
|
|
76
|
+
Frontend setup with the CDN build:
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
<script src="https://cdn.jsdelivr.net/npm/@perkamo/browser@latest/dist/perkamo-browser.global.min.js"></script>
|
|
80
|
+
<script>
|
|
81
|
+
const perkamo = PerkamoBrowser.createPerkamoBrowserClient({
|
|
82
|
+
baseUrl: "https://api.perkamo.com",
|
|
83
|
+
tenant: "commerce-test",
|
|
84
|
+
getToken: () =>
|
|
85
|
+
fetch("/api/perkamo/token", { method: "POST", credentials: "include" })
|
|
86
|
+
.then((response) => response.json())
|
|
87
|
+
.then((body) => body.token),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
perkamo.getProfileJson().then((profile) => {
|
|
91
|
+
document.querySelector("#points").textContent = String(profile.wallets.points ?? 0);
|
|
92
|
+
});
|
|
93
|
+
</script>
|
|
94
|
+
```
|
|
95
|
+
|
|
35
96
|
The client calls browser-token routes:
|
|
36
97
|
|
|
37
98
|
- `POST /v1/client/events`
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var PerkamoBrowser = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
PerkamoApiError: () => PerkamoApiError,
|
|
25
|
+
createPerkamoBrowserClient: () => createPerkamoBrowserClient,
|
|
26
|
+
mountPerkamoProgressWidget: () => mountPerkamoProgressWidget,
|
|
27
|
+
toProfileJson: () => toProfileJson
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ../sdk-core/dist/index.js
|
|
31
|
+
var PerkamoApiError = class extends Error {
|
|
32
|
+
status;
|
|
33
|
+
body;
|
|
34
|
+
constructor(status, body) {
|
|
35
|
+
const message = body != null && typeof body === "object" && "message" in body ? String(body.message) : `HTTP ${status}`;
|
|
36
|
+
super(`Perkamo API error: ${message}`);
|
|
37
|
+
this.name = "PerkamoApiError";
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.body = body;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var reservedContextKeys = /* @__PURE__ */ new Set([
|
|
43
|
+
"xp",
|
|
44
|
+
"wallet",
|
|
45
|
+
"wallets",
|
|
46
|
+
"rewards",
|
|
47
|
+
"level",
|
|
48
|
+
"perks",
|
|
49
|
+
"achievements"
|
|
50
|
+
]);
|
|
51
|
+
function createTransactionId(prefix = "tx") {
|
|
52
|
+
if (!globalThis.crypto?.randomUUID) {
|
|
53
|
+
throw new Error("Secure randomUUID support is required to create transaction IDs");
|
|
54
|
+
}
|
|
55
|
+
return `${prefix}_${globalThis.crypto.randomUUID()}`;
|
|
56
|
+
}
|
|
57
|
+
function assertSafeEventInput(input) {
|
|
58
|
+
const required = [
|
|
59
|
+
["tenant", input.tenant, 128],
|
|
60
|
+
["user_id", input.user_id, 256],
|
|
61
|
+
["event", input.event, 128],
|
|
62
|
+
["transaction_id", input.transaction_id, 256]
|
|
63
|
+
];
|
|
64
|
+
for (const [field, value, maxLen] of required) {
|
|
65
|
+
if (!value || typeof value !== "string") {
|
|
66
|
+
throw new Error(`Perkamo event field '${field}' is required and must be a string`);
|
|
67
|
+
}
|
|
68
|
+
if (value.length > maxLen) {
|
|
69
|
+
throw new Error(`Perkamo event field '${field}' exceeds max length of ${maxLen} characters`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const key of Object.keys(input.context ?? {})) {
|
|
73
|
+
if (reservedContextKeys.has(key.toLowerCase())) {
|
|
74
|
+
throw new Error(`Refusing to send reserved Perkamo context field '${key}' from the SDK \u2014 Perkamo computes this value server-side from your rules`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/index.ts
|
|
80
|
+
function requireFetch(customFetch) {
|
|
81
|
+
const fetcher = customFetch ?? globalThis.fetch;
|
|
82
|
+
if (!fetcher) {
|
|
83
|
+
throw new Error("@perkamo/browser requires fetch support");
|
|
84
|
+
}
|
|
85
|
+
return fetcher;
|
|
86
|
+
}
|
|
87
|
+
function assertNoServerSecrets(options) {
|
|
88
|
+
for (const key of ["apiKey", "serverApiKey", "secret", "signingSecret"]) {
|
|
89
|
+
if (key in options) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`@perkamo/browser does not accept '${key}'. Mint short-lived browser tokens on your backend and pass getToken instead.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function assertSecureBaseUrl(baseUrl, allowInsecureHttp) {
|
|
97
|
+
const url = new URL(baseUrl);
|
|
98
|
+
if (url.protocol === "https:") return;
|
|
99
|
+
const localHosts = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
100
|
+
if (url.protocol === "http:" && (allowInsecureHttp || localHosts.has(url.hostname))) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw new Error(
|
|
104
|
+
"@perkamo/browser requires an HTTPS baseUrl. Use allowInsecureHttp only for local development."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
function requireDocument() {
|
|
108
|
+
if (!globalThis.document) {
|
|
109
|
+
throw new Error("Perkamo widgets require a browser document");
|
|
110
|
+
}
|
|
111
|
+
return globalThis.document;
|
|
112
|
+
}
|
|
113
|
+
function requireEventSource() {
|
|
114
|
+
if (!globalThis.EventSource) {
|
|
115
|
+
throw new Error("Perkamo profile streams require EventSource support");
|
|
116
|
+
}
|
|
117
|
+
return globalThis.EventSource;
|
|
118
|
+
}
|
|
119
|
+
function fetchWithTimeout(fetcher, input, init, timeoutMs) {
|
|
120
|
+
if (!timeoutMs) return fetcher(input, init);
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
123
|
+
return fetcher(input, { ...init, signal: controller.signal }).finally(
|
|
124
|
+
() => clearTimeout(timer)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
async function throwIfError(response) {
|
|
128
|
+
if (response.ok) return;
|
|
129
|
+
let body;
|
|
130
|
+
try {
|
|
131
|
+
body = await response.json();
|
|
132
|
+
} catch {
|
|
133
|
+
body = null;
|
|
134
|
+
}
|
|
135
|
+
throw new PerkamoApiError(response.status, body);
|
|
136
|
+
}
|
|
137
|
+
async function authorizationHeader(getToken) {
|
|
138
|
+
const token = await getToken();
|
|
139
|
+
if (!token.trim()) {
|
|
140
|
+
throw new Error("@perkamo/browser getToken returned an empty token");
|
|
141
|
+
}
|
|
142
|
+
return `Bearer ${token}`;
|
|
143
|
+
}
|
|
144
|
+
function cloneJsonValue(value) {
|
|
145
|
+
return JSON.parse(JSON.stringify(value));
|
|
146
|
+
}
|
|
147
|
+
function optionalJsonObject(value) {
|
|
148
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
149
|
+
return cloneJsonValue(value);
|
|
150
|
+
}
|
|
151
|
+
function toProfileJson(profile, options = {}) {
|
|
152
|
+
return {
|
|
153
|
+
user_id: profile.user_id,
|
|
154
|
+
...options.includeTraits ? { traits: optionalJsonObject(profile.traits) ?? {} } : {},
|
|
155
|
+
wallets: cloneJsonValue(profile.wallets),
|
|
156
|
+
level: cloneJsonValue(profile.level),
|
|
157
|
+
...profile.progress ? { progress: cloneJsonValue(profile.progress) } : {},
|
|
158
|
+
perks: cloneJsonValue(profile.perks),
|
|
159
|
+
next_perks: cloneJsonValue(profile.next_perks),
|
|
160
|
+
achievements: cloneJsonValue(profile.achievements),
|
|
161
|
+
flags: cloneJsonValue(profile.flags)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function createPerkamoBrowserClient(options) {
|
|
165
|
+
assertNoServerSecrets(options);
|
|
166
|
+
assertSecureBaseUrl(options.baseUrl, options.allowInsecureHttp === true);
|
|
167
|
+
const fetcher = requireFetch(options.fetch);
|
|
168
|
+
const baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
169
|
+
async function go(input, init) {
|
|
170
|
+
return fetchWithTimeout(fetcher, input, init, options.timeoutMs);
|
|
171
|
+
}
|
|
172
|
+
async function headers(withBody = false) {
|
|
173
|
+
return {
|
|
174
|
+
...withBody ? { "content-type": "application/json" } : {},
|
|
175
|
+
authorization: await authorizationHeader(options.getToken)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function getProfile() {
|
|
179
|
+
const response = await go(`${baseUrl}/v1/client/profile/me`, {
|
|
180
|
+
headers: await headers()
|
|
181
|
+
});
|
|
182
|
+
await throwIfError(response);
|
|
183
|
+
return response.json();
|
|
184
|
+
}
|
|
185
|
+
async function getProfileJson(profileOptions = {}) {
|
|
186
|
+
return toProfileJson(await getProfile(), profileOptions);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
async emit(event, context = {}, emitOptions = {}) {
|
|
190
|
+
const input = {
|
|
191
|
+
tenant: options.tenant,
|
|
192
|
+
user_id: "me",
|
|
193
|
+
event,
|
|
194
|
+
transaction_id: emitOptions.txId ?? createTransactionId(),
|
|
195
|
+
context,
|
|
196
|
+
occurred_at: (emitOptions.occurredAt ?? /* @__PURE__ */ new Date()).toISOString()
|
|
197
|
+
};
|
|
198
|
+
assertSafeEventInput(input);
|
|
199
|
+
const response = await go(`${baseUrl}/v1/client/events`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: await headers(true),
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
event: input.event,
|
|
204
|
+
transaction_id: input.transaction_id,
|
|
205
|
+
context: input.context,
|
|
206
|
+
occurred_at: input.occurred_at
|
|
207
|
+
})
|
|
208
|
+
});
|
|
209
|
+
await throwIfError(response);
|
|
210
|
+
return response.json();
|
|
211
|
+
},
|
|
212
|
+
getProfile,
|
|
213
|
+
profile: getProfile,
|
|
214
|
+
getProfileJson,
|
|
215
|
+
profileJson: getProfileJson,
|
|
216
|
+
subscribeProfile(onProfile, onError) {
|
|
217
|
+
if (!options.getStreamToken) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
"@perkamo/browser profile streams require getStreamToken so regular bearer tokens are not placed in URLs"
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const EventSourceCtor = requireEventSource();
|
|
223
|
+
const url = new URL(`${baseUrl}/v1/client/profile/me/stream`);
|
|
224
|
+
url.searchParams.set("tenant", options.tenant);
|
|
225
|
+
let closed = false;
|
|
226
|
+
let source = null;
|
|
227
|
+
void Promise.resolve(options.getStreamToken()).then((token) => {
|
|
228
|
+
if (closed) return;
|
|
229
|
+
if (!token.trim()) {
|
|
230
|
+
throw new Error("@perkamo/browser getStreamToken returned an empty token");
|
|
231
|
+
}
|
|
232
|
+
url.searchParams.set("token", token);
|
|
233
|
+
source = new EventSourceCtor(url.toString());
|
|
234
|
+
source.onmessage = (event) => {
|
|
235
|
+
try {
|
|
236
|
+
onProfile(JSON.parse(event.data));
|
|
237
|
+
} catch (caught) {
|
|
238
|
+
onError?.(
|
|
239
|
+
caught instanceof Error ? caught : new Error("Perkamo profile stream message could not be parsed")
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
source.onerror = (event) => onError?.(event);
|
|
244
|
+
}).catch(
|
|
245
|
+
(error) => onError?.(error instanceof Error ? error : new Error(String(error)))
|
|
246
|
+
);
|
|
247
|
+
return {
|
|
248
|
+
close() {
|
|
249
|
+
closed = true;
|
|
250
|
+
source?.close();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function resolveTarget(target) {
|
|
257
|
+
if (typeof target !== "string") return target;
|
|
258
|
+
const element = requireDocument().querySelector(target);
|
|
259
|
+
if (!element) {
|
|
260
|
+
throw new Error(`Perkamo widget target '${target}' was not found`);
|
|
261
|
+
}
|
|
262
|
+
return element;
|
|
263
|
+
}
|
|
264
|
+
function renderProfile(target, profile, labels) {
|
|
265
|
+
const level = profile.level;
|
|
266
|
+
const points = Object.entries(profile.wallets).map(([wallet, value]) => `${wallet}: ${value}`).join(" \xB7 ");
|
|
267
|
+
target.textContent = [
|
|
268
|
+
`${labels.level} ${level.level}`,
|
|
269
|
+
`${labels.points}: ${points || "0"}`,
|
|
270
|
+
`${labels.perks}: ${profile.perks.length}`
|
|
271
|
+
].join("\n");
|
|
272
|
+
}
|
|
273
|
+
function mountPerkamoProgressWidget(options) {
|
|
274
|
+
const target = resolveTarget(options.target);
|
|
275
|
+
const labels = {
|
|
276
|
+
level: options.labels?.level ?? "Level",
|
|
277
|
+
points: options.labels?.points ?? "Points",
|
|
278
|
+
perks: options.labels?.perks ?? "Perks",
|
|
279
|
+
loading: options.labels?.loading ?? "Loading Perkamo profile...",
|
|
280
|
+
error: options.labels?.error ?? "Unable to load Perkamo profile."
|
|
281
|
+
};
|
|
282
|
+
let destroyed = false;
|
|
283
|
+
async function refresh() {
|
|
284
|
+
target.textContent = labels.loading;
|
|
285
|
+
try {
|
|
286
|
+
const profile = await options.client.profile();
|
|
287
|
+
if (!destroyed) renderProfile(target, profile, labels);
|
|
288
|
+
} catch {
|
|
289
|
+
if (!destroyed) target.textContent = labels.error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
void refresh();
|
|
293
|
+
return {
|
|
294
|
+
refresh,
|
|
295
|
+
destroy() {
|
|
296
|
+
destroyed = true;
|
|
297
|
+
target.textContent = "";
|
|
298
|
+
target.innerHTML = "";
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return __toCommonJS(index_exports);
|
|
303
|
+
})();
|
|
304
|
+
//# sourceMappingURL=perkamo-browser.global.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts", "../../sdk-core/src/index.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n PerkamoApiError,\n assertSafeEventInput,\n createTransactionId,\n type EventIngestResponse,\n type PerkamoAchievements,\n type PerkamoEventContext,\n type PerkamoEventInput,\n type PerkamoLevel,\n type PerkamoLevelProgress,\n type PerkamoNextPerk,\n type PerkamoProfile,\n type PerkamoProfileFlags,\n type PerkamoUnlockedPerk,\n} from \"@perkamo/sdk-core\";\n\nexport {\n PerkamoApiError,\n type EventIngestResponse,\n type PerkamoProfile,\n} from \"@perkamo/sdk-core\";\n\nexport type BrowserTokenProvider = () => string | Promise<string>;\n\nexport type PerkamoBrowserClientOptions = {\n baseUrl: string;\n tenant: string;\n getToken: BrowserTokenProvider;\n getStreamToken?: BrowserTokenProvider;\n fetch?: typeof fetch;\n timeoutMs?: number;\n allowInsecureHttp?: boolean;\n};\n\nexport type BrowserEmitOptions = {\n txId?: string;\n occurredAt?: Date;\n};\n\nexport type JsonValue =\n | string\n | number\n | boolean\n | null\n | JsonValue[]\n | { [key: string]: JsonValue };\n\nexport type JsonObject = { [key: string]: JsonValue };\n\nexport type PerkamoProfileJsonOptions = {\n /**\n * Profile traits can contain customer-defined personal data. The browser JSON\n * helper omits them unless the integration opts in explicitly.\n */\n includeTraits?: boolean;\n};\n\nexport type PerkamoProfileJson = {\n user_id: string;\n traits?: JsonObject;\n wallets: Record<string, number>;\n level: PerkamoLevel;\n progress?: PerkamoLevelProgress;\n perks: PerkamoUnlockedPerk[];\n next_perks: PerkamoNextPerk[];\n achievements: PerkamoAchievements;\n flags: PerkamoProfileFlags;\n};\n\nexport type PerkamoBrowserClient = {\n emit(\n event: string,\n context?: PerkamoEventContext,\n options?: BrowserEmitOptions,\n ): Promise<EventIngestResponse>;\n getProfile(): Promise<PerkamoProfile>;\n profile(): Promise<PerkamoProfile>;\n getProfileJson(options?: PerkamoProfileJsonOptions): Promise<PerkamoProfileJson>;\n profileJson(options?: PerkamoProfileJsonOptions): Promise<PerkamoProfileJson>;\n subscribeProfile(\n onProfile: (profile: PerkamoProfile) => void,\n onError?: (error: Event | Error) => void,\n ): { close(): void };\n};\n\nexport type PerkamoProgressWidgetOptions = {\n client: PerkamoBrowserClient;\n target: Element | string;\n labels?: {\n level?: string;\n points?: string;\n perks?: string;\n loading?: string;\n error?: string;\n };\n};\n\nfunction requireFetch(customFetch?: typeof fetch): typeof fetch {\n const fetcher = customFetch ?? globalThis.fetch;\n if (!fetcher) {\n throw new Error(\"@perkamo/browser requires fetch support\");\n }\n return fetcher;\n}\n\nfunction assertNoServerSecrets(options: Record<string, unknown>): void {\n for (const key of [\"apiKey\", \"serverApiKey\", \"secret\", \"signingSecret\"]) {\n if (key in options) {\n throw new Error(\n `@perkamo/browser does not accept '${key}'. Mint short-lived browser tokens on your backend and pass getToken instead.`,\n );\n }\n }\n}\n\nfunction assertSecureBaseUrl(baseUrl: string, allowInsecureHttp: boolean): void {\n const url = new URL(baseUrl);\n if (url.protocol === \"https:\") return;\n\n const localHosts = new Set([\"localhost\", \"127.0.0.1\", \"::1\", \"[::1]\"]);\n if (url.protocol === \"http:\" && (allowInsecureHttp || localHosts.has(url.hostname))) {\n return;\n }\n\n throw new Error(\n \"@perkamo/browser requires an HTTPS baseUrl. Use allowInsecureHttp only for local development.\",\n );\n}\n\nfunction requireDocument(): Document {\n if (!globalThis.document) {\n throw new Error(\"Perkamo widgets require a browser document\");\n }\n return globalThis.document;\n}\n\nfunction requireEventSource(): typeof EventSource {\n if (!globalThis.EventSource) {\n throw new Error(\"Perkamo profile streams require EventSource support\");\n }\n return globalThis.EventSource;\n}\n\nfunction fetchWithTimeout(\n fetcher: typeof fetch,\n input: string,\n init: RequestInit,\n timeoutMs: number | undefined,\n): Promise<Response> {\n if (!timeoutMs) return fetcher(input, init);\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n return fetcher(input, { ...init, signal: controller.signal }).finally(() =>\n clearTimeout(timer),\n );\n}\n\nasync function throwIfError(response: Response): Promise<void> {\n if (response.ok) return;\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n body = null;\n }\n throw new PerkamoApiError(response.status, body);\n}\n\nasync function authorizationHeader(getToken: BrowserTokenProvider): Promise<string> {\n const token = await getToken();\n if (!token.trim()) {\n throw new Error(\"@perkamo/browser getToken returned an empty token\");\n }\n return `Bearer ${token}`;\n}\n\nfunction cloneJsonValue<T>(value: T): T {\n return JSON.parse(JSON.stringify(value)) as T;\n}\n\nfunction optionalJsonObject(value: unknown): JsonObject | undefined {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return undefined;\n return cloneJsonValue(value) as JsonObject;\n}\n\nexport function toProfileJson(\n profile: PerkamoProfile,\n options: PerkamoProfileJsonOptions = {},\n): PerkamoProfileJson {\n return {\n user_id: profile.user_id,\n ...(options.includeTraits\n ? { traits: optionalJsonObject(profile.traits) ?? {} }\n : {}),\n wallets: cloneJsonValue(profile.wallets),\n level: cloneJsonValue(profile.level),\n ...(profile.progress ? { progress: cloneJsonValue(profile.progress) } : {}),\n perks: cloneJsonValue(profile.perks),\n next_perks: cloneJsonValue(profile.next_perks),\n achievements: cloneJsonValue(profile.achievements),\n flags: cloneJsonValue(profile.flags),\n };\n}\n\nexport function createPerkamoBrowserClient(\n options: PerkamoBrowserClientOptions,\n): PerkamoBrowserClient {\n assertNoServerSecrets(options as Record<string, unknown>);\n assertSecureBaseUrl(options.baseUrl, options.allowInsecureHttp === true);\n const fetcher = requireFetch(options.fetch);\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n\n async function go(input: string, init: RequestInit): Promise<Response> {\n return fetchWithTimeout(fetcher, input, init, options.timeoutMs);\n }\n\n async function headers(withBody = false): Promise<Record<string, string>> {\n return {\n ...(withBody ? { \"content-type\": \"application/json\" } : {}),\n authorization: await authorizationHeader(options.getToken),\n };\n }\n\n async function getProfile() {\n const response = await go(`${baseUrl}/v1/client/profile/me`, {\n headers: await headers(),\n });\n await throwIfError(response);\n return response.json() as Promise<PerkamoProfile>;\n }\n\n async function getProfileJson(profileOptions: PerkamoProfileJsonOptions = {}) {\n return toProfileJson(await getProfile(), profileOptions);\n }\n\n return {\n async emit(event, context = {}, emitOptions = {}) {\n const input: PerkamoEventInput = {\n tenant: options.tenant,\n user_id: \"me\",\n event,\n transaction_id: emitOptions.txId ?? createTransactionId(),\n context,\n occurred_at: (emitOptions.occurredAt ?? new Date()).toISOString(),\n };\n assertSafeEventInput(input);\n const response = await go(`${baseUrl}/v1/client/events`, {\n method: \"POST\",\n headers: await headers(true),\n body: JSON.stringify({\n event: input.event,\n transaction_id: input.transaction_id,\n context: input.context,\n occurred_at: input.occurred_at,\n }),\n });\n await throwIfError(response);\n return response.json() as Promise<EventIngestResponse>;\n },\n\n getProfile,\n profile: getProfile,\n getProfileJson,\n profileJson: getProfileJson,\n\n subscribeProfile(onProfile, onError) {\n if (!options.getStreamToken) {\n throw new Error(\n \"@perkamo/browser profile streams require getStreamToken so regular bearer tokens are not placed in URLs\",\n );\n }\n const EventSourceCtor = requireEventSource();\n const url = new URL(`${baseUrl}/v1/client/profile/me/stream`);\n url.searchParams.set(\"tenant\", options.tenant);\n\n let closed = false;\n let source: EventSource | null = null;\n\n void Promise.resolve(options.getStreamToken())\n .then((token: string) => {\n if (closed) return;\n if (!token.trim()) {\n throw new Error(\"@perkamo/browser getStreamToken returned an empty token\");\n }\n url.searchParams.set(\"token\", token);\n source = new EventSourceCtor(url.toString());\n source.onmessage = (event) => {\n try {\n onProfile(JSON.parse(event.data) as PerkamoProfile);\n } catch (caught) {\n onError?.(\n caught instanceof Error\n ? caught\n : new Error(\"Perkamo profile stream message could not be parsed\"),\n );\n }\n };\n source.onerror = (event) => onError?.(event);\n })\n .catch((error: unknown) =>\n onError?.(error instanceof Error ? error : new Error(String(error))),\n );\n\n return {\n close() {\n closed = true;\n source?.close();\n },\n };\n },\n };\n}\n\nfunction resolveTarget(target: Element | string): Element {\n if (typeof target !== \"string\") return target;\n const element = requireDocument().querySelector(target);\n if (!element) {\n throw new Error(`Perkamo widget target '${target}' was not found`);\n }\n return element;\n}\n\nfunction renderProfile(\n target: Element,\n profile: PerkamoProfile,\n labels: Required<NonNullable<PerkamoProgressWidgetOptions[\"labels\"]>>,\n): void {\n const level = profile.level;\n const points = Object.entries(profile.wallets)\n .map(([wallet, value]) => `${wallet}: ${value}`)\n .join(\" \u00B7 \");\n target.textContent = [\n `${labels.level} ${level.level}`,\n `${labels.points}: ${points || \"0\"}`,\n `${labels.perks}: ${profile.perks.length}`,\n ].join(\"\\n\");\n}\n\nexport function mountPerkamoProgressWidget(options: PerkamoProgressWidgetOptions): {\n refresh(): Promise<void>;\n destroy(): void;\n} {\n const target = resolveTarget(options.target);\n const labels = {\n level: options.labels?.level ?? \"Level\",\n points: options.labels?.points ?? \"Points\",\n perks: options.labels?.perks ?? \"Perks\",\n loading: options.labels?.loading ?? \"Loading Perkamo profile...\",\n error: options.labels?.error ?? \"Unable to load Perkamo profile.\",\n };\n\n let destroyed = false;\n\n async function refresh() {\n target.textContent = labels.loading;\n try {\n const profile = await options.client.profile();\n if (!destroyed) renderProfile(target, profile, labels);\n } catch {\n if (!destroyed) target.textContent = labels.error;\n }\n }\n\n void refresh();\n\n return {\n refresh,\n destroy() {\n destroyed = true;\n target.textContent = \"\";\n target.innerHTML = \"\";\n },\n };\n}\n", "export type PerkamoContextValue =\n | string\n | number\n | boolean\n | null\n | PerkamoContextValue[]\n | { [key: string]: PerkamoContextValue };\n\nexport type PerkamoEventContext = Record<string, PerkamoContextValue>;\n\nexport type PerkamoEventInput = {\n tenant: string;\n user_id: string;\n event: string;\n transaction_id: string;\n context?: PerkamoEventContext;\n occurred_at?: string;\n};\n\n// \u2500\u2500\u2500 Response shapes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type WalletDelta = {\n wallet: string;\n amount: number;\n};\n\nexport type PerkamoLevel = {\n wallet: string;\n level: number;\n current: number;\n currentLevelAt: number;\n nextLevelAt: number | null;\n progress: number;\n};\n\nexport type PerkamoLevelProgress = {\n point_name: string;\n into_level: number;\n to_next: number;\n ratio: number;\n current_level_floor: number;\n next_level_at: number | null;\n};\n\nexport type PerkamoUnlocked = {\n type: string;\n key: string;\n name: string;\n};\n\nexport type EventIngestResponse = {\n applied: boolean;\n duplicate: boolean;\n delta: WalletDelta[];\n leveled_up: boolean;\n unlocked: PerkamoUnlocked[];\n wallet_state: Record<string, number>;\n level: PerkamoLevel | null;\n};\n\nexport type BatchIngestResponse = {\n results: EventIngestResponse[];\n};\n\nexport type PerkamoUnlockedPerk = {\n key: string;\n unlocked_at: string;\n metadata: Record<string, unknown>;\n};\n\nexport type PerkamoNextPerk = {\n key: string;\n name: string;\n description: string;\n trigger?: Record<string, unknown>;\n card?: { title: string; description: string; animation: string };\n};\n\nexport type PerkamoAchievements = {\n completed: Array<{ key: string; name: string }>;\n in_progress: Array<{ key: string; name: string; current: number; target: number }>;\n unlocked: Array<{ key: string; cycle: number }>;\n};\n\nexport type PerkamoProfileFlags = {\n levels: Record<string, boolean>;\n has_level_at_least: Record<string, boolean>;\n perks: Record<string, boolean>;\n};\n\nexport type PerkamoProfile = {\n user_id: string;\n traits?: Record<string, unknown>;\n wallets: Record<string, number>;\n level: PerkamoLevel;\n progress?: PerkamoLevelProgress;\n perks: PerkamoUnlockedPerk[];\n next_perks: PerkamoNextPerk[];\n achievements: PerkamoAchievements;\n flags: PerkamoProfileFlags;\n};\n\nexport type IdentifyResponse = {\n identified: boolean;\n user_id: string;\n registration_event: EventIngestResponse | null;\n profile: { tenant: string; user_id: string };\n};\n\nexport type RedeemRewardResponse = {\n redeemed: boolean;\n duplicate: boolean;\n reward: {\n key: string;\n name: string;\n description: string;\n cost: { wallet: string; amount: number };\n };\n delta: WalletDelta[];\n effect: Record<string, unknown>;\n wallet_state: Record<string, number>;\n level: PerkamoLevel | null;\n};\n\n// \u2500\u2500\u2500 Error \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport class PerkamoApiError extends Error {\n readonly status: number;\n readonly body: unknown;\n\n constructor(status: number, body: unknown) {\n const message =\n body != null && typeof body === \"object\" && \"message\" in body\n ? String((body as Record<string, unknown>).message)\n : `HTTP ${status}`;\n super(`Perkamo API error: ${message}`);\n this.name = \"PerkamoApiError\";\n this.status = status;\n this.body = body;\n }\n}\n\n// \u2500\u2500\u2500 Signing helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst reservedContextKeys = new Set([\n \"xp\",\n \"wallet\",\n \"wallets\",\n \"rewards\",\n \"level\",\n \"perks\",\n \"achievements\",\n]);\n\nfunction stableEntries(value: Record<string, PerkamoContextValue>) {\n return Object.entries(value).sort(([left], [right]) => left.localeCompare(right));\n}\n\nexport function canonicalJson(value: PerkamoContextValue): string {\n if (value === null || typeof value !== \"object\") {\n return JSON.stringify(value);\n }\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => canonicalJson(item)).join(\",\")}]`;\n }\n\n return `{${stableEntries(value)\n .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)\n .join(\",\")}}`;\n}\n\nexport function createTransactionId(prefix = \"tx\"): string {\n if (!globalThis.crypto?.randomUUID) {\n throw new Error(\"Secure randomUUID support is required to create transaction IDs\");\n }\n return `${prefix}_${globalThis.crypto.randomUUID()}`;\n}\n\nexport function assertSafeEventInput(input: PerkamoEventInput): void {\n const required: Array<[string, unknown, number]> = [\n [\"tenant\", input.tenant, 128],\n [\"user_id\", input.user_id, 256],\n [\"event\", input.event, 128],\n [\"transaction_id\", input.transaction_id, 256],\n ];\n\n for (const [field, value, maxLen] of required) {\n if (!value || typeof value !== \"string\") {\n throw new Error(\n `Perkamo event field '${field}' is required and must be a string`,\n );\n }\n if (value.length > maxLen) {\n throw new Error(\n `Perkamo event field '${field}' exceeds max length of ${maxLen} characters`,\n );\n }\n }\n\n for (const key of Object.keys(input.context ?? {})) {\n if (reservedContextKeys.has(key.toLowerCase())) {\n throw new Error(\n `Refusing to send reserved Perkamo context field '${key}' from the SDK \u2014 ` +\n `Perkamo computes this value server-side from your rules`,\n );\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
"use strict";var PerkamoBrowser=(()=>{var g=Object.defineProperty;var E=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var T=Object.prototype.hasOwnProperty;var S=(e,r)=>{for(var t in r)g(e,t,{get:r[t],enumerable:!0})},J=(e,r,t,o)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of x(r))!T.call(e,n)&&n!==t&&g(e,n,{get:()=>r[n],enumerable:!(o=E(r,n))||o.enumerable});return e};var O=e=>J(g({},"__esModule",{value:!0}),e);var H={};S(H,{PerkamoApiError:()=>m,createPerkamoBrowserClient:()=>A,mountPerkamoProgressWidget:()=>D,toProfileJson:()=>b});var m=class extends Error{status;body;constructor(r,t){let o=t!=null&&typeof t=="object"&&"message"in t?String(t.message):`HTTP ${r}`;super(`Perkamo API error: ${o}`),this.name="PerkamoApiError",this.status=r,this.body=t}},$=new Set(["xp","wallet","wallets","rewards","level","perks","achievements"]);function h(e="tx"){if(!globalThis.crypto?.randomUUID)throw new Error("Secure randomUUID support is required to create transaction IDs");return`${e}_${globalThis.crypto.randomUUID()}`}function y(e){let r=[["tenant",e.tenant,128],["user_id",e.user_id,256],["event",e.event,128],["transaction_id",e.transaction_id,256]];for(let[t,o,n]of r){if(!o||typeof o!="string")throw new Error(`Perkamo event field '${t}' is required and must be a string`);if(o.length>n)throw new Error(`Perkamo event field '${t}' exceeds max length of ${n} characters`)}for(let t of Object.keys(e.context??{}))if($.has(t.toLowerCase()))throw new Error(`Refusing to send reserved Perkamo context field '${t}' from the SDK \u2014 Perkamo computes this value server-side from your rules`)}function I(e){let r=e??globalThis.fetch;if(!r)throw new Error("@perkamo/browser requires fetch support");return r}function j(e){for(let r of["apiKey","serverApiKey","secret","signingSecret"])if(r in e)throw new Error(`@perkamo/browser does not accept '${r}'. Mint short-lived browser tokens on your backend and pass getToken instead.`)}function U(e,r){let t=new URL(e);if(t.protocol==="https:")return;let o=new Set(["localhost","127.0.0.1","::1","[::1]"]);if(!(t.protocol==="http:"&&(r||o.has(t.hostname))))throw new Error("@perkamo/browser requires an HTTPS baseUrl. Use allowInsecureHttp only for local development.")}function R(){if(!globalThis.document)throw new Error("Perkamo widgets require a browser document");return globalThis.document}function _(){if(!globalThis.EventSource)throw new Error("Perkamo profile streams require EventSource support");return globalThis.EventSource}function C(e,r,t,o){if(!o)return e(r,t);let n=new AbortController,i=setTimeout(()=>n.abort(),o);return e(r,{...t,signal:n.signal}).finally(()=>clearTimeout(i))}async function v(e){if(e.ok)return;let r;try{r=await e.json()}catch{r=null}throw new m(e.status,r)}async function q(e){let r=await e();if(!r.trim())throw new Error("@perkamo/browser getToken returned an empty token");return`Bearer ${r}`}function l(e){return JSON.parse(JSON.stringify(e))}function B(e){if(!(!e||typeof e!="object"||Array.isArray(e)))return l(e)}function b(e,r={}){return{user_id:e.user_id,...r.includeTraits?{traits:B(e.traits)??{}}:{},wallets:l(e.wallets),level:l(e.level),...e.progress?{progress:l(e.progress)}:{},perks:l(e.perks),next_perks:l(e.next_perks),achievements:l(e.achievements),flags:l(e.flags)}}function A(e){j(e),U(e.baseUrl,e.allowInsecureHttp===!0);let r=I(e.fetch),t=e.baseUrl.replace(/\/$/,"");async function o(s,c){return C(r,s,c,e.timeoutMs)}async function n(s=!1){return{...s?{"content-type":"application/json"}:{},authorization:await q(e.getToken)}}async function i(){let s=await o(`${t}/v1/client/profile/me`,{headers:await n()});return await v(s),s.json()}async function p(s={}){return b(await i(),s)}return{async emit(s,c={},P={}){let a={tenant:e.tenant,user_id:"me",event:s,transaction_id:P.txId??h(),context:c,occurred_at:(P.occurredAt??new Date).toISOString()};y(a);let f=await o(`${t}/v1/client/events`,{method:"POST",headers:await n(!0),body:JSON.stringify({event:a.event,transaction_id:a.transaction_id,context:a.context,occurred_at:a.occurred_at})});return await v(f),f.json()},getProfile:i,profile:i,getProfileJson:p,profileJson:p,subscribeProfile(s,c){if(!e.getStreamToken)throw new Error("@perkamo/browser profile streams require getStreamToken so regular bearer tokens are not placed in URLs");let P=_(),a=new URL(`${t}/v1/client/profile/me/stream`);a.searchParams.set("tenant",e.tenant);let f=!1,k=null;return Promise.resolve(e.getStreamToken()).then(u=>{if(!f){if(!u.trim())throw new Error("@perkamo/browser getStreamToken returned an empty token");a.searchParams.set("token",u),k=new P(a.toString()),k.onmessage=d=>{try{s(JSON.parse(d.data))}catch(w){c?.(w instanceof Error?w:new Error("Perkamo profile stream message could not be parsed"))}},k.onerror=d=>c?.(d)}}).catch(u=>c?.(u instanceof Error?u:new Error(String(u)))),{close(){f=!0,k?.close()}}}}}function L(e){if(typeof e!="string")return e;let r=R().querySelector(e);if(!r)throw new Error(`Perkamo widget target '${e}' was not found`);return r}function N(e,r,t){let o=r.level,n=Object.entries(r.wallets).map(([i,p])=>`${i}: ${p}`).join(" \xB7 ");e.textContent=[`${t.level} ${o.level}`,`${t.points}: ${n||"0"}`,`${t.perks}: ${r.perks.length}`].join(`
|
|
2
|
+
`)}function D(e){let r=L(e.target),t={level:e.labels?.level??"Level",points:e.labels?.points??"Points",perks:e.labels?.perks??"Perks",loading:e.labels?.loading??"Loading Perkamo profile...",error:e.labels?.error??"Unable to load Perkamo profile."},o=!1;async function n(){r.textContent=t.loading;try{let i=await e.client.profile();o||N(r,i,t)}catch{o||(r.textContent=t.error)}}return n(),{refresh:n,destroy(){o=!0,r.textContent="",r.innerHTML=""}}}return O(H);})();
|
|
3
|
+
//# sourceMappingURL=perkamo-browser.global.min.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts", "../../sdk-core/src/index.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n PerkamoApiError,\n assertSafeEventInput,\n createTransactionId,\n type EventIngestResponse,\n type PerkamoAchievements,\n type PerkamoEventContext,\n type PerkamoEventInput,\n type PerkamoLevel,\n type PerkamoLevelProgress,\n type PerkamoNextPerk,\n type PerkamoProfile,\n type PerkamoProfileFlags,\n type PerkamoUnlockedPerk,\n} from \"@perkamo/sdk-core\";\n\nexport {\n PerkamoApiError,\n type EventIngestResponse,\n type PerkamoProfile,\n} from \"@perkamo/sdk-core\";\n\nexport type BrowserTokenProvider = () => string | Promise<string>;\n\nexport type PerkamoBrowserClientOptions = {\n baseUrl: string;\n tenant: string;\n getToken: BrowserTokenProvider;\n getStreamToken?: BrowserTokenProvider;\n fetch?: typeof fetch;\n timeoutMs?: number;\n allowInsecureHttp?: boolean;\n};\n\nexport type BrowserEmitOptions = {\n txId?: string;\n occurredAt?: Date;\n};\n\nexport type JsonValue =\n | string\n | number\n | boolean\n | null\n | JsonValue[]\n | { [key: string]: JsonValue };\n\nexport type JsonObject = { [key: string]: JsonValue };\n\nexport type PerkamoProfileJsonOptions = {\n /**\n * Profile traits can contain customer-defined personal data. The browser JSON\n * helper omits them unless the integration opts in explicitly.\n */\n includeTraits?: boolean;\n};\n\nexport type PerkamoProfileJson = {\n user_id: string;\n traits?: JsonObject;\n wallets: Record<string, number>;\n level: PerkamoLevel;\n progress?: PerkamoLevelProgress;\n perks: PerkamoUnlockedPerk[];\n next_perks: PerkamoNextPerk[];\n achievements: PerkamoAchievements;\n flags: PerkamoProfileFlags;\n};\n\nexport type PerkamoBrowserClient = {\n emit(\n event: string,\n context?: PerkamoEventContext,\n options?: BrowserEmitOptions,\n ): Promise<EventIngestResponse>;\n getProfile(): Promise<PerkamoProfile>;\n profile(): Promise<PerkamoProfile>;\n getProfileJson(options?: PerkamoProfileJsonOptions): Promise<PerkamoProfileJson>;\n profileJson(options?: PerkamoProfileJsonOptions): Promise<PerkamoProfileJson>;\n subscribeProfile(\n onProfile: (profile: PerkamoProfile) => void,\n onError?: (error: Event | Error) => void,\n ): { close(): void };\n};\n\nexport type PerkamoProgressWidgetOptions = {\n client: PerkamoBrowserClient;\n target: Element | string;\n labels?: {\n level?: string;\n points?: string;\n perks?: string;\n loading?: string;\n error?: string;\n };\n};\n\nfunction requireFetch(customFetch?: typeof fetch): typeof fetch {\n const fetcher = customFetch ?? globalThis.fetch;\n if (!fetcher) {\n throw new Error(\"@perkamo/browser requires fetch support\");\n }\n return fetcher;\n}\n\nfunction assertNoServerSecrets(options: Record<string, unknown>): void {\n for (const key of [\"apiKey\", \"serverApiKey\", \"secret\", \"signingSecret\"]) {\n if (key in options) {\n throw new Error(\n `@perkamo/browser does not accept '${key}'. Mint short-lived browser tokens on your backend and pass getToken instead.`,\n );\n }\n }\n}\n\nfunction assertSecureBaseUrl(baseUrl: string, allowInsecureHttp: boolean): void {\n const url = new URL(baseUrl);\n if (url.protocol === \"https:\") return;\n\n const localHosts = new Set([\"localhost\", \"127.0.0.1\", \"::1\", \"[::1]\"]);\n if (url.protocol === \"http:\" && (allowInsecureHttp || localHosts.has(url.hostname))) {\n return;\n }\n\n throw new Error(\n \"@perkamo/browser requires an HTTPS baseUrl. Use allowInsecureHttp only for local development.\",\n );\n}\n\nfunction requireDocument(): Document {\n if (!globalThis.document) {\n throw new Error(\"Perkamo widgets require a browser document\");\n }\n return globalThis.document;\n}\n\nfunction requireEventSource(): typeof EventSource {\n if (!globalThis.EventSource) {\n throw new Error(\"Perkamo profile streams require EventSource support\");\n }\n return globalThis.EventSource;\n}\n\nfunction fetchWithTimeout(\n fetcher: typeof fetch,\n input: string,\n init: RequestInit,\n timeoutMs: number | undefined,\n): Promise<Response> {\n if (!timeoutMs) return fetcher(input, init);\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n return fetcher(input, { ...init, signal: controller.signal }).finally(() =>\n clearTimeout(timer),\n );\n}\n\nasync function throwIfError(response: Response): Promise<void> {\n if (response.ok) return;\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n body = null;\n }\n throw new PerkamoApiError(response.status, body);\n}\n\nasync function authorizationHeader(getToken: BrowserTokenProvider): Promise<string> {\n const token = await getToken();\n if (!token.trim()) {\n throw new Error(\"@perkamo/browser getToken returned an empty token\");\n }\n return `Bearer ${token}`;\n}\n\nfunction cloneJsonValue<T>(value: T): T {\n return JSON.parse(JSON.stringify(value)) as T;\n}\n\nfunction optionalJsonObject(value: unknown): JsonObject | undefined {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return undefined;\n return cloneJsonValue(value) as JsonObject;\n}\n\nexport function toProfileJson(\n profile: PerkamoProfile,\n options: PerkamoProfileJsonOptions = {},\n): PerkamoProfileJson {\n return {\n user_id: profile.user_id,\n ...(options.includeTraits\n ? { traits: optionalJsonObject(profile.traits) ?? {} }\n : {}),\n wallets: cloneJsonValue(profile.wallets),\n level: cloneJsonValue(profile.level),\n ...(profile.progress ? { progress: cloneJsonValue(profile.progress) } : {}),\n perks: cloneJsonValue(profile.perks),\n next_perks: cloneJsonValue(profile.next_perks),\n achievements: cloneJsonValue(profile.achievements),\n flags: cloneJsonValue(profile.flags),\n };\n}\n\nexport function createPerkamoBrowserClient(\n options: PerkamoBrowserClientOptions,\n): PerkamoBrowserClient {\n assertNoServerSecrets(options as Record<string, unknown>);\n assertSecureBaseUrl(options.baseUrl, options.allowInsecureHttp === true);\n const fetcher = requireFetch(options.fetch);\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n\n async function go(input: string, init: RequestInit): Promise<Response> {\n return fetchWithTimeout(fetcher, input, init, options.timeoutMs);\n }\n\n async function headers(withBody = false): Promise<Record<string, string>> {\n return {\n ...(withBody ? { \"content-type\": \"application/json\" } : {}),\n authorization: await authorizationHeader(options.getToken),\n };\n }\n\n async function getProfile() {\n const response = await go(`${baseUrl}/v1/client/profile/me`, {\n headers: await headers(),\n });\n await throwIfError(response);\n return response.json() as Promise<PerkamoProfile>;\n }\n\n async function getProfileJson(profileOptions: PerkamoProfileJsonOptions = {}) {\n return toProfileJson(await getProfile(), profileOptions);\n }\n\n return {\n async emit(event, context = {}, emitOptions = {}) {\n const input: PerkamoEventInput = {\n tenant: options.tenant,\n user_id: \"me\",\n event,\n transaction_id: emitOptions.txId ?? createTransactionId(),\n context,\n occurred_at: (emitOptions.occurredAt ?? new Date()).toISOString(),\n };\n assertSafeEventInput(input);\n const response = await go(`${baseUrl}/v1/client/events`, {\n method: \"POST\",\n headers: await headers(true),\n body: JSON.stringify({\n event: input.event,\n transaction_id: input.transaction_id,\n context: input.context,\n occurred_at: input.occurred_at,\n }),\n });\n await throwIfError(response);\n return response.json() as Promise<EventIngestResponse>;\n },\n\n getProfile,\n profile: getProfile,\n getProfileJson,\n profileJson: getProfileJson,\n\n subscribeProfile(onProfile, onError) {\n if (!options.getStreamToken) {\n throw new Error(\n \"@perkamo/browser profile streams require getStreamToken so regular bearer tokens are not placed in URLs\",\n );\n }\n const EventSourceCtor = requireEventSource();\n const url = new URL(`${baseUrl}/v1/client/profile/me/stream`);\n url.searchParams.set(\"tenant\", options.tenant);\n\n let closed = false;\n let source: EventSource | null = null;\n\n void Promise.resolve(options.getStreamToken())\n .then((token: string) => {\n if (closed) return;\n if (!token.trim()) {\n throw new Error(\"@perkamo/browser getStreamToken returned an empty token\");\n }\n url.searchParams.set(\"token\", token);\n source = new EventSourceCtor(url.toString());\n source.onmessage = (event) => {\n try {\n onProfile(JSON.parse(event.data) as PerkamoProfile);\n } catch (caught) {\n onError?.(\n caught instanceof Error\n ? caught\n : new Error(\"Perkamo profile stream message could not be parsed\"),\n );\n }\n };\n source.onerror = (event) => onError?.(event);\n })\n .catch((error: unknown) =>\n onError?.(error instanceof Error ? error : new Error(String(error))),\n );\n\n return {\n close() {\n closed = true;\n source?.close();\n },\n };\n },\n };\n}\n\nfunction resolveTarget(target: Element | string): Element {\n if (typeof target !== \"string\") return target;\n const element = requireDocument().querySelector(target);\n if (!element) {\n throw new Error(`Perkamo widget target '${target}' was not found`);\n }\n return element;\n}\n\nfunction renderProfile(\n target: Element,\n profile: PerkamoProfile,\n labels: Required<NonNullable<PerkamoProgressWidgetOptions[\"labels\"]>>,\n): void {\n const level = profile.level;\n const points = Object.entries(profile.wallets)\n .map(([wallet, value]) => `${wallet}: ${value}`)\n .join(\" \u00B7 \");\n target.textContent = [\n `${labels.level} ${level.level}`,\n `${labels.points}: ${points || \"0\"}`,\n `${labels.perks}: ${profile.perks.length}`,\n ].join(\"\\n\");\n}\n\nexport function mountPerkamoProgressWidget(options: PerkamoProgressWidgetOptions): {\n refresh(): Promise<void>;\n destroy(): void;\n} {\n const target = resolveTarget(options.target);\n const labels = {\n level: options.labels?.level ?? \"Level\",\n points: options.labels?.points ?? \"Points\",\n perks: options.labels?.perks ?? \"Perks\",\n loading: options.labels?.loading ?? \"Loading Perkamo profile...\",\n error: options.labels?.error ?? \"Unable to load Perkamo profile.\",\n };\n\n let destroyed = false;\n\n async function refresh() {\n target.textContent = labels.loading;\n try {\n const profile = await options.client.profile();\n if (!destroyed) renderProfile(target, profile, labels);\n } catch {\n if (!destroyed) target.textContent = labels.error;\n }\n }\n\n void refresh();\n\n return {\n refresh,\n destroy() {\n destroyed = true;\n target.textContent = \"\";\n target.innerHTML = \"\";\n },\n };\n}\n", "export type PerkamoContextValue =\n | string\n | number\n | boolean\n | null\n | PerkamoContextValue[]\n | { [key: string]: PerkamoContextValue };\n\nexport type PerkamoEventContext = Record<string, PerkamoContextValue>;\n\nexport type PerkamoEventInput = {\n tenant: string;\n user_id: string;\n event: string;\n transaction_id: string;\n context?: PerkamoEventContext;\n occurred_at?: string;\n};\n\n// \u2500\u2500\u2500 Response shapes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type WalletDelta = {\n wallet: string;\n amount: number;\n};\n\nexport type PerkamoLevel = {\n wallet: string;\n level: number;\n current: number;\n currentLevelAt: number;\n nextLevelAt: number | null;\n progress: number;\n};\n\nexport type PerkamoLevelProgress = {\n point_name: string;\n into_level: number;\n to_next: number;\n ratio: number;\n current_level_floor: number;\n next_level_at: number | null;\n};\n\nexport type PerkamoUnlocked = {\n type: string;\n key: string;\n name: string;\n};\n\nexport type EventIngestResponse = {\n applied: boolean;\n duplicate: boolean;\n delta: WalletDelta[];\n leveled_up: boolean;\n unlocked: PerkamoUnlocked[];\n wallet_state: Record<string, number>;\n level: PerkamoLevel | null;\n};\n\nexport type BatchIngestResponse = {\n results: EventIngestResponse[];\n};\n\nexport type PerkamoUnlockedPerk = {\n key: string;\n unlocked_at: string;\n metadata: Record<string, unknown>;\n};\n\nexport type PerkamoNextPerk = {\n key: string;\n name: string;\n description: string;\n trigger?: Record<string, unknown>;\n card?: { title: string; description: string; animation: string };\n};\n\nexport type PerkamoAchievements = {\n completed: Array<{ key: string; name: string }>;\n in_progress: Array<{ key: string; name: string; current: number; target: number }>;\n unlocked: Array<{ key: string; cycle: number }>;\n};\n\nexport type PerkamoProfileFlags = {\n levels: Record<string, boolean>;\n has_level_at_least: Record<string, boolean>;\n perks: Record<string, boolean>;\n};\n\nexport type PerkamoProfile = {\n user_id: string;\n traits?: Record<string, unknown>;\n wallets: Record<string, number>;\n level: PerkamoLevel;\n progress?: PerkamoLevelProgress;\n perks: PerkamoUnlockedPerk[];\n next_perks: PerkamoNextPerk[];\n achievements: PerkamoAchievements;\n flags: PerkamoProfileFlags;\n};\n\nexport type IdentifyResponse = {\n identified: boolean;\n user_id: string;\n registration_event: EventIngestResponse | null;\n profile: { tenant: string; user_id: string };\n};\n\nexport type RedeemRewardResponse = {\n redeemed: boolean;\n duplicate: boolean;\n reward: {\n key: string;\n name: string;\n description: string;\n cost: { wallet: string; amount: number };\n };\n delta: WalletDelta[];\n effect: Record<string, unknown>;\n wallet_state: Record<string, number>;\n level: PerkamoLevel | null;\n};\n\n// \u2500\u2500\u2500 Error \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport class PerkamoApiError extends Error {\n readonly status: number;\n readonly body: unknown;\n\n constructor(status: number, body: unknown) {\n const message =\n body != null && typeof body === \"object\" && \"message\" in body\n ? String((body as Record<string, unknown>).message)\n : `HTTP ${status}`;\n super(`Perkamo API error: ${message}`);\n this.name = \"PerkamoApiError\";\n this.status = status;\n this.body = body;\n }\n}\n\n// \u2500\u2500\u2500 Signing helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst reservedContextKeys = new Set([\n \"xp\",\n \"wallet\",\n \"wallets\",\n \"rewards\",\n \"level\",\n \"perks\",\n \"achievements\",\n]);\n\nfunction stableEntries(value: Record<string, PerkamoContextValue>) {\n return Object.entries(value).sort(([left], [right]) => left.localeCompare(right));\n}\n\nexport function canonicalJson(value: PerkamoContextValue): string {\n if (value === null || typeof value !== \"object\") {\n return JSON.stringify(value);\n }\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => canonicalJson(item)).join(\",\")}]`;\n }\n\n return `{${stableEntries(value)\n .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)\n .join(\",\")}}`;\n}\n\nexport function createTransactionId(prefix = \"tx\"): string {\n if (!globalThis.crypto?.randomUUID) {\n throw new Error(\"Secure randomUUID support is required to create transaction IDs\");\n }\n return `${prefix}_${globalThis.crypto.randomUUID()}`;\n}\n\nexport function assertSafeEventInput(input: PerkamoEventInput): void {\n const required: Array<[string, unknown, number]> = [\n [\"tenant\", input.tenant, 128],\n [\"user_id\", input.user_id, 256],\n [\"event\", input.event, 128],\n [\"transaction_id\", input.transaction_id, 256],\n ];\n\n for (const [field, value, maxLen] of required) {\n if (!value || typeof value !== \"string\") {\n throw new Error(\n `Perkamo event field '${field}' is required and must be a string`,\n );\n }\n if (value.length > maxLen) {\n throw new Error(\n `Perkamo event field '${field}' exceeds max length of ${maxLen} characters`,\n );\n }\n }\n\n for (const key of Object.keys(input.context ?? {})) {\n if (reservedContextKeys.has(key.toLowerCase())) {\n throw new Error(\n `Refusing to send reserved Perkamo context field '${key}' from the SDK \u2014 ` +\n `Perkamo computes this value server-side from your rules`,\n );\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["index_exports", "__export", "PerkamoApiError", "createPerkamoBrowserClient", "mountPerkamoProgressWidget", "toProfileJson", "PerkamoApiError", "status", "body", "message", "reservedContextKeys", "createTransactionId", "prefix", "assertSafeEventInput", "input", "required", "field", "value", "maxLen", "key", "reservedContextKeys", "requireFetch", "customFetch", "fetcher", "assertNoServerSecrets", "options", "key", "assertSecureBaseUrl", "baseUrl", "allowInsecureHttp", "url", "localHosts", "requireDocument", "requireEventSource", "fetchWithTimeout", "input", "init", "timeoutMs", "controller", "timer", "throwIfError", "response", "body", "PerkamoApiError", "authorizationHeader", "getToken", "token", "cloneJsonValue", "value", "optionalJsonObject", "toProfileJson", "profile", "createPerkamoBrowserClient", "go", "headers", "withBody", "getProfile", "getProfileJson", "profileOptions", "event", "context", "emitOptions", "createTransactionId", "assertSafeEventInput", "onProfile", "onError", "EventSourceCtor", "closed", "source", "caught", "error", "resolveTarget", "target", "element", "renderProfile", "labels", "level", "points", "wallet", "mountPerkamoProgressWidget", "destroyed", "refresh"]
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@perkamo/browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Browser-safe Perkamo client and widget helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
|
+
"browser": "dist/perkamo-browser.global.min.js",
|
|
9
|
+
"unpkg": "dist/perkamo-browser.global.min.js",
|
|
10
|
+
"jsdelivr": "dist/perkamo-browser.global.min.js",
|
|
8
11
|
"files": [
|
|
9
12
|
"dist",
|
|
10
13
|
"README.md",
|
|
@@ -25,7 +28,9 @@
|
|
|
25
28
|
"access": "public"
|
|
26
29
|
},
|
|
27
30
|
"scripts": {
|
|
28
|
-
"build": "
|
|
31
|
+
"build": "npm run build:types && npm run build:standalone",
|
|
32
|
+
"build:types": "tsc -p tsconfig.json",
|
|
33
|
+
"build:standalone": "esbuild src/index.ts --bundle --format=iife --global-name=PerkamoBrowser --target=es2022 --outfile=dist/perkamo-browser.global.js --sourcemap && esbuild src/index.ts --bundle --format=iife --global-name=PerkamoBrowser --target=es2022 --minify --outfile=dist/perkamo-browser.global.min.js --sourcemap",
|
|
29
34
|
"test": "vitest run src",
|
|
30
35
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
36
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
@@ -33,9 +38,10 @@
|
|
|
33
38
|
"format:check": "prettier --check src"
|
|
34
39
|
},
|
|
35
40
|
"dependencies": {
|
|
36
|
-
"@perkamo/sdk-core": "0.1.
|
|
41
|
+
"@perkamo/sdk-core": "0.1.2"
|
|
37
42
|
},
|
|
38
43
|
"devDependencies": {
|
|
44
|
+
"esbuild": "^0.28.0",
|
|
39
45
|
"vitest": "4.1.7"
|
|
40
46
|
}
|
|
41
47
|
}
|