@likyly/sdk 0.0.0-stage → 1.1.1

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.
Files changed (59) hide show
  1. package/README.md +286 -2
  2. package/dist/cjs/client.d.ts +70 -0
  3. package/dist/cjs/client.js +85 -0
  4. package/dist/cjs/encoding.d.ts +11 -0
  5. package/dist/cjs/encoding.js +37 -0
  6. package/dist/cjs/errors.d.ts +52 -0
  7. package/dist/cjs/errors.js +83 -0
  8. package/dist/cjs/http.d.ts +49 -0
  9. package/dist/cjs/http.js +155 -0
  10. package/dist/cjs/index.d.ts +8 -0
  11. package/dist/cjs/index.js +20 -0
  12. package/dist/cjs/package.json +1 -0
  13. package/dist/cjs/recommend-cache.d.ts +42 -0
  14. package/dist/cjs/recommend-cache.js +114 -0
  15. package/dist/cjs/resources/events.d.ts +45 -0
  16. package/dist/cjs/resources/events.js +134 -0
  17. package/dist/cjs/resources/items.d.ts +43 -0
  18. package/dist/cjs/resources/items.js +97 -0
  19. package/dist/cjs/resources/placements.d.ts +24 -0
  20. package/dist/cjs/resources/placements.js +97 -0
  21. package/dist/cjs/resources/recommendations.d.ts +54 -0
  22. package/dist/cjs/resources/recommendations.js +130 -0
  23. package/dist/cjs/resources/users.d.ts +19 -0
  24. package/dist/cjs/resources/users.js +64 -0
  25. package/dist/cjs/session.d.ts +32 -0
  26. package/dist/cjs/session.js +76 -0
  27. package/dist/cjs/types.d.ts +226 -0
  28. package/dist/cjs/types.js +2 -0
  29. package/dist/cjs/version.d.ts +3 -0
  30. package/dist/cjs/version.js +6 -0
  31. package/dist/esm/client.d.ts +70 -0
  32. package/dist/esm/client.js +81 -0
  33. package/dist/esm/encoding.d.ts +11 -0
  34. package/dist/esm/encoding.js +31 -0
  35. package/dist/esm/errors.d.ts +52 -0
  36. package/dist/esm/errors.js +69 -0
  37. package/dist/esm/http.d.ts +49 -0
  38. package/dist/esm/http.js +149 -0
  39. package/dist/esm/index.d.ts +8 -0
  40. package/dist/esm/index.js +4 -0
  41. package/dist/esm/recommend-cache.d.ts +42 -0
  42. package/dist/esm/recommend-cache.js +110 -0
  43. package/dist/esm/resources/events.d.ts +45 -0
  44. package/dist/esm/resources/events.js +130 -0
  45. package/dist/esm/resources/items.d.ts +43 -0
  46. package/dist/esm/resources/items.js +91 -0
  47. package/dist/esm/resources/placements.d.ts +24 -0
  48. package/dist/esm/resources/placements.js +93 -0
  49. package/dist/esm/resources/recommendations.d.ts +54 -0
  50. package/dist/esm/resources/recommendations.js +124 -0
  51. package/dist/esm/resources/users.d.ts +19 -0
  52. package/dist/esm/resources/users.js +60 -0
  53. package/dist/esm/session.d.ts +32 -0
  54. package/dist/esm/session.js +72 -0
  55. package/dist/esm/types.d.ts +226 -0
  56. package/dist/esm/types.js +1 -0
  57. package/dist/esm/version.d.ts +3 -0
  58. package/dist/esm/version.js +3 -0
  59. package/package.json +60 -4
