@genlook/storefront 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/README.md +180 -0
- package/dist/analytics.d.ts +26 -0
- package/dist/analytics.js +7 -0
- package/dist/anonymous-id.d.ts +30 -0
- package/dist/anonymous-id.js +33 -0
- package/dist/client.d.ts +289 -0
- package/dist/client.js +465 -0
- package/dist/consent.d.ts +17 -0
- package/dist/consent.js +34 -0
- package/dist/create-client.d.ts +84 -0
- package/dist/create-client.js +131 -0
- package/dist/default-tracking.d.ts +64 -0
- package/dist/default-tracking.js +101 -0
- package/dist/email.d.ts +5 -0
- package/dist/email.js +24 -0
- package/dist/entities.d.ts +186 -0
- package/dist/entities.js +47 -0
- package/dist/erasure.d.ts +36 -0
- package/dist/erasure.js +23 -0
- package/dist/events.d.ts +226 -0
- package/dist/events.js +41 -0
- package/dist/fetch-transport.d.ts +65 -0
- package/dist/fetch-transport.js +112 -0
- package/dist/generation.d.ts +132 -0
- package/dist/generation.js +382 -0
- package/dist/history.d.ts +76 -0
- package/dist/history.js +68 -0
- package/dist/memory-storage.d.ts +10 -0
- package/dist/memory-storage.js +12 -0
- package/dist/pending-upload.d.ts +32 -0
- package/dist/pending-upload.js +13 -0
- package/dist/persistence.d.ts +12 -0
- package/dist/persistence.js +101 -0
- package/dist/policy.d.ts +24 -0
- package/dist/policy.js +45 -0
- package/dist/ports.d.ts +187 -0
- package/dist/ports.js +1 -0
- package/dist/public-api.d.ts +235 -0
- package/dist/public-api.js +1 -0
- package/dist/public.d.ts +33 -0
- package/dist/public.js +12 -0
- package/dist/settings.d.ts +38 -0
- package/dist/settings.js +70 -0
- package/dist/sharing.d.ts +26 -0
- package/dist/sharing.js +42 -0
- package/dist/storage-adapters.d.ts +69 -0
- package/dist/storage-adapters.js +82 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +23 -0
- package/dist/tracker.d.ts +124 -0
- package/dist/tracker.js +229 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/upload.d.ts +123 -0
- package/dist/upload.js +176 -0
- package/dist/usage.d.ts +45 -0
- package/dist/usage.js +110 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +1 -0
- package/package.json +30 -0
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { UploadRejectionReason } from "./upload";
|
|
2
|
+
/**
|
|
3
|
+
* The per-reason detail a rejection carries, spread FLAT alongside the base
|
|
4
|
+
* fields. Deliberately a CLOSED set of optional keys rather than
|
|
5
|
+
* `Record<string, unknown>`: an open index signature widens `keyof` across the
|
|
6
|
+
* whole union to `string`, which silently defeats the no-PII assertion in
|
|
7
|
+
* analytics.ts and would let any field — an address included — ride along.
|
|
8
|
+
*/
|
|
9
|
+
interface UploadRejectionExtra {
|
|
10
|
+
/** invalid_image_type */
|
|
11
|
+
allowedTypes?: readonly string[];
|
|
12
|
+
/** file_too_large */
|
|
13
|
+
maxFileSize?: number;
|
|
14
|
+
/** invalid_dimensions */
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
minWidth?: number;
|
|
18
|
+
minHeight?: number;
|
|
19
|
+
/** "dimension_read_failed" when the browser could not read the image at all.
|
|
20
|
+
* Distinct from the verdict's own `rejectionReason`. */
|
|
21
|
+
reason?: string;
|
|
22
|
+
/** invalid_dimensions_post_crop — the truncated backend message. */
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
/** The flat, BQ-ready projection of UploadMeta shared by `photo_submitted` and
|
|
26
|
+
* `photo_upload_started` — one shape so the two events can't drift. A closed
|
|
27
|
+
* interface (never an index signature): an open one would widen `keyof` and
|
|
28
|
+
* defeat the PII check in analytics.ts. */
|
|
29
|
+
export interface PhotoMetaFields {
|
|
30
|
+
fileSize: number;
|
|
31
|
+
mimeType: string;
|
|
32
|
+
uploadSource: string;
|
|
33
|
+
skipClientDimensionCheck: boolean;
|
|
34
|
+
/** The picked photo's own dimensions, when the host could read them. Carried
|
|
35
|
+
* because shape is the question behind "does this suit me": a full-length
|
|
36
|
+
* portrait and a square selfie are different inputs. Absent when unreadable
|
|
37
|
+
* or not checked. */
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
40
|
+
/** Seconds since the file was created (`File.lastModified`) at pick time —
|
|
41
|
+
* ~0 = taken in the chooser, large = an old roll photo. */
|
|
42
|
+
photo_age_s?: number;
|
|
43
|
+
/** `lastModified` fell inside the chooser's open→resolve window: the shopper
|
|
44
|
+
* took the photo via the chooser's camera path rather than picking from the
|
|
45
|
+
* roll. Host-observed (UploadMeta); absent when the host can't tell. */
|
|
46
|
+
captured_in_picker?: boolean;
|
|
47
|
+
/** Chooser open → file returned, in ms. Host-observed; absent when unknown. */
|
|
48
|
+
picker_duration_ms?: number;
|
|
49
|
+
/** The page was backgrounded while the chooser was open (Android camera path,
|
|
50
|
+
* app switches). Host-observed; absent when unknown. */
|
|
51
|
+
picker_backgrounded?: boolean;
|
|
52
|
+
/** How long the photo waited at the consent gate before release, in ms.
|
|
53
|
+
* Core-stamped on `flushStagedPhoto`; PRESENCE means this upload was held —
|
|
54
|
+
* an immediate (already-consented) upload omits it. */
|
|
55
|
+
held_duration_ms?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Everything the core announces about its own lifecycle.
|
|
59
|
+
*
|
|
60
|
+
* Discriminated on `type` so a host can switch exhaustively and the compiler
|
|
61
|
+
* catches a new member. Names are `<subject>:<past-tense>` — an event is a fact
|
|
62
|
+
* about something that already happened, never a request.
|
|
63
|
+
*/
|
|
64
|
+
export type CoreEvent =
|
|
65
|
+
/** A picked photo was handed to `stagePhoto` — the pipeline's entry edge,
|
|
66
|
+
* carrying everything known about the photo and how it was acquired. `held` is
|
|
67
|
+
* true when legal consent is still outstanding, so nothing has been sent and
|
|
68
|
+
* nothing will be until consent is accepted. Local pick-time rejections never
|
|
69
|
+
* reach the core, so the complete "chooser returned a file" edge is this event
|
|
70
|
+
* ∪ the host's local-rejection event. */
|
|
71
|
+
({
|
|
72
|
+
type: "tryon:photo_submitted";
|
|
73
|
+
held: boolean;
|
|
74
|
+
} & PhotoMetaFields)
|
|
75
|
+
/** The tracked photo was dropped (`clearPendingUpload`), in flight or still held
|
|
76
|
+
* pre-consent. */
|
|
77
|
+
| {
|
|
78
|
+
type: "tryon:photo_discarded";
|
|
79
|
+
}
|
|
80
|
+
/** Bytes are now in flight (an immediate stage, or a held photo released by
|
|
81
|
+
* consent). Always preceded by `tryon:photo_submitted`. */
|
|
82
|
+
| ({
|
|
83
|
+
type: "tryon:photo_upload_started";
|
|
84
|
+
} & PhotoMetaFields)
|
|
85
|
+
/** The upload chain completed. Read the cropped URL from
|
|
86
|
+
* `selectCurrentUpload(getState())`. */
|
|
87
|
+
| {
|
|
88
|
+
type: "tryon:photo_upload_succeeded";
|
|
89
|
+
fileId: string;
|
|
90
|
+
fileSize: number;
|
|
91
|
+
mimeType: string;
|
|
92
|
+
uploadSource: string;
|
|
93
|
+
skipClientDimensionCheck: boolean;
|
|
94
|
+
duration_ms: number;
|
|
95
|
+
}
|
|
96
|
+
/** The upload failed for a non-verdict reason (network, prepare, storage).
|
|
97
|
+
* Retryable with the same photo, unlike a rejection. */
|
|
98
|
+
| {
|
|
99
|
+
type: "tryon:photo_upload_failed";
|
|
100
|
+
fileSize: number;
|
|
101
|
+
mimeType: string;
|
|
102
|
+
uploadSource: string;
|
|
103
|
+
error: string;
|
|
104
|
+
kind: "upload_failed";
|
|
105
|
+
code: "UPLOAD_FAILED";
|
|
106
|
+
/** Connectivity when the failure was recorded. ~90% of these arrive as the
|
|
107
|
+
* browser's generic "Failed to fetch" / "Load failed", which name no cause;
|
|
108
|
+
* these separate lost signal from a backgrounded page. Absent when the host
|
|
109
|
+
* supplies no NetworkInfo port. */
|
|
110
|
+
net_online?: boolean;
|
|
111
|
+
net_effective_type?: string;
|
|
112
|
+
net_visibility?: "visible" | "hidden";
|
|
113
|
+
}
|
|
114
|
+
/** The photo was refused — pre-upload validation or the server's post-crop
|
|
115
|
+
* verdict. Terminal for this photo: the shopper must pick another. `extra` is
|
|
116
|
+
* the per-reason detail (allowedTypes / maxFileSize / width+height+min* /
|
|
117
|
+
* dimension_read_failed / truncated error), spread flat. */
|
|
118
|
+
| ({
|
|
119
|
+
type: "tryon:photo_upload_rejected";
|
|
120
|
+
fileSize: number;
|
|
121
|
+
mimeType: string;
|
|
122
|
+
uploadSource: string;
|
|
123
|
+
rejectionReason: UploadRejectionReason;
|
|
124
|
+
kind: "upload_rejected";
|
|
125
|
+
} & UploadRejectionExtra)
|
|
126
|
+
/** Legal consent was recorded. Any photo held by `stagePhoto` is released in the
|
|
127
|
+
* same tick, so `tryon:upload_started` normally follows immediately. */
|
|
128
|
+
| {
|
|
129
|
+
type: "tryon:legal_consent_accepted";
|
|
130
|
+
consent_version: string;
|
|
131
|
+
}
|
|
132
|
+
/** Accepted and started (past the policy check and the dedup window, quota
|
|
133
|
+
* counted). A repeat inside the dedup TTL does NOT re-emit. */
|
|
134
|
+
| {
|
|
135
|
+
type: "tryon:generation_started";
|
|
136
|
+
generationId: string;
|
|
137
|
+
}
|
|
138
|
+
/** Produced an image. The history entry (when the caller passed a `context`) is
|
|
139
|
+
* appended before this fires. */
|
|
140
|
+
| {
|
|
141
|
+
type: "tryon:generation_succeeded";
|
|
142
|
+
generationId: string;
|
|
143
|
+
duration_ms: number;
|
|
144
|
+
productId: string;
|
|
145
|
+
variantId?: string;
|
|
146
|
+
}
|
|
147
|
+
/** Terminal error. `kind` is the GenerationErrorKind a host switches on; `code`
|
|
148
|
+
* is the backend wire code. */
|
|
149
|
+
| {
|
|
150
|
+
type: "tryon:generation_failed";
|
|
151
|
+
generationId: string;
|
|
152
|
+
kind: string;
|
|
153
|
+
code: string;
|
|
154
|
+
}
|
|
155
|
+
/** The generate was refused before anything was spent — by the CLIENT policy
|
|
156
|
+
* layer (reasons like "quota-exceeded" / "credits-expired" / "login-required")
|
|
157
|
+
* or by the SERVER pre-entity, where reason is the raw wire code
|
|
158
|
+
* (QUOTA_EXCEEDED / BILLING_NOT_ALLOWED / RATE_LIMIT_EXCEEDED /
|
|
159
|
+
* FITTING_ROOM_WEEKLY_LIMIT_EXCEEDED). Either way nothing ran and nothing was
|
|
160
|
+
* charged (the server path refunds the counted use). */
|
|
161
|
+
| {
|
|
162
|
+
type: "tryon:generation_blocked";
|
|
163
|
+
reason: string;
|
|
164
|
+
}
|
|
165
|
+
/** A result was rated. `rating` is 1 / -1, or 0 when withdrawn. */
|
|
166
|
+
| {
|
|
167
|
+
type: "tryon:result_rated";
|
|
168
|
+
generationId: string;
|
|
169
|
+
rating: number;
|
|
170
|
+
source: string;
|
|
171
|
+
}
|
|
172
|
+
/** A structured reason was given for a rating. */
|
|
173
|
+
| {
|
|
174
|
+
type: "tryon:result_feedback_given";
|
|
175
|
+
generationId: string;
|
|
176
|
+
reason: string;
|
|
177
|
+
source: string;
|
|
178
|
+
}
|
|
179
|
+
/** A share link was minted for a result. */
|
|
180
|
+
| {
|
|
181
|
+
type: "tryon:share_link_created";
|
|
182
|
+
generationId: string;
|
|
183
|
+
entryId: string;
|
|
184
|
+
}
|
|
185
|
+
/** An email was collected at the gate. NO ADDRESS — it reaches the backend
|
|
186
|
+
* through `POST /shopper/email`, and a copy here would only be a second system
|
|
187
|
+
* to secure and to honour erasure in. */
|
|
188
|
+
| {
|
|
189
|
+
type: "tryon:email_collected";
|
|
190
|
+
emailCollectionStep: number;
|
|
191
|
+
checkboxDisplayed: boolean;
|
|
192
|
+
marketingConsent?: boolean;
|
|
193
|
+
}
|
|
194
|
+
/** A shopper-initiated erasure completed; local state is cleared either way. */
|
|
195
|
+
| {
|
|
196
|
+
type: "tryon:data_erased";
|
|
197
|
+
};
|
|
198
|
+
export type CoreEventType = CoreEvent["type"];
|
|
199
|
+
/** Narrow a CoreEvent to one member by its `type`, so `on("tryon:photo_upload_succeeded", e => …)`
|
|
200
|
+
* hands the listener a payload that already knows it has `fileId`. */
|
|
201
|
+
export type CoreEventOf<T extends CoreEventType> = Extract<CoreEvent, {
|
|
202
|
+
type: T;
|
|
203
|
+
}>;
|
|
204
|
+
export type CoreEventListener<T extends CoreEventType> = (event: CoreEventOf<T>) => void;
|
|
205
|
+
/**
|
|
206
|
+
* Minimal typed emitter. No replay — events are edges, state is level, so read
|
|
207
|
+
* `getState()` for current state. A throwing listener is swallowed and does not
|
|
208
|
+
* stop the others: a host render bug must never break a try-on.
|
|
209
|
+
*/
|
|
210
|
+
export declare class CoreEvents {
|
|
211
|
+
private readonly listeners;
|
|
212
|
+
private readonly anyListeners;
|
|
213
|
+
/** Subscribe to one event type. Returns an unsubscribe function. */
|
|
214
|
+
on<T extends CoreEventType>(type: T, listener: CoreEventListener<T>): () => void;
|
|
215
|
+
/**
|
|
216
|
+
* Subscribe to every event, current and future — for consumers generic over the
|
|
217
|
+
* event set (logging, host bridges, dev tooling). A new member of
|
|
218
|
+
* {@link CoreEvent} then needs no change here or in them.
|
|
219
|
+
*
|
|
220
|
+
* Runs after the type-specific listeners; same no-replay guarantee as {@link on}.
|
|
221
|
+
*/
|
|
222
|
+
onAny(listener: (event: CoreEvent) => void): () => void;
|
|
223
|
+
/** Announce an event to its subscribers (type-specific first, then wildcard). */
|
|
224
|
+
emit(event: CoreEvent): void;
|
|
225
|
+
}
|
|
226
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class CoreEvents {
|
|
2
|
+
listeners = new Map();
|
|
3
|
+
anyListeners = new Set();
|
|
4
|
+
on(type, listener) {
|
|
5
|
+
let set = this.listeners.get(type);
|
|
6
|
+
if (!set) {
|
|
7
|
+
set = new Set();
|
|
8
|
+
this.listeners.set(type, set);
|
|
9
|
+
}
|
|
10
|
+
const entry = listener;
|
|
11
|
+
set.add(entry);
|
|
12
|
+
return () => {
|
|
13
|
+
set.delete(entry);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
onAny(listener) {
|
|
17
|
+
this.anyListeners.add(listener);
|
|
18
|
+
return () => {
|
|
19
|
+
this.anyListeners.delete(listener);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
emit(event) {
|
|
23
|
+
const set = this.listeners.get(event.type);
|
|
24
|
+
if (set) {
|
|
25
|
+
for (const listener of [...set]) {
|
|
26
|
+
try {
|
|
27
|
+
listener(event);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const listener of [...this.anyListeners]) {
|
|
34
|
+
try {
|
|
35
|
+
listener(event);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core's default {@link Transport}: a plain `fetch` client for callers that
|
|
3
|
+
* reach `api.genlook.app` directly with a publishable key (native apps, headless
|
|
4
|
+
* or custom storefronts). `fetch` exists natively in browsers AND React Native,
|
|
5
|
+
* so one transport covers both.
|
|
6
|
+
*
|
|
7
|
+
* Storefront plugins keep their own transport (app-proxy prefix, signed params,
|
|
8
|
+
* redirect-prefix recovery); this one is the zero-wiring path for an integrator
|
|
9
|
+
* who only holds a key.
|
|
10
|
+
*/
|
|
11
|
+
import type { Transport, TransportResponse } from "./ports";
|
|
12
|
+
/** Default Genlook API origin. */
|
|
13
|
+
export declare const GENLOOK_API_BASE_URL = "https://api.genlook.app";
|
|
14
|
+
/** Default path segment the storefront endpoints are mounted under. The older
|
|
15
|
+
* `store/v1/public` prefix is still served by the backend until 2027-03-01 and
|
|
16
|
+
* can be passed as `apiPath` by an integration pinned to it. */
|
|
17
|
+
export declare const GENLOOK_STORE_API_PATH = "storefront/v1";
|
|
18
|
+
/** Init the transport hands to the host `fetch`. Structural subset of RequestInit. */
|
|
19
|
+
export interface FetchLikeInit {
|
|
20
|
+
method?: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
keepalive?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** The host `fetch`. The browser/React Native `fetch` satisfies it structurally
|
|
26
|
+
* (`Response` satisfies {@link TransportResponse}). */
|
|
27
|
+
export type FetchLike = (url: string, init?: FetchLikeInit) => Promise<TransportResponse>;
|
|
28
|
+
/** A value that may be supplied directly or resolved lazily per request. */
|
|
29
|
+
type Resolvable<T> = T | (() => T);
|
|
30
|
+
export interface FetchTransportOptions {
|
|
31
|
+
/** Publishable key (`pk_…`), sent as `Authorization: Bearer …`. */
|
|
32
|
+
publishableKey: string;
|
|
33
|
+
/** API origin. Default {@link GENLOOK_API_BASE_URL}. */
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
/** Path the public endpoints live under. Default {@link GENLOOK_STORE_API_PATH}. */
|
|
36
|
+
apiPath?: string;
|
|
37
|
+
/** Anonymous device id → `x-genlook-anonymous-id`. Lazy form supported so the
|
|
38
|
+
* transport can be built before storage hydration resolves. */
|
|
39
|
+
anonymousId?: Resolvable<string | null | undefined>;
|
|
40
|
+
/** Known shopper → `x-genlook-customer-id`. */
|
|
41
|
+
customerId?: Resolvable<string | null | undefined>;
|
|
42
|
+
/** Known shopper email → `x-genlook-customer-email`. */
|
|
43
|
+
customerEmail?: Resolvable<string | null | undefined>;
|
|
44
|
+
/** Client build id → `x-genlook-widget-version`. */
|
|
45
|
+
widgetVersion?: string;
|
|
46
|
+
/** Extra headers on every proxied request (never on `putRaw`). Also the way
|
|
47
|
+
* to relabel the channel: a host that embeds this transport under its own
|
|
48
|
+
* name overrides `x-genlook-integration` / `x-genlook-integration-version`
|
|
49
|
+
* here. */
|
|
50
|
+
headers?: Record<string, string>;
|
|
51
|
+
/** Device fingerprint, read only when a request sets `includeFingerprint`. */
|
|
52
|
+
getFingerprint?: () => string | null | undefined;
|
|
53
|
+
/** Override the host `fetch` (tests, custom agents). Default `globalThis.fetch`. */
|
|
54
|
+
fetchImpl?: FetchLike;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the default fetch transport.
|
|
58
|
+
*
|
|
59
|
+
* `fetch(path, init)` hits `{baseUrl}/{apiPath}/{path}` with the publishable key
|
|
60
|
+
* and the identity headers. `putRaw(url, body, headers)` PUTs bytes to an
|
|
61
|
+
* absolute signed storage URL and deliberately carries NO Genlook headers — the
|
|
62
|
+
* GCS signed-URL contract breaks if extra headers are signed in.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createFetchTransport(options: FetchTransportOptions): Transport;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { SDK_VERSION } from "./version";
|
|
2
|
+
export const GENLOOK_API_BASE_URL = "https://api.genlook.app";
|
|
3
|
+
export const GENLOOK_STORE_API_PATH = "storefront/v1";
|
|
4
|
+
function syntheticResponse(statusText) {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
status: 0,
|
|
8
|
+
statusText,
|
|
9
|
+
json: async () => ({}),
|
|
10
|
+
text: async () => "",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function resolve(value) {
|
|
14
|
+
return typeof value === "function" ? value() : value;
|
|
15
|
+
}
|
|
16
|
+
function joinUrl(base, path) {
|
|
17
|
+
if (!path)
|
|
18
|
+
return base;
|
|
19
|
+
const left = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
20
|
+
const right = path.startsWith("/") ? path.slice(1) : path;
|
|
21
|
+
return `${left}/${right}`;
|
|
22
|
+
}
|
|
23
|
+
function getFetch(override) {
|
|
24
|
+
if (override)
|
|
25
|
+
return override;
|
|
26
|
+
const f = globalThis.fetch;
|
|
27
|
+
return typeof f === "function" ? f.bind(globalThis) : undefined;
|
|
28
|
+
}
|
|
29
|
+
function getSendBeacon() {
|
|
30
|
+
const nav = globalThis.navigator;
|
|
31
|
+
return nav && typeof nav.sendBeacon === "function"
|
|
32
|
+
? nav.sendBeacon.bind(nav)
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
function beaconPayload(body, contentType) {
|
|
36
|
+
const BlobCtor = globalThis.Blob;
|
|
37
|
+
return BlobCtor ? new BlobCtor([body], { type: contentType }) : body;
|
|
38
|
+
}
|
|
39
|
+
export function createFetchTransport(options) {
|
|
40
|
+
const base = joinUrl(options.baseUrl ?? GENLOOK_API_BASE_URL, options.apiPath ?? GENLOOK_STORE_API_PATH);
|
|
41
|
+
const doFetch = (url, init) => {
|
|
42
|
+
const impl = getFetch(options.fetchImpl);
|
|
43
|
+
if (!impl) {
|
|
44
|
+
return Promise.reject(new Error("[Genlook] No fetch implementation available in this runtime."));
|
|
45
|
+
}
|
|
46
|
+
return impl(url, init);
|
|
47
|
+
};
|
|
48
|
+
const buildHeaders = (init) => {
|
|
49
|
+
const headers = {
|
|
50
|
+
"x-genlook-integration": "tryon_core",
|
|
51
|
+
"x-genlook-integration-version": SDK_VERSION,
|
|
52
|
+
...options.headers,
|
|
53
|
+
...init?.headers,
|
|
54
|
+
Authorization: `Bearer ${options.publishableKey}`,
|
|
55
|
+
};
|
|
56
|
+
const anonymousId = resolve(options.anonymousId);
|
|
57
|
+
if (anonymousId)
|
|
58
|
+
headers["x-genlook-anonymous-id"] = anonymousId;
|
|
59
|
+
const customerId = resolve(options.customerId);
|
|
60
|
+
if (customerId)
|
|
61
|
+
headers["x-genlook-customer-id"] = customerId;
|
|
62
|
+
const customerEmail = resolve(options.customerEmail);
|
|
63
|
+
if (customerEmail)
|
|
64
|
+
headers["x-genlook-customer-email"] = customerEmail;
|
|
65
|
+
if (options.widgetVersion) {
|
|
66
|
+
headers["x-genlook-widget-version"] = options.widgetVersion;
|
|
67
|
+
}
|
|
68
|
+
if (init?.includeFingerprint) {
|
|
69
|
+
const fingerprint = options.getFingerprint?.();
|
|
70
|
+
if (fingerprint)
|
|
71
|
+
headers["x-genlook-fingerprint"] = fingerprint;
|
|
72
|
+
}
|
|
73
|
+
return headers;
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
fetch(path, init) {
|
|
77
|
+
const url = joinUrl(base, path);
|
|
78
|
+
const headers = buildHeaders(init);
|
|
79
|
+
if (init?.beacon) {
|
|
80
|
+
const body = init.body ?? "";
|
|
81
|
+
const send = getSendBeacon();
|
|
82
|
+
if (send) {
|
|
83
|
+
const contentType = headers["Content-Type"] ?? "application/json";
|
|
84
|
+
try {
|
|
85
|
+
if (send(url, beaconPayload(body, contentType))) {
|
|
86
|
+
return Promise.resolve(syntheticResponse("beacon"));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!getFetch(options.fetchImpl)) {
|
|
93
|
+
return Promise.resolve(syntheticResponse("beacon-unsupported"));
|
|
94
|
+
}
|
|
95
|
+
return doFetch(url, {
|
|
96
|
+
method: init.method ?? "POST",
|
|
97
|
+
headers,
|
|
98
|
+
body: init.body,
|
|
99
|
+
keepalive: true,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return doFetch(url, {
|
|
103
|
+
method: init?.method,
|
|
104
|
+
headers,
|
|
105
|
+
body: init?.body,
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
putRaw(url, body, headers) {
|
|
109
|
+
return doFetch(url, { method: "PUT", headers, body });
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Transport } from "./ports";
|
|
2
|
+
import type { FittingRoomRequest, FittingRoomResponse, GenerationStatusResponse, CurrentPlanResponse, CheckCreditsResponse, RatingReasonCode } from "./types";
|
|
3
|
+
import type { Store } from "./store";
|
|
4
|
+
import type { CoreState, GenerationJob, GenError } from "./entities";
|
|
5
|
+
/** Failure category the host UI routes on. Derived from the backend's
|
|
6
|
+
* structured codes, never from message text (see classifyErrorKind). */
|
|
7
|
+
export type GenerationErrorKind = "quota" | "weekly_limit" | "rate_limited" | "overloaded" | "creation_failed" | "upload_failed" | "failed";
|
|
8
|
+
/**
|
|
9
|
+
* Derive the error kind from the backend's structured code. The message-text
|
|
10
|
+
* sniff at the bottom is the ONLY message-based classification left in the
|
|
11
|
+
* codebase: it handles Vertex capacity errors predating structured codes
|
|
12
|
+
* (`out of capacity` / RESOURCE_EXHAUSTED+429 with no errorCode). Everything
|
|
13
|
+
* else switches on codes.
|
|
14
|
+
*/
|
|
15
|
+
export declare function classifyErrorKind(code: string, message: string): GenerationErrorKind;
|
|
16
|
+
/**
|
|
17
|
+
* Thrown for every generation failure: backend failure codes on job creation,
|
|
18
|
+
* a FAILED poll status, polling timeouts, and upload coordination failures.
|
|
19
|
+
* `code` is the backend's stable wire code (e.g. GENERATION_FAILED,
|
|
20
|
+
* MISSING_REQUIRED_CLASSIFICATION); `kind` is the routing category derived from
|
|
21
|
+
* it; `message` is backend text for logs only and must never be rendered.
|
|
22
|
+
*
|
|
23
|
+
* CROSS-BUNDLE CONTRACT: the error crosses the boot/UI bundle boundary, so
|
|
24
|
+
* hosts recognise it by `error.name === "GenerationFailedError"` and read
|
|
25
|
+
* `kind`/`code` off the object — never `instanceof` (which would not match a
|
|
26
|
+
* separately bundled copy of this class).
|
|
27
|
+
*
|
|
28
|
+
* Defined here (the module that throws it, matching the pre-split layout) rather
|
|
29
|
+
* than a sibling module, so this file carries NO runtime value import — the
|
|
30
|
+
* repo's strip-only `node --test` cannot resolve extensionless sibling imports,
|
|
31
|
+
* and `.ts` extensions in source break the `tsc` build (TS5097).
|
|
32
|
+
*/
|
|
33
|
+
export declare class GenerationFailedError extends Error {
|
|
34
|
+
readonly code: string;
|
|
35
|
+
readonly kind: GenerationErrorKind;
|
|
36
|
+
constructor(code: string, message: string, kind?: GenerationErrorKind);
|
|
37
|
+
}
|
|
38
|
+
export declare function createFittingRoomJob(transport: Transport, request: FittingRoomRequest): Promise<FittingRoomResponse>;
|
|
39
|
+
export declare function getGenerationStatus(transport: Transport, jobId: string): Promise<GenerationStatusResponse>;
|
|
40
|
+
export declare function pollGenerationStatus(transport: Transport, jobId: string, onStatusChange?: (status: GenerationStatusResponse) => void, pollInterval?: number, maxAttempts?: number): Promise<GenerationStatusResponse>;
|
|
41
|
+
export declare function getCurrentPlan(transport: Transport): Promise<CurrentPlanResponse>;
|
|
42
|
+
export declare function checkCredits(transport: Transport): Promise<CheckCreditsResponse>;
|
|
43
|
+
export declare function rateGeneration(transport: Transport, generationId: string, rating: 1 | -1 | 0, reason?: RatingReasonCode | null): Promise<{
|
|
44
|
+
rating: number | null;
|
|
45
|
+
ratedAt: string | null;
|
|
46
|
+
ratingReason: string | null;
|
|
47
|
+
}>;
|
|
48
|
+
export interface RateEngineDeps {
|
|
49
|
+
transport: Transport;
|
|
50
|
+
store: Store<CoreState>;
|
|
51
|
+
/** Emits widget:result_rated (bound to analytics by the client). */
|
|
52
|
+
emitResultRated: (generationId: string, rating: 1 | -1 | 0) => void;
|
|
53
|
+
/** Emits widget:result_rating_reason (bound to analytics by the client). */
|
|
54
|
+
emitResultRatingReason: (generationId: string, reason: RatingReasonCode) => void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Rate a history entry. Without a reason: optimistic rating write-through
|
|
58
|
+
* (clearing any previous reason), emit, send — and revert the rating if the
|
|
59
|
+
* backend rejects. With a reason (thumbs-down follow-up): write the reason
|
|
60
|
+
* through, emit, send. Never throws — backend failures are logged.
|
|
61
|
+
*/
|
|
62
|
+
export declare function rateResult(deps: RateEngineDeps, entryId: string, rating: 1 | -1 | 0, reason?: RatingReasonCode | null): Promise<void>;
|
|
63
|
+
export interface GenerationEngineDeps {
|
|
64
|
+
transport: Transport;
|
|
65
|
+
store: Store<CoreState>;
|
|
66
|
+
now: () => number;
|
|
67
|
+
/** Emits the generation_start pair (bound to analytics by the client; spied in tests). */
|
|
68
|
+
emitGenerationStart: (generationId: string) => void;
|
|
69
|
+
/** Emits tryon:generation_succeeded on the done transition. */
|
|
70
|
+
emitGenerationSucceeded: (input: {
|
|
71
|
+
generationId: string;
|
|
72
|
+
durationMs: number;
|
|
73
|
+
productId: string;
|
|
74
|
+
variantId?: string;
|
|
75
|
+
}) => void;
|
|
76
|
+
/** Emits tryon:generation_failed on the error transition. */
|
|
77
|
+
emitGenerationFailed: (input: {
|
|
78
|
+
generationId: string;
|
|
79
|
+
kind: string;
|
|
80
|
+
code: string;
|
|
81
|
+
}) => void;
|
|
82
|
+
/** Pre-entity server policy denial (see blockedReasonFor) — the server-side
|
|
83
|
+
* twin of the client policy layer's tryon:generation_blocked. */
|
|
84
|
+
emitGenerationBlocked: (reason: string) => void;
|
|
85
|
+
pollInterval?: number;
|
|
86
|
+
maxAttempts?: number;
|
|
87
|
+
}
|
|
88
|
+
export interface GenerationInput {
|
|
89
|
+
userImageId: string;
|
|
90
|
+
productId: string;
|
|
91
|
+
variantId?: string;
|
|
92
|
+
/** The quota-window timestamp the client counted for this generation on
|
|
93
|
+
* acceptance (client.recordUsage()'s return value). Stamped onto the job entity
|
|
94
|
+
* so a terminal-error refund removes exactly this entry; also used for the
|
|
95
|
+
* bare pre-entity refund when the backend rejects job creation. */
|
|
96
|
+
usageAt?: number;
|
|
97
|
+
}
|
|
98
|
+
/** Entity-level error classification. Navigation is owned by the host's switch
|
|
99
|
+
* on the rethrown error's `kind`; this only tags the store entity. Switches on
|
|
100
|
+
* the typed error's kind/code — never on message text. */
|
|
101
|
+
export declare function classifyGenerationError(err: unknown): GenError;
|
|
102
|
+
/**
|
|
103
|
+
* Create a generation job and drive it to a terminal state, updating the store
|
|
104
|
+
* on every transition. Fires widget:generation_start on the requested→generating
|
|
105
|
+
* boundary. Throws the typed generation errors a host's error→navigation switch
|
|
106
|
+
* branches on; the store also records a typed error entity.
|
|
107
|
+
*/
|
|
108
|
+
export declare function runGeneration(deps: GenerationEngineDeps, input: GenerationInput): Promise<{
|
|
109
|
+
imageUrl: string;
|
|
110
|
+
generationId: string;
|
|
111
|
+
}>;
|
|
112
|
+
/**
|
|
113
|
+
* Resume a rehydrated non-terminal job: fire the start event if it was never
|
|
114
|
+
* reported (crash-before-flush), poll to a terminal state, update the store.
|
|
115
|
+
* Never throws — resumption is a background continuation.
|
|
116
|
+
*/
|
|
117
|
+
export declare function resumeGeneration(deps: GenerationEngineDeps, generationId: string): Promise<void>;
|
|
118
|
+
/** Mark a completed result as seen (host called acknowledge). */
|
|
119
|
+
export declare function acknowledgeGeneration(store: Store<CoreState>, generationId: string): void;
|
|
120
|
+
/**
|
|
121
|
+
* Schedule resumption of rehydrated in-flight jobs on the NEXT microtask rather
|
|
122
|
+
* than synchronously in the client constructor.
|
|
123
|
+
*
|
|
124
|
+
* Contract: a composition root (boot.ts — and the Woo/Presta boots, where NO
|
|
125
|
+
* pre-boot stub exists at all) finishes publishing the global runtime SYNCHRONOUSLY
|
|
126
|
+
* AFTER `new TryOnClient(...)` returns. The resume fetch derefs that global's
|
|
127
|
+
* transport (cabinFetch → window.Genlook.cabin.http.fetch), so resuming inline in
|
|
128
|
+
* the ctor would deref a not-yet-published global and mark every
|
|
129
|
+
* refresh-mid-generation job errored. One microtask hop guarantees the transport
|
|
130
|
+
* deps are live before the first status poll fires.
|
|
131
|
+
*/
|
|
132
|
+
export declare function scheduleInFlightResumes(deps: GenerationEngineDeps, jobs: GenerationJob[]): void;
|