@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/tracker.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
export const FLUSH_INTERVAL_MS = 5000;
|
|
2
|
+
function isFetchErrorResponse(v) {
|
|
3
|
+
return (!!v &&
|
|
4
|
+
typeof v.status === "number" &&
|
|
5
|
+
typeof v.text === "function");
|
|
6
|
+
}
|
|
7
|
+
export class Tracker {
|
|
8
|
+
queue = [];
|
|
9
|
+
pageviewId;
|
|
10
|
+
lastPathname;
|
|
11
|
+
isFlushing = false;
|
|
12
|
+
isBot;
|
|
13
|
+
consentSnapshot = null;
|
|
14
|
+
consentSubscribed = false;
|
|
15
|
+
uiVariant = null;
|
|
16
|
+
uiBuild = null;
|
|
17
|
+
widgetScopeConsent = false;
|
|
18
|
+
fallbackAnonymousId = null;
|
|
19
|
+
fallbackSessionId = null;
|
|
20
|
+
transport;
|
|
21
|
+
consent;
|
|
22
|
+
pageContext;
|
|
23
|
+
uuid;
|
|
24
|
+
now;
|
|
25
|
+
static MAX_QUEUE_SIZE = 100;
|
|
26
|
+
static EARLY_FLUSH_SIZE = 20;
|
|
27
|
+
constructor(deps) {
|
|
28
|
+
this.transport = deps.transport;
|
|
29
|
+
this.consent = deps.consent;
|
|
30
|
+
this.pageContext = deps.pageContext;
|
|
31
|
+
this.uuid = deps.uuid;
|
|
32
|
+
this.now = deps.now ?? Date.now;
|
|
33
|
+
this.pageviewId = this.uuid();
|
|
34
|
+
this.lastPathname = this.pageContext.getPathname();
|
|
35
|
+
this.isBot = this.pageContext.isBot();
|
|
36
|
+
}
|
|
37
|
+
setUiVariant(variant) {
|
|
38
|
+
this.uiVariant = variant;
|
|
39
|
+
}
|
|
40
|
+
setUiBuild(build) {
|
|
41
|
+
this.uiBuild = build;
|
|
42
|
+
}
|
|
43
|
+
static WIDGET_SCOPE_PREFIXES = ["widget:", "tryon:", "sheet:", "api:"];
|
|
44
|
+
isTrackingAllowed() {
|
|
45
|
+
this.ensureConsentSubscribed();
|
|
46
|
+
return this.consentSnapshot?.analytics_allowed !== false;
|
|
47
|
+
}
|
|
48
|
+
grantWidgetScopeConsent() {
|
|
49
|
+
this.widgetScopeConsent = true;
|
|
50
|
+
}
|
|
51
|
+
isEventAllowed(event) {
|
|
52
|
+
if (this.isTrackingAllowed())
|
|
53
|
+
return true;
|
|
54
|
+
if (!this.widgetScopeConsent)
|
|
55
|
+
return false;
|
|
56
|
+
return Tracker.WIDGET_SCOPE_PREFIXES.some((p) => event.startsWith(p));
|
|
57
|
+
}
|
|
58
|
+
capture(event, properties) {
|
|
59
|
+
if (this.isBot)
|
|
60
|
+
return;
|
|
61
|
+
if (!this.isEventAllowed(event))
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
this.enqueue({
|
|
65
|
+
event,
|
|
66
|
+
properties: properties || {},
|
|
67
|
+
timestamp: new Date(this.now()).toISOString(),
|
|
68
|
+
$insert_id: this.uuid(),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.error("[Genlook] Failed to capture event:", error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async captureFetchError(url, method, error) {
|
|
76
|
+
try {
|
|
77
|
+
if (url.includes("/tracking/event"))
|
|
78
|
+
return;
|
|
79
|
+
const props = {
|
|
80
|
+
error_url: url,
|
|
81
|
+
error_method: method,
|
|
82
|
+
};
|
|
83
|
+
if (isFetchErrorResponse(error)) {
|
|
84
|
+
props.error_status = error.status;
|
|
85
|
+
props.error_message = `${method} ${url} failed with status ${error.status}`;
|
|
86
|
+
props.error_response_body = (await error.text()).slice(0, 10000);
|
|
87
|
+
const headers = {};
|
|
88
|
+
error.headers.forEach((v, k) => { headers[k] = v; });
|
|
89
|
+
props.error_response_headers = headers;
|
|
90
|
+
}
|
|
91
|
+
else if (error instanceof Error) {
|
|
92
|
+
props.error_message = error.message;
|
|
93
|
+
}
|
|
94
|
+
this.capture("api:error", props);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.error("[Genlook] Failed to capture fetch error:", err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async flush(opts) {
|
|
101
|
+
if (this.isBot) {
|
|
102
|
+
this.queue = [];
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (this.queue.length === 0)
|
|
106
|
+
return;
|
|
107
|
+
const beacon = opts?.beacon === true;
|
|
108
|
+
if (!beacon && this.isFlushing)
|
|
109
|
+
return;
|
|
110
|
+
if (!beacon)
|
|
111
|
+
this.isFlushing = true;
|
|
112
|
+
const context = this.buildContext();
|
|
113
|
+
const events = [...this.queue];
|
|
114
|
+
this.queue = [];
|
|
115
|
+
try {
|
|
116
|
+
const body = JSON.stringify({ events, context });
|
|
117
|
+
await this.transport.fetch("/events", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body,
|
|
121
|
+
beacon,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.queue = events.concat(this.queue).slice(-Tracker.MAX_QUEUE_SIZE);
|
|
126
|
+
console.error("[Genlook] Flush failed:", error);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
if (!beacon)
|
|
130
|
+
this.isFlushing = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
enqueue(event) {
|
|
134
|
+
if (this.queue.length >= Tracker.MAX_QUEUE_SIZE)
|
|
135
|
+
this.queue.shift();
|
|
136
|
+
this.queue.push(event);
|
|
137
|
+
if (this.queue.length >= Tracker.EARLY_FLUSH_SIZE)
|
|
138
|
+
this.flush().catch(() => { });
|
|
139
|
+
}
|
|
140
|
+
ensureConsentSubscribed() {
|
|
141
|
+
if (this.consentSubscribed)
|
|
142
|
+
return;
|
|
143
|
+
this.consentSubscribed = true;
|
|
144
|
+
try {
|
|
145
|
+
this.consentSnapshot = this.consent.getSnapshot();
|
|
146
|
+
this.consent.onChange((snapshot) => {
|
|
147
|
+
this.consentSnapshot = snapshot;
|
|
148
|
+
this.handleConsentChange(snapshot);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch { }
|
|
152
|
+
}
|
|
153
|
+
handleConsentChange(snapshot) {
|
|
154
|
+
if (snapshot?.analytics !== "no")
|
|
155
|
+
return;
|
|
156
|
+
this.pageContext.purgeTrackingStorage();
|
|
157
|
+
this.pageContext.resetSessionId();
|
|
158
|
+
}
|
|
159
|
+
buildContext() {
|
|
160
|
+
const base = this.pageContext.getBaseContext();
|
|
161
|
+
if (base.$pathname !== this.lastPathname) {
|
|
162
|
+
this.pageviewId = this.uuid();
|
|
163
|
+
this.lastPathname = base.$pathname;
|
|
164
|
+
}
|
|
165
|
+
const context = {
|
|
166
|
+
$current_url: base.$current_url,
|
|
167
|
+
$pathname: base.$pathname,
|
|
168
|
+
$host: base.$host,
|
|
169
|
+
$screen_width: base.$screen_width,
|
|
170
|
+
$screen_height: base.$screen_height,
|
|
171
|
+
$viewport_width: base.$viewport_width,
|
|
172
|
+
$viewport_height: base.$viewport_height,
|
|
173
|
+
$raw_user_agent: base.$raw_user_agent,
|
|
174
|
+
$browser_language: base.$browser_language,
|
|
175
|
+
$timezone: base.$timezone,
|
|
176
|
+
$pageview_id: this.pageviewId,
|
|
177
|
+
widget_enabled: base.widget_enabled,
|
|
178
|
+
platform: base.platform,
|
|
179
|
+
store_id: base.store_id,
|
|
180
|
+
product_id: base.product_id,
|
|
181
|
+
variant_id: base.variant_id,
|
|
182
|
+
widget_version: base.widget_version,
|
|
183
|
+
app_version: base.app_version ?? null,
|
|
184
|
+
page_type: base.page_type,
|
|
185
|
+
collection_id: base.collection_id,
|
|
186
|
+
integration: base.integration ?? null,
|
|
187
|
+
integration_version: base.integration_version ?? null,
|
|
188
|
+
consent: this.consentSnapshot,
|
|
189
|
+
ui_variant: this.uiVariant,
|
|
190
|
+
ui_version: this.uiBuild,
|
|
191
|
+
};
|
|
192
|
+
const id = this.readIdentityContext();
|
|
193
|
+
context.$referrer = id.$referrer;
|
|
194
|
+
context.$referring_domain = id.$referring_domain;
|
|
195
|
+
context.session_id = id.session_id;
|
|
196
|
+
context.anonymous_id = id.anonymous_id;
|
|
197
|
+
context.first_touch = id.first_touch;
|
|
198
|
+
return context;
|
|
199
|
+
}
|
|
200
|
+
readIdentityContext() {
|
|
201
|
+
try {
|
|
202
|
+
const id = this.pageContext.getIdentityContext();
|
|
203
|
+
return {
|
|
204
|
+
...id,
|
|
205
|
+
anonymous_id: id.anonymous_id || this.getFallbackAnonymousId(),
|
|
206
|
+
session_id: id.session_id || this.getFallbackSessionId(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return {
|
|
211
|
+
$referrer: null,
|
|
212
|
+
$referring_domain: null,
|
|
213
|
+
session_id: this.getFallbackSessionId(),
|
|
214
|
+
anonymous_id: this.getFallbackAnonymousId(),
|
|
215
|
+
first_touch: null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
getFallbackAnonymousId() {
|
|
220
|
+
if (this.fallbackAnonymousId === null)
|
|
221
|
+
this.fallbackAnonymousId = `anon_${this.uuid()}`;
|
|
222
|
+
return this.fallbackAnonymousId;
|
|
223
|
+
}
|
|
224
|
+
getFallbackSessionId() {
|
|
225
|
+
if (this.fallbackSessionId === null)
|
|
226
|
+
this.fallbackSessionId = `sess_${this.uuid()}`;
|
|
227
|
+
return this.fallbackSessionId;
|
|
228
|
+
}
|
|
229
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface FittingRoomRequest {
|
|
2
|
+
userImageId: string;
|
|
3
|
+
/** Shopify Admin GraphQL product GID (normalised from storefront numeric id at send time). */
|
|
4
|
+
productId: string;
|
|
5
|
+
/** Shopify Admin GraphQL variant GID (optional; normalised at send time). */
|
|
6
|
+
variantId?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FittingRoomResponse {
|
|
9
|
+
jobId: string;
|
|
10
|
+
message: string;
|
|
11
|
+
code?: "BILLING_NOT_ALLOWED" | "QUOTA_EXCEEDED" | "RATE_LIMIT_EXCEEDED" | "CREATION_FAILED";
|
|
12
|
+
}
|
|
13
|
+
export interface GenerationStatusResponse {
|
|
14
|
+
generationId: string;
|
|
15
|
+
status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED";
|
|
16
|
+
resultImageKey?: string;
|
|
17
|
+
resultImageUrl?: string;
|
|
18
|
+
errorMessage?: string;
|
|
19
|
+
errorCode?: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CurrentPlanResponse {
|
|
24
|
+
plan: "free" | "starter" | "growth" | "pro" | "enterprise";
|
|
25
|
+
}
|
|
26
|
+
export interface CheckCreditsResponse {
|
|
27
|
+
allowed: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface CollectEmailRequest {
|
|
30
|
+
email: string;
|
|
31
|
+
emailCollectionStep: number;
|
|
32
|
+
marketingConsent?: boolean;
|
|
33
|
+
checkboxDisplayed?: boolean;
|
|
34
|
+
/** Version tag of the consent copy the shopper agreed to (GDPR evidence). */
|
|
35
|
+
marketingConsentWording?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface CollectEmailResponse {
|
|
38
|
+
success: boolean;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ShareGenerationResponse {
|
|
42
|
+
shareToken: string;
|
|
43
|
+
shareUrl: string;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
}
|
|
46
|
+
export interface UploadResult {
|
|
47
|
+
fileId: string;
|
|
48
|
+
fileUrl: string;
|
|
49
|
+
imageWidth?: number;
|
|
50
|
+
imageHeight?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface PrepareUploadRequest {
|
|
53
|
+
productId?: string;
|
|
54
|
+
variantId?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface PrepareUploadResponse {
|
|
57
|
+
uploadUrl: string;
|
|
58
|
+
uploadKey: string;
|
|
59
|
+
}
|
|
60
|
+
export interface UploadCompleteResponse {
|
|
61
|
+
fileId: string;
|
|
62
|
+
fileUrl: string;
|
|
63
|
+
message: string;
|
|
64
|
+
}
|
|
65
|
+
/** Closed set of reason codes paired with a thumbs-down rating. Mirrors the
|
|
66
|
+
* backend's reason-code enum so downstream analytics stay clean. */
|
|
67
|
+
export type RatingReasonCode = "distorted_body" | "wrong_fit" | "face_changed" | "wrong_colors" | "something_else";
|
|
68
|
+
/**
|
|
69
|
+
* Standardized consent-state snapshot attached to every tracking batch, and the
|
|
70
|
+
* source of the tracker's binary consent gate. Platform-neutral by design: `source`
|
|
71
|
+
* names the origin ("platform-default", "shopify", ...), `available` says
|
|
72
|
+
* whether a real CMP answered, the raw `analytics`/`marketing`/`preferences`/
|
|
73
|
+
* `sale_of_data` strings carry the CMP's own vocabulary ('yes'/'no'/''), and the
|
|
74
|
+
* `_allowed` booleans carry a resolved verdict. Any field a platform can't
|
|
75
|
+
* resolve is null. Canonical home is here (headless core); a host runtime may
|
|
76
|
+
* re-export it so platform adapters can import it from there.
|
|
77
|
+
*/
|
|
78
|
+
export interface ConsentSnapshot {
|
|
79
|
+
source: string;
|
|
80
|
+
available: boolean;
|
|
81
|
+
analytics: string | null;
|
|
82
|
+
marketing: string | null;
|
|
83
|
+
preferences: string | null;
|
|
84
|
+
sale_of_data: string | null;
|
|
85
|
+
analytics_allowed: boolean | null;
|
|
86
|
+
marketing_allowed: boolean | null;
|
|
87
|
+
preferences_allowed: boolean | null;
|
|
88
|
+
sale_of_data_allowed: boolean | null;
|
|
89
|
+
should_show_banner: boolean | null;
|
|
90
|
+
region: string | null;
|
|
91
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Transport, MediaInput } from "./ports";
|
|
2
|
+
import type { PrepareUploadRequest, UploadResult } from "./types";
|
|
3
|
+
import type { Store } from "./store";
|
|
4
|
+
import type { CoreState, UploadMeta, UploadDimensions } from "./entities";
|
|
5
|
+
/** Max accepted upload size (bytes). */
|
|
6
|
+
export declare const MAX_UPLOAD_BYTES: number;
|
|
7
|
+
/** Accepted image MIME types (HEIC-by-extension is accepted separately). */
|
|
8
|
+
export declare const ALLOWED_UPLOAD_MIME_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
|
|
9
|
+
/** Minimum accepted client-read dimensions (below → invalid_dimensions). */
|
|
10
|
+
export declare const MIN_UPLOAD_WIDTH = 500;
|
|
11
|
+
export declare const MIN_UPLOAD_HEIGHT = 625;
|
|
12
|
+
/** True for a HEIC/HEIF file: recognised MIME set OR a .heic/.heif extension.
|
|
13
|
+
* Call it with `mimeType ← file.type` and `fileName ← file.name`; fileName is
|
|
14
|
+
* optional, and when absent only the MIME set is consulted. */
|
|
15
|
+
export declare function isHeicLike(mimeType: string, fileName?: string): boolean;
|
|
16
|
+
/** Closed set of rejection reasons. Pre-upload verdicts + the post-crop verdict
|
|
17
|
+
* classified from the backend's structured code. The UI switches on this to pick
|
|
18
|
+
* its localized copy; the rejection event carries it as `rejectionReason`. */
|
|
19
|
+
export type UploadRejectionReason = "invalid_image_type" | "file_too_large" | "invalid_dimensions" | "invalid_dimensions_post_crop";
|
|
20
|
+
/** Pure-validation input. `fileName`/`dimensions` are optional — an older host
|
|
21
|
+
* that predates core-side validation omits them and the core skips the checks it
|
|
22
|
+
* cannot perform (backward tolerant) rather than throwing. */
|
|
23
|
+
export interface UploadValidationInput {
|
|
24
|
+
fileSize: number;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
fileName?: string;
|
|
27
|
+
dimensions?: UploadDimensions;
|
|
28
|
+
}
|
|
29
|
+
/** Pre-upload verdict. `extra` carries the per-reason event metadata the host used
|
|
30
|
+
* to attach inline (allowedTypes / maxFileSize / dimension fields), preserved
|
|
31
|
+
* byte-for-byte so the rejection event payload is unchanged. */
|
|
32
|
+
export type UploadVerdict = {
|
|
33
|
+
ok: true;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: UploadRejectionReason;
|
|
37
|
+
extra: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Pre-upload validation. Decision order and reasons:
|
|
41
|
+
* 1. invalid_image_type — non-allowed MIME AND not HEIC-like.
|
|
42
|
+
* 2. file_too_large — over MAX_UPLOAD_BYTES.
|
|
43
|
+
* 3. invalid_dimensions — "unreadable", or width/height below the minimums.
|
|
44
|
+
* The dimension check is skipped when `dimensions` is "skipped" (HEIC-like) or
|
|
45
|
+
* absent (older host). Pure — never throws; the caller emits + throws.
|
|
46
|
+
*/
|
|
47
|
+
export declare function validateUpload(input: UploadValidationInput): UploadVerdict;
|
|
48
|
+
/**
|
|
49
|
+
* CROSS-BUNDLE CONTRACT: recognised by `error.name === "UploadRejectedError"` +
|
|
50
|
+
* the `reason` field read off the object — never `instanceof` (which would not
|
|
51
|
+
* match a separately bundled copy of this class), same pattern as
|
|
52
|
+
* GenerationFailedError. Carries the per-reason event `extra` so the host never
|
|
53
|
+
* recomputes it. Colocated here (the module that throws it) so this file keeps NO
|
|
54
|
+
* runtime sibling value import and loads under the strip-only `node --test`.
|
|
55
|
+
*/
|
|
56
|
+
export declare class UploadRejectedError extends Error {
|
|
57
|
+
readonly reason: UploadRejectionReason;
|
|
58
|
+
readonly extra: Record<string, unknown>;
|
|
59
|
+
constructor(reason: UploadRejectionReason, message: string, extra?: Record<string, unknown>);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Transport-level HTTP failure from a backend endpoint (/uploads,
|
|
63
|
+
* /uploads/:key/complete) that answers with the structured `{ code, message, status }`
|
|
64
|
+
* ApiErrorBody. Carries the parsed `code` next to the flattened `message`.
|
|
65
|
+
* CROSS-BUNDLE CONTRACT: consumers (classifyUploadFailure via runUpload) MUST
|
|
66
|
+
* recognise the structured code by the DUCK-TYPED `code` field read off the
|
|
67
|
+
* object — never `instanceof` (a separately bundled transport may throw its own
|
|
68
|
+
* object carrying a `code` field, which would not match this class). `code` is
|
|
69
|
+
* undefined when the error body did not JSON-parse to an object with a string
|
|
70
|
+
* `code`. NOT exported from src/index.ts — the duck-typed field is the contract.
|
|
71
|
+
*/
|
|
72
|
+
export declare class UploadHttpError extends Error {
|
|
73
|
+
readonly code?: string;
|
|
74
|
+
readonly status: number;
|
|
75
|
+
constructor(message: string, status: number, code?: string);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Post-crop rejection classifier. The backend rejects a low-resolution-after-crop
|
|
79
|
+
* upload from /uploads/:key/complete with a structured `code: "INVALID_IMAGE"` in the
|
|
80
|
+
* response body (ApiException → HttpException, whose object body is passed through
|
|
81
|
+
* unchanged by the global BaseExceptionFilter). `uploadPhoto` parses that JSON
|
|
82
|
+
* body and surfaces the `code` on the thrown UploadHttpError, so the structured
|
|
83
|
+
* `code` is now the PRIMARY signal: `code === "INVALID_IMAGE"` → post-crop
|
|
84
|
+
* rejection. A non-matching (foreign) code falls THROUGH to the message check
|
|
85
|
+
* rather than returning null — an older host may pass garbage in `code`. The
|
|
86
|
+
* message-token match ("INVALID_IMAGE" token OR the legacy `resolution is too
|
|
87
|
+
* low` prose) survives ONLY as a fallback for errors produced by older bundled
|
|
88
|
+
* transports / cached boot bundles that still flatten the structured body into
|
|
89
|
+
* the message without surfacing `code`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function classifyUploadFailure(message: string, code?: string): UploadRejectionReason | null;
|
|
92
|
+
/**
|
|
93
|
+
* Upload user media via the 3-step signed-URL flow:
|
|
94
|
+
* 1. POST /uploads → GCS signed write URL + uploadKey
|
|
95
|
+
* 2. PUT raw bytes to GCS → direct upload, NO Genlook identity headers
|
|
96
|
+
* 3. POST /uploads/:key/complete → backend crops and returns the final fileId + fileUrl
|
|
97
|
+
*
|
|
98
|
+
* The chain is synchronous and step 3 returns the cropped image — the widget
|
|
99
|
+
* chains generation immediately, so this must NOT be offloaded to a queue.
|
|
100
|
+
*/
|
|
101
|
+
export declare function uploadPhoto(transport: Transport, file: MediaInput, productContext?: PrepareUploadRequest): Promise<UploadResult>;
|
|
102
|
+
export interface UploadEngineDeps {
|
|
103
|
+
transport: Transport;
|
|
104
|
+
store: Store<CoreState>;
|
|
105
|
+
now: () => number;
|
|
106
|
+
emitImageUpload: (meta: UploadMeta) => void;
|
|
107
|
+
emitImageUploadSuccess: (meta: UploadMeta, fileId: string, uploadDurationMs: number) => void;
|
|
108
|
+
emitImageUploadError: (meta: UploadMeta, error: string) => void;
|
|
109
|
+
/** Emits the image_upload_rejected pair (bound to analytics by the client). One
|
|
110
|
+
* emission point for pre-upload verdicts and the post-crop classification. */
|
|
111
|
+
emitImageUploadRejected: (meta: UploadMeta, reason: UploadRejectionReason, extra: Record<string, unknown>) => void;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Drive a photo through uploading→ready/failed while firing the upload funnel
|
|
115
|
+
* events from the transitions. Runs pre-upload validation from the meta BEFORE any
|
|
116
|
+
* network call: on a verdict it emits the image_upload_rejected pair and throws a
|
|
117
|
+
* typed UploadRejectedError (no photo entity, no upload_started event). The
|
|
118
|
+
* prepare→PUT→complete chain stays synchronous and `ready` carries the cropped
|
|
119
|
+
* result. On failure it classifies a post-crop rejection (structured
|
|
120
|
+
* INVALID_IMAGE) → image_upload_rejected + typed error; any other failure fires
|
|
121
|
+
* image_upload_error and rethrows, so the host's error routing is unchanged.
|
|
122
|
+
*/
|
|
123
|
+
export declare function runUpload(deps: UploadEngineDeps, file: MediaInput, meta: UploadMeta, productContext?: PrepareUploadRequest): Promise<UploadResult>;
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
2
|
+
export const ALLOWED_UPLOAD_MIME_TYPES = [
|
|
3
|
+
"image/jpeg",
|
|
4
|
+
"image/png",
|
|
5
|
+
"image/webp",
|
|
6
|
+
"image/heic",
|
|
7
|
+
"image/heif",
|
|
8
|
+
];
|
|
9
|
+
export const MIN_UPLOAD_WIDTH = 500;
|
|
10
|
+
export const MIN_UPLOAD_HEIGHT = 625;
|
|
11
|
+
const HEIC_MIME_TYPES = new Set(["image/heic", "image/heif"]);
|
|
12
|
+
const HEIC_EXTENSION_RE = /\.(heic|heif)$/i;
|
|
13
|
+
export function isHeicLike(mimeType, fileName) {
|
|
14
|
+
if (HEIC_MIME_TYPES.has(mimeType))
|
|
15
|
+
return true;
|
|
16
|
+
return fileName ? HEIC_EXTENSION_RE.test(fileName) : false;
|
|
17
|
+
}
|
|
18
|
+
export function validateUpload(input) {
|
|
19
|
+
const { fileSize, mimeType, fileName, dimensions } = input;
|
|
20
|
+
const allowed = ALLOWED_UPLOAD_MIME_TYPES.includes(mimeType);
|
|
21
|
+
if (!allowed && !isHeicLike(mimeType, fileName)) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
reason: "invalid_image_type",
|
|
25
|
+
extra: { allowedTypes: [...ALLOWED_UPLOAD_MIME_TYPES] },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (fileSize > MAX_UPLOAD_BYTES) {
|
|
29
|
+
return { ok: false, reason: "file_too_large", extra: { maxFileSize: MAX_UPLOAD_BYTES } };
|
|
30
|
+
}
|
|
31
|
+
if (dimensions && dimensions !== "skipped") {
|
|
32
|
+
if (dimensions === "unreadable") {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: "invalid_dimensions",
|
|
36
|
+
extra: {
|
|
37
|
+
reason: "dimension_read_failed",
|
|
38
|
+
minWidth: MIN_UPLOAD_WIDTH,
|
|
39
|
+
minHeight: MIN_UPLOAD_HEIGHT,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (dimensions.width < MIN_UPLOAD_WIDTH || dimensions.height < MIN_UPLOAD_HEIGHT) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: "invalid_dimensions",
|
|
47
|
+
extra: {
|
|
48
|
+
width: dimensions.width,
|
|
49
|
+
height: dimensions.height,
|
|
50
|
+
minWidth: MIN_UPLOAD_WIDTH,
|
|
51
|
+
minHeight: MIN_UPLOAD_HEIGHT,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
export class UploadRejectedError extends Error {
|
|
59
|
+
reason;
|
|
60
|
+
extra;
|
|
61
|
+
constructor(reason, message, extra = {}) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "UploadRejectedError";
|
|
64
|
+
this.reason = reason;
|
|
65
|
+
this.extra = extra;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class UploadHttpError extends Error {
|
|
69
|
+
code;
|
|
70
|
+
status;
|
|
71
|
+
constructor(message, status, code) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "UploadHttpError";
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.code = code;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function uploadHttpError(label, status, statusText, text) {
|
|
79
|
+
let code;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(text);
|
|
82
|
+
if (parsed && typeof parsed === "object" && typeof parsed.code === "string") {
|
|
83
|
+
code = parsed.code;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
}
|
|
88
|
+
return new UploadHttpError(`${label}: ${status} ${statusText}. ${text}`, status, code);
|
|
89
|
+
}
|
|
90
|
+
export function classifyUploadFailure(message, code) {
|
|
91
|
+
if (code === "INVALID_IMAGE") {
|
|
92
|
+
return "invalid_dimensions_post_crop";
|
|
93
|
+
}
|
|
94
|
+
if (message.includes("INVALID_IMAGE") || message.includes("resolution is too low")) {
|
|
95
|
+
return "invalid_dimensions_post_crop";
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
export async function uploadPhoto(transport, file, productContext) {
|
|
100
|
+
const prepareRes = await transport.fetch("/uploads", {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
body: JSON.stringify(productContext ?? {}),
|
|
104
|
+
});
|
|
105
|
+
if (!prepareRes.ok) {
|
|
106
|
+
const text = await prepareRes.text();
|
|
107
|
+
throw uploadHttpError("Prepare upload failed", prepareRes.status, prepareRes.statusText, text);
|
|
108
|
+
}
|
|
109
|
+
const { uploadUrl, uploadKey } = (await prepareRes.json());
|
|
110
|
+
console.log("Prepared upload, key:", uploadKey);
|
|
111
|
+
const putRes = await transport.putRaw(uploadUrl, file, { "Content-Type": "application/octet-stream" });
|
|
112
|
+
if (!putRes.ok) {
|
|
113
|
+
const text = await putRes.text();
|
|
114
|
+
throw new Error(`Direct upload to storage failed: ${putRes.status} ${putRes.statusText}. ${text}`);
|
|
115
|
+
}
|
|
116
|
+
console.log("Direct upload complete for key:", uploadKey);
|
|
117
|
+
const completeRes = await transport.fetch(`/uploads/${encodeURIComponent(uploadKey)}/complete`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: "{}",
|
|
121
|
+
includeFingerprint: true,
|
|
122
|
+
});
|
|
123
|
+
if (!completeRes.ok) {
|
|
124
|
+
const text = await completeRes.text();
|
|
125
|
+
throw uploadHttpError("Upload complete failed", completeRes.status, completeRes.statusText, text);
|
|
126
|
+
}
|
|
127
|
+
const result = (await completeRes.json());
|
|
128
|
+
console.log("Upload processed:", result);
|
|
129
|
+
return { fileId: result.fileId, fileUrl: result.fileUrl };
|
|
130
|
+
}
|
|
131
|
+
function setPhoto(store, photo) {
|
|
132
|
+
store.setState((s) => ({ ...s, photos: { ...s.photos, [photo.id]: photo } }));
|
|
133
|
+
}
|
|
134
|
+
function beginUpload(store, photo) {
|
|
135
|
+
store.setState((s) => ({
|
|
136
|
+
...s,
|
|
137
|
+
photos: { ...s.photos, [photo.id]: photo },
|
|
138
|
+
latestPhotoId: photo.id,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
export async function runUpload(deps, file, meta, productContext) {
|
|
142
|
+
const verdict = validateUpload({
|
|
143
|
+
fileSize: meta.fileSize,
|
|
144
|
+
mimeType: meta.mimeType,
|
|
145
|
+
fileName: meta.fileName,
|
|
146
|
+
dimensions: meta.dimensions,
|
|
147
|
+
});
|
|
148
|
+
if (!verdict.ok) {
|
|
149
|
+
deps.emitImageUploadRejected(meta, verdict.reason, verdict.extra);
|
|
150
|
+
throw new UploadRejectedError(verdict.reason, `Upload rejected: ${verdict.reason}`, verdict.extra);
|
|
151
|
+
}
|
|
152
|
+
const id = `p${deps.now()}-${Math.floor(Math.random() * 1e9)}`;
|
|
153
|
+
const createdAt = deps.now();
|
|
154
|
+
beginUpload(deps.store, { id, status: "uploading", progress: 0, createdAt });
|
|
155
|
+
deps.emitImageUpload(meta);
|
|
156
|
+
const start = deps.now();
|
|
157
|
+
try {
|
|
158
|
+
const result = await uploadPhoto(deps.transport, file, productContext);
|
|
159
|
+
setPhoto(deps.store, { id, status: "ready", result, createdAt });
|
|
160
|
+
deps.emitImageUploadSuccess(meta, result.fileId, deps.now() - start);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
165
|
+
setPhoto(deps.store, { id, status: "failed", error: message, createdAt });
|
|
166
|
+
const code = err && typeof err.code === "string" ? err.code : undefined;
|
|
167
|
+
const postCropReason = classifyUploadFailure(message, code);
|
|
168
|
+
if (postCropReason) {
|
|
169
|
+
const extra = { error: message.substring(0, 500) };
|
|
170
|
+
deps.emitImageUploadRejected(meta, postCropReason, extra);
|
|
171
|
+
throw new UploadRejectedError(postCropReason, message, extra);
|
|
172
|
+
}
|
|
173
|
+
deps.emitImageUploadError(meta, message);
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/dist/usage.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { KVStorage } from "./ports";
|
|
2
|
+
/** Persisted usage snapshot. `tryOns` are recent try-on timestamps (rolling-
|
|
3
|
+
* window rate limit); `refunded` are tombstones for entries rolled back on a
|
|
4
|
+
* terminal generation failure; `totalTryOns` is the lifetime counter (email-
|
|
5
|
+
* collection step); `email` is the collected shopper email (or null). */
|
|
6
|
+
export interface StoredUsage {
|
|
7
|
+
tryOns: number[];
|
|
8
|
+
refunded: number[];
|
|
9
|
+
totalTryOns: number;
|
|
10
|
+
email: string | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Legacy StoredLimits key earlier widget versions wrote through
|
|
14
|
+
* `window.Genlook.storage` (getJSON/setJSON). The KVStorage port applies the
|
|
15
|
+
* same `genlook-` prefix getJSON did, so passing this logical key to the port
|
|
16
|
+
* reaches the exact same localStorage slot those versions used.
|
|
17
|
+
*/
|
|
18
|
+
export declare const LEGACY_LIMITS_KEY = "genlook-widget-user-limits";
|
|
19
|
+
/** Drop try-on timestamps older than the 7-day retention (strict `>`), matching
|
|
20
|
+
* the legacy prune. */
|
|
21
|
+
export declare function pruneTryOns(tryOns: number[], now: number): number[];
|
|
22
|
+
export declare function saveUsage(storage: KVStorage, storeId: string, usage: StoredUsage, now: number): void;
|
|
23
|
+
/**
|
|
24
|
+
* Merge a persisted usage blob with the in-memory snapshot of a live core
|
|
25
|
+
* instance. Two instances of the core (a second tab, a bfcache-restored page)
|
|
26
|
+
* each hold their own snapshot; a blind write from the stale one rolls the
|
|
27
|
+
* counter back and hands the shopper free try-ons. The merge is monotonic:
|
|
28
|
+
* counted timestamps union, refund tombstones union and win over `tryOns` (so a
|
|
29
|
+
* stale copy that still holds a refunded entry can't resurrect it), and the
|
|
30
|
+
* lifetime counter takes the max of the two GROSS counts (stored total + own
|
|
31
|
+
* tombstones), minus the merged tombstones — a plain max on the stored total
|
|
32
|
+
* would make a refund's decrement unmergeable, the pre-refund copy always
|
|
33
|
+
* winning and the failed generation still counting toward the email step.
|
|
34
|
+
* `persisted === null` = nothing to merge.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mergeUsage(persisted: StoredUsage | null, current: StoredUsage, now: number): StoredUsage;
|
|
37
|
+
/** Load persisted usage from the new key. null when absent/malformed. */
|
|
38
|
+
export declare function loadUsage(storage: KVStorage, storeId: string, now: number): StoredUsage | null;
|
|
39
|
+
/**
|
|
40
|
+
* One-time migration source: read the legacy StoredLimits blob and normalize it
|
|
41
|
+
* to StoredUsage. Handles the array format and the old generationCount format,
|
|
42
|
+
* exactly as the legacy normalizeStored did. Returns null only when the legacy
|
|
43
|
+
* key is absent/malformed. Never deletes the legacy key.
|
|
44
|
+
*/
|
|
45
|
+
export declare function migrateLegacyUsage(storage: KVStorage, now: number): StoredUsage | null;
|