@@ -0,0 +1,93 @@
1
+ import { compact, encodeSegment, requireId } from "../encoding.js";
2
+ import { toRecommendedItem } from "./recommendations.js";
3
+ import { LikylyError, NetworkError, PausedError, RateLimitError, TimeoutError } from "../errors.js";
4
+ /**
5
+ * `likyly.placements` - the recommended way to ask for recommendations. A Placement ("what to
6
+ * recommend, where, with what fallback and filters") is configured once - via the LIKYLY MCP
7
+ * admin tools or dashboard, never from this runtime SDK - and called by `slug` from here on.
8
+ * `likyly.recommendations` (the strategy-specific / signal-based API) remains available for
9
+ * advanced, per-call use with no persistent configuration.
10
+ */
11
+ export class PlacementsResource {
12
+ http;
13
+ cache;
14
+ constructor(http,
15
+ /** null when the client was built with `cache: false`. */
16
+ cache = null) {
17
+ this.http = http;
18
+ this.cache = cache;
19
+ }
20
+ /**
21
+ * Recommendations for one placement: send whatever context you have (`itemId`, `userId`,
22
+ * `sessionId`, ...) - the placement's own configuration decides the strategy, fallback and
23
+ * filters, not you. Send `recommendationId` back on the events that follow to attribute them.
24
+ */
25
+ async recommend(opts, options) {
26
+ const placement = requireId(opts.placement, "placement");
27
+ const body = compact({ context: contextBody(opts.context), limit: opts.limit });
28
+ const key = this.cache ? cacheKey(placement, body) : "";
29
+ const cached = this.cache?.get(key);
30
+ if (cached)
31
+ return cached;
32
+ if (this.cache?.isPaused()) {
33
+ throw new PausedError(`LIKYLY did not answer a moment ago: calls resume in ${this.cache.pauseRemaining()}s`);
34
+ }
35
+ try {
36
+ const res = await this.http.request({
37
+ method: "POST",
38
+ path: `/placements/${encodeSegment(placement)}/recommend`,
39
+ body,
40
+ // A read in effect, but each call mints a fresh recommendationId server-side - repeating
41
+ // it on retry is exactly what you'd want for a dropped connection, not a duplicate write.
42
+ idempotent: true,
43
+ options,
44
+ });
45
+ const result = toPlacementRecommendation(res.data);
46
+ this.cache?.recordSuccess();
47
+ this.cache?.set(key, result);
48
+ return result;
49
+ }
50
+ catch (error) {
51
+ if (isOutage(error))
52
+ this.cache?.recordFailure();
53
+ throw error;
54
+ }
55
+ }
56
+ }
57
+ function contextBody(context) {
58
+ if (!context)
59
+ return undefined;
60
+ return compact({
61
+ current_item_id: context.itemId,
62
+ category_id: context.categoryId,
63
+ collection_id: context.collectionId,
64
+ user_id: context.userId,
65
+ anonymous_id: context.anonymousId,
66
+ session_id: context.sessionId,
67
+ cart_item_ids: context.cartItemIds,
68
+ locale: context.locale,
69
+ custom_context: context.customContext,
70
+ });
71
+ }
72
+ function toPlacementRecommendation(w) {
73
+ return {
74
+ recommendationId: w.recommendation_id,
75
+ placement: w.placement,
76
+ strategyUsed: w.strategy_used,
77
+ items: w.items.map(toRecommendedItem),
78
+ };
79
+ }
80
+ /** Same placement, limit and context = same answer; who the anonymous visitor is does not count. */
81
+ function cacheKey(placement, body) {
82
+ const context = { ...(body.context ?? {}) };
83
+ delete context.session_id;
84
+ delete context.anonymous_id;
85
+ const sorted = Object.fromEntries(Object.entries(context).sort(([a], [b]) => a.localeCompare(b)));
86
+ return `${placement}|${body.limit ?? ""}|${JSON.stringify(sorted)}`;
87
+ }
88
+ /** LIKYLY down or overloaded, as opposed to a mistake in the call (4xx). */
89
+ function isOutage(error) {
90
+ if (error instanceof NetworkError || error instanceof TimeoutError || error instanceof RateLimitError)
91
+ return true;
92
+ return error instanceof LikylyError && (error.statusCode ?? 0) >= 500;
93
+ }
@@ -0,0 +1,54 @@
1
+ import type { HttpClient } from "../http.js";
2
+ import type { CollaborativeOptions, Explanation, HybridOptions, PopularOptions, RecommendationRequest, RecommendationResponse, RecommendedItem, RequestOptions, SessionOptions, SimilarOptions } from "../types.js";
3
+ /**
4
+ * `likyly.recommendations`. Use {@link get}: send what you know and LIKYLY picks the best strategy.
5
+ * The other methods are the *Advanced Recommendations* - one strategy at a time.
6
+ */
7
+ export declare class RecommendationsResource {
8
+ private readonly http;
9
+ constructor(http: HttpClient);
10
+ /**
11
+ * Recommendations for a user, an anonymous session, an item being viewed, viewed items - or
12
+ * nothing at all (then you get what is popular). You never choose the algorithm; `strategy` in the
13
+ * response says what was used. Send `recommendationId` back on the events that follow.
14
+ */
15
+ get(request?: RecommendationRequest, options?: RequestOptions): Promise<RecommendationResponse>;
16
+ /** The most popular items - the fallback for a visitor with no history at all. */
17
+ popular(opts?: PopularOptions, options?: RequestOptions): Promise<RecommendationResponse>;
18
+ /** Items similar to one item (content similarity). No user needed. */
19
+ similar(opts: SimilarOptions, options?: RequestOptions): Promise<RecommendationResponse>;
20
+ /** Collaborative filtering: what users with similar histories liked. Needs a trained model. */
21
+ collaborative(opts: CollaborativeOptions, options?: RequestOptions): Promise<RecommendationResponse>;
22
+ /** Similar items, personalized for a user. `alpha` (0-1) weighs the collaborative signal against content similarity. */
23
+ hybrid(opts: HybridOptions, options?: RequestOptions): Promise<RecommendationResponse>;
24
+ /**
25
+ * Recency-weighted recommendations from what was viewed: pass `viewedItemIds` (an explicit list,
26
+ * oldest first) **or** `userId` (LIKYLY's own history of that user's views) - exactly one.
27
+ */
28
+ session(opts: SessionOptions, options?: RequestOptions): Promise<RecommendationResponse>;
29
+ private advanced;
30
+ }
31
+ export interface WireExplanation {
32
+ reason: string;
33
+ content_similarity?: number | null;
34
+ semantic_similarity?: number | null;
35
+ popularity_score?: number | null;
36
+ interaction_count?: number | null;
37
+ interaction_label?: string | null;
38
+ collaborative_score?: number | null;
39
+ source_item_ids?: string[] | null;
40
+ similar_users?: {
41
+ user_id: string;
42
+ shared_item_ids?: string[];
43
+ }[] | null;
44
+ }
45
+ export interface WireRecommendedItem {
46
+ item_id: string;
47
+ score?: number | null;
48
+ title?: string | null;
49
+ description?: string | null;
50
+ properties?: Record<string, unknown>;
51
+ explanation?: WireExplanation | null;
52
+ }
53
+ export declare function toExplanation(w: WireExplanation): Explanation;
54
+ export declare function toRecommendedItem(w: WireRecommendedItem): RecommendedItem;
@@ -0,0 +1,124 @@
1
+ import { compact, encodeSegment, requireId, requirePathSafeId } from "../encoding.js";
2
+ import { ValidationError } from "../errors.js";
3
+ const DEFAULT_LIMIT = 10;
4
+ /**
5
+ * `likyly.recommendations`. Use {@link get}: send what you know and LIKYLY picks the best strategy.
6
+ * The other methods are the *Advanced Recommendations* - one strategy at a time.
7
+ */
8
+ export class RecommendationsResource {
9
+ http;
10
+ constructor(http) {
11
+ this.http = http;
12
+ }
13
+ /**
14
+ * Recommendations for a user, an anonymous session, an item being viewed, viewed items - or
15
+ * nothing at all (then you get what is popular). You never choose the algorithm; `strategy` in the
16
+ * response says what was used. Send `recommendationId` back on the events that follow.
17
+ */
18
+ async get(request = {}, options) {
19
+ const res = await this.http.request({
20
+ method: "POST",
21
+ path: "/getRec",
22
+ body: compact({
23
+ user_id: request.userId !== undefined ? requireId(request.userId, "userId") : undefined,
24
+ session_id: request.sessionId !== undefined ? requireId(request.sessionId, "sessionId") : undefined,
25
+ item_id: request.itemId !== undefined ? requireId(request.itemId, "itemId") : undefined,
26
+ viewed_item_ids: request.viewedItemIds?.map((id) => requireId(id, "viewedItemIds[]")),
27
+ placement: request.placement,
28
+ count: request.limit,
29
+ debug: request.debug,
30
+ }),
31
+ idempotent: true, // a read: repeating it only mints another recommendationId
32
+ options,
33
+ });
34
+ return toRecommendation(res.data);
35
+ }
36
+ // ---- Advanced Recommendations ------------------------------------------------------------
37
+ // One strategy at a time, for expert use. Ids travel in the URL path here, so they cannot contain "/".
38
+ /** The most popular items - the fallback for a visitor with no history at all. */
39
+ async popular(opts = {}, options) {
40
+ return this.advanced(`/getRec/popular/${limitOf(opts)}`, opts, {}, options);
41
+ }
42
+ /** Items similar to one item (content similarity). No user needed. */
43
+ async similar(opts, options) {
44
+ return this.advanced(`/getRec/content/${encodeSegment(requirePathSafeId(opts.itemId, "itemId"))}/${limitOf(opts)}`, opts, {}, options);
45
+ }
46
+ /** Collaborative filtering: what users with similar histories liked. Needs a trained model. */
47
+ async collaborative(opts, options) {
48
+ return this.advanced(`/getRec/collaborative/${encodeSegment(requirePathSafeId(opts.userId, "userId"))}/${limitOf(opts)}`, opts, {}, options);
49
+ }
50
+ /** Similar items, personalized for a user. `alpha` (0-1) weighs the collaborative signal against content similarity. */
51
+ async hybrid(opts, options) {
52
+ const path = `/getRec/hybrid/${encodeSegment(requirePathSafeId(opts.userId, "userId"))}/${encodeSegment(requirePathSafeId(opts.itemId, "itemId"))}/${limitOf(opts)}`;
53
+ return this.advanced(path, opts, { alpha: opts.alpha }, options);
54
+ }
55
+ /**
56
+ * Recency-weighted recommendations from what was viewed: pass `viewedItemIds` (an explicit list,
57
+ * oldest first) **or** `userId` (LIKYLY's own history of that user's views) - exactly one.
58
+ */
59
+ async session(opts, options) {
60
+ const hasList = opts.viewedItemIds !== undefined;
61
+ const hasUser = opts.userId !== undefined;
62
+ if (hasList === hasUser)
63
+ throw new ValidationError("session() needs either viewedItemIds or userId (exactly one)");
64
+ if (hasUser) {
65
+ return this.advanced(`/getRec/sessionForUser/${encodeSegment(requirePathSafeId(opts.userId, "userId"))}/${limitOf(opts)}`, opts, {}, options);
66
+ }
67
+ const ids = (opts.viewedItemIds ?? []).map((id) => requireId(id, "viewedItemIds[]"));
68
+ if (ids.length === 0)
69
+ throw new ValidationError("viewedItemIds must contain at least one item id");
70
+ if (ids.some((id) => id.includes(","))) {
71
+ throw new ValidationError('an item id containing "," cannot be sent in this endpoint\'s comma-separated list - use recommendations.get()');
72
+ }
73
+ return this.advanced("/getRec/session", opts, { viewed_item_ids: ids.join(","), count: limitOf(opts) }, options);
74
+ }
75
+ async advanced(path, opts, extra, options) {
76
+ const res = await this.http.request({
77
+ method: "GET",
78
+ path,
79
+ // response_format=object: always the same {recommendation_id, strategy, items} envelope
80
+ query: { response_format: "object", placement: opts.placement, session_id: opts.sessionId, ...extra },
81
+ idempotent: true,
82
+ options,
83
+ });
84
+ return toRecommendation(res.data);
85
+ }
86
+ }
87
+ function limitOf(opts) {
88
+ const limit = opts.limit ?? DEFAULT_LIMIT;
89
+ if (!Number.isInteger(limit) || limit < 1)
90
+ throw new ValidationError("limit must be a positive integer");
91
+ return limit;
92
+ }
93
+ const opt = (v) => (v === null || v === undefined ? undefined : v);
94
+ export function toExplanation(w) {
95
+ return {
96
+ reason: w.reason,
97
+ contentSimilarity: opt(w.content_similarity),
98
+ semanticSimilarity: opt(w.semantic_similarity),
99
+ popularityScore: opt(w.popularity_score),
100
+ interactionCount: opt(w.interaction_count),
101
+ interactionLabel: opt(w.interaction_label),
102
+ collaborativeScore: opt(w.collaborative_score),
103
+ sourceItemIds: opt(w.source_item_ids),
104
+ similarUsers: w.similar_users?.map((u) => ({ userId: u.user_id, sharedItemIds: u.shared_item_ids ?? [] })),
105
+ };
106
+ }
107
+ export function toRecommendedItem(w) {
108
+ return {
109
+ itemId: w.item_id,
110
+ score: opt(w.score),
111
+ title: opt(w.title),
112
+ description: opt(w.description),
113
+ properties: w.properties ?? {},
114
+ explanation: w.explanation ? toExplanation(w.explanation) : undefined,
115
+ };
116
+ }
117
+ function toRecommendation(w) {
118
+ return {
119
+ recommendationId: w.recommendation_id,
120
+ strategy: w.strategy,
121
+ placement: opt(w.placement),
122
+ items: w.items.map(toRecommendedItem),
123
+ };
124
+ }
@@ -0,0 +1,19 @@
1
+ import type { HttpClient } from "../http.js";
2
+ import type { BatchResult, ListOptions, RequestOptions, User, UserImport, UserInput, UserList } from "../types.js";
3
+ /**
4
+ * `likyly.users` - optional user profiles. Needs the **secret** API key (profiles are personal data).
5
+ * You don't have to create a user before sending events for them.
6
+ */
7
+ export declare class UsersResource {
8
+ private readonly http;
9
+ constructor(http: HttpClient);
10
+ get(userId: string, options?: RequestOptions): Promise<User>;
11
+ /** One page of users. Pagination is `limit` + `offset`; without `limit` the API returns every user. */
12
+ list(listOptions?: ListOptions, options?: RequestOptions): Promise<UserList>;
13
+ /** Creates or replaces the profile (idempotent). `properties` is free-form: country, segment, language, ... */
14
+ upsert(userId: string, user?: UserInput, options?: RequestOptions): Promise<User>;
15
+ /** Erases the user: the profile **and every event recorded for them**. */
16
+ delete(userId: string, options?: RequestOptions): Promise<void>;
17
+ /** Batch upsert (1-1000 users). A failing entry is reported in `errors`. */
18
+ import(users: UserImport[], options?: RequestOptions): Promise<BatchResult>;
19
+ }
@@ -0,0 +1,60 @@
1
+ import { compact, encodeSegment, requireId } from "../encoding.js";
2
+ import { ValidationError } from "../errors.js";
3
+ import { toBatch } from "./items.js";
4
+ /**
5
+ * `likyly.users` - optional user profiles. Needs the **secret** API key (profiles are personal data).
6
+ * You don't have to create a user before sending events for them.
7
+ */
8
+ export class UsersResource {
9
+ http;
10
+ constructor(http) {
11
+ this.http = http;
12
+ }
13
+ async get(userId, options) {
14
+ const res = await this.http.request({ method: "GET", path: `/users/${encodeSegment(requireId(userId, "userId"))}`, idempotent: true, options });
15
+ return toUser(res.data);
16
+ }
17
+ /** One page of users. Pagination is `limit` + `offset`; without `limit` the API returns every user. */
18
+ async list(listOptions = {}, options) {
19
+ const res = await this.http.request({
20
+ method: "GET",
21
+ path: "/users",
22
+ query: { limit: listOptions.limit, offset: listOptions.offset },
23
+ idempotent: true,
24
+ options,
25
+ });
26
+ const total = res.headers.get("x-total-count");
27
+ return { users: res.data.map(toUser), total: total === null ? undefined : Number(total), limit: listOptions.limit, offset: listOptions.offset ?? 0 };
28
+ }
29
+ /** Creates or replaces the profile (idempotent). `properties` is free-form: country, segment, language, ... */
30
+ async upsert(userId, user = {}, options) {
31
+ const res = await this.http.request({
32
+ method: "PUT",
33
+ path: `/users/${encodeSegment(requireId(userId, "userId"))}`,
34
+ body: compact({ properties: user.properties }),
35
+ idempotent: true,
36
+ options,
37
+ });
38
+ return toUser(res.data);
39
+ }
40
+ /** Erases the user: the profile **and every event recorded for them**. */
41
+ async delete(userId, options) {
42
+ await this.http.request({ method: "DELETE", path: `/users/${encodeSegment(requireId(userId, "userId"))}`, idempotent: true, options });
43
+ }
44
+ /** Batch upsert (1-1000 users). A failing entry is reported in `errors`. */
45
+ async import(users, options) {
46
+ if (!Array.isArray(users) || users.length === 0)
47
+ throw new ValidationError("users must be a non-empty array");
48
+ const res = await this.http.request({
49
+ method: "POST",
50
+ path: "/users/import",
51
+ body: { users: users.map((u) => ({ user_id: requireId(u.userId, "userId"), ...compact({ properties: u.properties }) })) },
52
+ idempotent: true,
53
+ options,
54
+ });
55
+ return toBatch(res.data);
56
+ }
57
+ }
58
+ function toUser(w) {
59
+ return { userId: w.user_id, properties: w.properties ?? {} };
60
+ }
@@ -0,0 +1,32 @@
1
+ import type { Likyly } from "./client.js";
2
+ import type { IdentifyResult } from "./types.js";
3
+ export interface LikylySessionOptions {
4
+ /** Override the localStorage key - e.g. to namespace multiple LIKYLY-powered apps on one domain. Defaults to "likyly_anonymous_id". */
5
+ storageKey?: string;
6
+ }
7
+ /**
8
+ * A first-party, cookieless anonymous visitor id - generated once per browser and persisted in
9
+ * `localStorage` (never a third-party cookie, never sent to anyone but LIKYLY). Use it as
10
+ * `context.anonymousId` / `sessionId` for recommendations and event tracking until the visitor
11
+ * is identified, then call {@link identify} to attach their history to the now-known user.
12
+ *
13
+ * SSR-safe: every method degrades to a no-op (`undefined`) outside a real browser (no
14
+ * `window`/`localStorage`, private-mode storage that throws, ...) rather than throwing - a
15
+ * server-rendered pass simply has no anonymous id yet, which is correct, not an error.
16
+ */
17
+ export declare class LikylySession {
18
+ private readonly storageKey;
19
+ private cachedId;
20
+ constructor(options?: LikylySessionOptions);
21
+ /** The visitor's anonymous id, creating and persisting one on first call. `undefined` when
22
+ * there is nowhere to persist it (SSR, Node, storage disabled). */
23
+ getAnonymousId(): string | undefined;
24
+ /**
25
+ * Call once you know the visitor's `userId` (e.g. right after login): attaches this
26
+ * session's *past* interactions to them server-side (`likyly.events.identify`), so they
27
+ * count as that user's history from then on. Safe to call more than once, and safe even if
28
+ * there is no anonymous id yet (a no-op). Both `userId` and the anonymous id remain fine to
29
+ * keep sending together afterwards - this doesn't need to be the last time you use either.
30
+ */
31
+ identify(likyly: Pick<Likyly, "events">, userId: string): Promise<IdentifyResult | undefined>;
32
+ }
@@ -0,0 +1,72 @@
1
+ const DEFAULT_STORAGE_KEY = "likyly_anonymous_id";
2
+ /**
3
+ * A first-party, cookieless anonymous visitor id - generated once per browser and persisted in
4
+ * `localStorage` (never a third-party cookie, never sent to anyone but LIKYLY). Use it as
5
+ * `context.anonymousId` / `sessionId` for recommendations and event tracking until the visitor
6
+ * is identified, then call {@link identify} to attach their history to the now-known user.
7
+ *
8
+ * SSR-safe: every method degrades to a no-op (`undefined`) outside a real browser (no
9
+ * `window`/`localStorage`, private-mode storage that throws, ...) rather than throwing - a
10
+ * server-rendered pass simply has no anonymous id yet, which is correct, not an error.
11
+ */
12
+ export class LikylySession {
13
+ storageKey;
14
+ cachedId;
15
+ constructor(options = {}) {
16
+ this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
17
+ }
18
+ /** The visitor's anonymous id, creating and persisting one on first call. `undefined` when
19
+ * there is nowhere to persist it (SSR, Node, storage disabled). */
20
+ getAnonymousId() {
21
+ if (this.cachedId)
22
+ return this.cachedId;
23
+ const storage = safeLocalStorage();
24
+ if (!storage)
25
+ return undefined;
26
+ try {
27
+ let id = storage.getItem(this.storageKey);
28
+ if (!id) {
29
+ id = generateAnonymousId();
30
+ storage.setItem(this.storageKey, id);
31
+ }
32
+ this.cachedId = id;
33
+ return id;
34
+ }
35
+ catch {
36
+ return undefined; // full/blocked storage - degrade to "no session", never throw
37
+ }
38
+ }
39
+ /**
40
+ * Call once you know the visitor's `userId` (e.g. right after login): attaches this
41
+ * session's *past* interactions to them server-side (`likyly.events.identify`), so they
42
+ * count as that user's history from then on. Safe to call more than once, and safe even if
43
+ * there is no anonymous id yet (a no-op). Both `userId` and the anonymous id remain fine to
44
+ * keep sending together afterwards - this doesn't need to be the last time you use either.
45
+ */
46
+ async identify(likyly, userId) {
47
+ const sessionId = this.getAnonymousId();
48
+ if (!sessionId)
49
+ return undefined;
50
+ return likyly.events.identify({ userId, sessionId });
51
+ }
52
+ }
53
+ function safeLocalStorage() {
54
+ try {
55
+ const storage = globalThis.localStorage;
56
+ if (!storage)
57
+ return undefined;
58
+ const probeKey = "__likyly_probe__";
59
+ storage.setItem(probeKey, "1"); // some private-mode browsers only throw on write, not on presence
60
+ storage.removeItem(probeKey);
61
+ return storage;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ function generateAnonymousId() {
68
+ const cryptoObj = globalThis.crypto;
69
+ if (typeof cryptoObj?.randomUUID === "function")
70
+ return `anon_${cryptoObj.randomUUID()}`;
71
+ return `anon_${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
72
+ }