@fluid-app/sites 0.0.0-stage → 0.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.
- package/dist/index.d.mts +394 -0
- package/dist/index.mjs +2241 -0
- package/dist/sites.js +2241 -0
- package/package.json +49 -3
- package/README.md +0 -3
package/dist/sites.js
ADDED
|
@@ -0,0 +1,2241 @@
|
|
|
1
|
+
//#region ../sites-contracts/src/routes.ts
|
|
2
|
+
/** The reserved routes every frame host and site host answers. */
|
|
3
|
+
const RESERVED_ROUTES = {
|
|
4
|
+
me: "/_fluid/me",
|
|
5
|
+
api: "/_fluid/api/",
|
|
6
|
+
db: "/_fluid/db",
|
|
7
|
+
fn: "/_fluid/fn/",
|
|
8
|
+
sdk: "/_fluid/sdk/v1/sites.js",
|
|
9
|
+
frame: "/_fluid/frame",
|
|
10
|
+
static: "/_fluid/static/",
|
|
11
|
+
authSignIn: "/_fluid/auth/sign-in",
|
|
12
|
+
authCallback: "/_fluid/auth/callback",
|
|
13
|
+
authSignOut: "/_fluid/auth/sign-out"
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region ../sites-contracts/src/shell.ts
|
|
17
|
+
/**
|
|
18
|
+
* Contracts section 2: the storefront shell (V1) and its messages with the
|
|
19
|
+
* frame.
|
|
20
|
+
*
|
|
21
|
+
* The address bar travels on `window.postMessage`; the token, history, the
|
|
22
|
+
* cart and sign-in travel on one `MessagePort` pinned to the frame host's
|
|
23
|
+
* origin.
|
|
24
|
+
*/
|
|
25
|
+
/** The namespace every message between the shell and the frame carries. */
|
|
26
|
+
const SHELL_MESSAGE_NAMESPACE = "fluid-site";
|
|
27
|
+
/** The shape an address-bar fragment must have to reach the frame. */
|
|
28
|
+
const ELEMENT_ID_FRAGMENT = /^#[A-Za-z0-9_.:-]{1,128}$/;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region ../sites-contracts/src/api.ts
|
|
31
|
+
/**
|
|
32
|
+
* Contracts section 6: the `/_fluid/api` scope map and the company policy.
|
|
33
|
+
*
|
|
34
|
+
* Site code calls the Fluid API on its own origin. `/_fluid/api/v2026-10/<path>`
|
|
35
|
+
* is the Member API's `/api/member/v2026-10/<path>`, as the viewer, within the
|
|
36
|
+
* site's grants.
|
|
37
|
+
*/
|
|
38
|
+
/** The Member API version `/_fluid/api` serves. */
|
|
39
|
+
const FLUID_API_VERSION = "v2026-10";
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region ../sites-contracts/src/query.ts
|
|
42
|
+
/** The HTTP status each `DbError` code answers with. A success is 200. */
|
|
43
|
+
const DB_ERROR_STATUS = {
|
|
44
|
+
validation: 400,
|
|
45
|
+
rule_denied: 403,
|
|
46
|
+
no_database: 404,
|
|
47
|
+
unique_violation: 409,
|
|
48
|
+
quota_exceeded: 429,
|
|
49
|
+
unavailable: 503,
|
|
50
|
+
timeout: 504
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region ../sites-contracts/src/fn.ts
|
|
54
|
+
/** The pattern every function name matches. */
|
|
55
|
+
const FN_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region ../sites-contracts/src/sdk.ts
|
|
58
|
+
/** The error a Supabase method this SDK leaves out throws, naming the method. */
|
|
59
|
+
const NOT_SUPPORTED = "not_supported";
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region ../sites-contracts/src/credit.ts
|
|
62
|
+
/**
|
|
63
|
+
* Contracts section 12: FairShare links.
|
|
64
|
+
*
|
|
65
|
+
* The link a rep shares is a native storefront link:
|
|
66
|
+
* `<storefront>/accounts/<handle>[/<path>]`, or
|
|
67
|
+
* `<storefront>/<credit>/accounts/<handle>[/<path>]` to carry a sharer's
|
|
68
|
+
* credit.
|
|
69
|
+
*/
|
|
70
|
+
/** A company-owned site's credit, and the storefront's default. */
|
|
71
|
+
const DEFAULT_CREDIT = "home";
|
|
72
|
+
/** The attribute that leaves a storefront link's href alone. */
|
|
73
|
+
const CREDIT_OPT_OUT_ATTRIBUTE = "data-fluid-credit";
|
|
74
|
+
/**
|
|
75
|
+
* Public: Whether a credit has the shape Rails accepts before it checks the
|
|
76
|
+
* username: 1 to 64 characters, with no "/".
|
|
77
|
+
*
|
|
78
|
+
* credit - The credit from the link.
|
|
79
|
+
*
|
|
80
|
+
* Returns true when it may be a username.
|
|
81
|
+
*/
|
|
82
|
+
function isCreditShaped(credit) {
|
|
83
|
+
return credit.length > 0 && credit.length <= 64 && !credit.includes("/");
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region ../sites-contracts/src/storefront.ts
|
|
87
|
+
/**
|
|
88
|
+
* Contracts section 12: the storefront paths a FairShare credit may lead.
|
|
89
|
+
*
|
|
90
|
+
* Rails serves a credited storefront page at `/<credit>/<path>` only for the
|
|
91
|
+
* paths drawn in `config/routes/storefront/affiliate_pages.rb` and
|
|
92
|
+
* `member_site_lists.rb`, plus the storefront's root, `/<credit>`, and a
|
|
93
|
+
* member site, `/<credit>/accounts/<handle>[/<path>]`. A link the SDK or the
|
|
94
|
+
* shell credits must land on one of them, so these lists are copies of the
|
|
95
|
+
* routes, and a test holds each copy equal to its Ruby source.
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* The paths after `/<credit>` that Rails serves with a GET, in the order the
|
|
99
|
+
* routes draw them. `:slug` stands for any one segment, whatever the route
|
|
100
|
+
* names it. The root, `/<credit>`, and `/<credit>/accounts/<handle>` are not
|
|
101
|
+
* here: the root is always served, and a site address has its own rule.
|
|
102
|
+
*/
|
|
103
|
+
const CREDITED_STOREFRONT_PATHS = [
|
|
104
|
+
"pages",
|
|
105
|
+
"pages/:slug",
|
|
106
|
+
"categories/:slug",
|
|
107
|
+
"categories",
|
|
108
|
+
"collections",
|
|
109
|
+
"collections/:slug",
|
|
110
|
+
"blog",
|
|
111
|
+
"posts/:slug",
|
|
112
|
+
"shop",
|
|
113
|
+
"join",
|
|
114
|
+
"learn-more/:slug",
|
|
115
|
+
"success",
|
|
116
|
+
"watch/:slug",
|
|
117
|
+
"libraries",
|
|
118
|
+
"libraries/:slug",
|
|
119
|
+
"enrollments",
|
|
120
|
+
"enrollments/:slug",
|
|
121
|
+
"post/:slug",
|
|
122
|
+
"media",
|
|
123
|
+
"media/:slug",
|
|
124
|
+
"page/:slug",
|
|
125
|
+
"promo/:slug",
|
|
126
|
+
"shop/:slug",
|
|
127
|
+
"products/:slug",
|
|
128
|
+
"products",
|
|
129
|
+
"library/:slug",
|
|
130
|
+
"sites"
|
|
131
|
+
];
|
|
132
|
+
/**
|
|
133
|
+
* The member account screens, `Themes::MemberStorefront::SystemScreens.keys`,
|
|
134
|
+
* in catalogue order. `/accounts/<key>` is a screen, never a site, and takes
|
|
135
|
+
* no credit.
|
|
136
|
+
*/
|
|
137
|
+
const MEMBER_ACCOUNT_SCREEN_KEYS = [
|
|
138
|
+
"messaging",
|
|
139
|
+
"contacts",
|
|
140
|
+
"my-site",
|
|
141
|
+
"profile",
|
|
142
|
+
"orders",
|
|
143
|
+
"subscriptions",
|
|
144
|
+
"media",
|
|
145
|
+
"playlists"
|
|
146
|
+
];
|
|
147
|
+
/** A site handle as the `/accounts/<handle>` routes accept it. */
|
|
148
|
+
const SITE_HANDLE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
149
|
+
/**
|
|
150
|
+
* Public: Whether Rails serves a storefront path with a credit in front of it:
|
|
151
|
+
* whether `/<credit>/<segments>` is a credited route.
|
|
152
|
+
*
|
|
153
|
+
* segments - The path's segments after the credit, decoded; `[]` is the
|
|
154
|
+
* root. An empty segment, or one holding a "/", is never served.
|
|
155
|
+
*
|
|
156
|
+
* Examples
|
|
157
|
+
*
|
|
158
|
+
* isCreditedStorefrontPath(["shop"])
|
|
159
|
+
* # => true
|
|
160
|
+
*
|
|
161
|
+
* isCreditedStorefrontPath(["accounts", "diamond-leaders"])
|
|
162
|
+
* # => true
|
|
163
|
+
*
|
|
164
|
+
* isCreditedStorefrontPath(["accounts", "orders"])
|
|
165
|
+
* # => false
|
|
166
|
+
*
|
|
167
|
+
* isCreditedStorefrontPath(["privacy-policy"])
|
|
168
|
+
* # => false
|
|
169
|
+
*
|
|
170
|
+
* Returns true when the credited path is served.
|
|
171
|
+
*/
|
|
172
|
+
function isCreditedStorefrontPath(segments) {
|
|
173
|
+
if (segments.some((segment) => segment === "" || segment.includes("/"))) return false;
|
|
174
|
+
const [first, second, third] = segments;
|
|
175
|
+
if (first === void 0) return true;
|
|
176
|
+
if (first === "accounts") return second !== void 0 && SITE_HANDLE.test(second) && !MEMBER_ACCOUNT_SCREEN_KEYS.includes(second) && !(third?.startsWith("_") ?? false);
|
|
177
|
+
if (segments.length > 2) return false;
|
|
178
|
+
const shape = second === void 0 ? first : `${first}/:slug`;
|
|
179
|
+
return CREDITED_STOREFRONT_PATHS.includes(shape);
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/errors.ts
|
|
183
|
+
/**
|
|
184
|
+
* The error the SDK throws when `/_fluid/*` answers with a status that is not
|
|
185
|
+
* 2xx, such as 401 `sign_in_required` or 403 `scope_not_granted`.
|
|
186
|
+
*/
|
|
187
|
+
var FluidRequestError = class FluidRequestError extends Error {
|
|
188
|
+
/** The HTTP status. */
|
|
189
|
+
status;
|
|
190
|
+
/** The body's `error` code, such as `scope_not_granted`, or null when there is none. */
|
|
191
|
+
code;
|
|
192
|
+
/** The parsed JSON body, or null when the body is not JSON. */
|
|
193
|
+
body;
|
|
194
|
+
constructor(status, code, body) {
|
|
195
|
+
super(code === null ? `/_fluid answered ${status}` : `/_fluid answered ${status} ${code}`);
|
|
196
|
+
this.name = "FluidRequestError";
|
|
197
|
+
this.status = status;
|
|
198
|
+
this.code = code;
|
|
199
|
+
this.body = body;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Read a failed response into an error.
|
|
203
|
+
*
|
|
204
|
+
* response - A response whose status is not 2xx.
|
|
205
|
+
*
|
|
206
|
+
* Returns the error, carrying the body's `error` code when there is one.
|
|
207
|
+
*/
|
|
208
|
+
static async fromResponse(response) {
|
|
209
|
+
const body = await response.json().catch(() => null);
|
|
210
|
+
const code = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" ? body.error : null;
|
|
211
|
+
return new FluidRequestError(response.status, code, body);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Internal: parse a 2xx JSON response, or throw `FluidRequestError`.
|
|
216
|
+
*
|
|
217
|
+
* Returns the parsed body.
|
|
218
|
+
* Throws FluidRequestError when the status is not 2xx.
|
|
219
|
+
*/
|
|
220
|
+
async function readJson(response) {
|
|
221
|
+
if (!response.ok) throw await FluidRequestError.fromResponse(response);
|
|
222
|
+
return await response.json();
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The error a Supabase method that `fluid.db` leaves out throws, such as
|
|
226
|
+
* `upsert`, `rpc` or a join, so the caller learns at once which one it was
|
|
227
|
+
* (contracts section 11).
|
|
228
|
+
*/
|
|
229
|
+
var NotSupportedError = class extends Error {
|
|
230
|
+
/** Always `not_supported`. */
|
|
231
|
+
code = NOT_SUPPORTED;
|
|
232
|
+
/** The method or feature that is not supported, such as `upsert`. */
|
|
233
|
+
method;
|
|
234
|
+
constructor(method) {
|
|
235
|
+
super(`${NOT_SUPPORTED}: fluid.db does not support ${method}`);
|
|
236
|
+
this.name = "NotSupportedError";
|
|
237
|
+
this.method = method;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/transport.ts
|
|
242
|
+
/**
|
|
243
|
+
* Internal: the transport of the page the SDK runs in, read at call time.
|
|
244
|
+
*
|
|
245
|
+
* holder - The page's token holder.
|
|
246
|
+
*
|
|
247
|
+
* Returns the transport.
|
|
248
|
+
*/
|
|
249
|
+
function pageTransport(holder) {
|
|
250
|
+
return {
|
|
251
|
+
origin: () => globalThis.location.origin,
|
|
252
|
+
fetch: (input, init) => globalThis.fetch(input, init),
|
|
253
|
+
token: () => holder.token(),
|
|
254
|
+
tokenSettled: () => holder.settled()
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Internal: send one request to the site's own origin once the first token
|
|
259
|
+
* came, or three seconds passed, with `Authorization: Bearer <token>` when a
|
|
260
|
+
* token is held.
|
|
261
|
+
*
|
|
262
|
+
* transport - Where to send it and with which token.
|
|
263
|
+
* url - The absolute URL to call.
|
|
264
|
+
* init - The request's options; an `Authorization` header already set wins.
|
|
265
|
+
*
|
|
266
|
+
* Returns the response.
|
|
267
|
+
* Throws TypeError when the URL is not on the site's own origin, so the token
|
|
268
|
+
* never leaves it.
|
|
269
|
+
*/
|
|
270
|
+
async function sendToOwnOrigin(transport, url, init = {}) {
|
|
271
|
+
checkOwnOrigin(transport, url);
|
|
272
|
+
await transport.tokenSettled();
|
|
273
|
+
return sendAtOnce(transport, url, init);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Internal: send one request to the site's own origin now, without waiting
|
|
277
|
+
* for the token. Only the handshake's first `/_fluid/me` goes this way.
|
|
278
|
+
*
|
|
279
|
+
* transport - Where to send it and with which token.
|
|
280
|
+
* url - The absolute URL to call.
|
|
281
|
+
* init - The request's options; an `Authorization` header already set wins.
|
|
282
|
+
*
|
|
283
|
+
* Returns the response.
|
|
284
|
+
* Throws TypeError when the URL is not on the site's own origin.
|
|
285
|
+
*/
|
|
286
|
+
async function sendAtOnce(transport, url, init = {}) {
|
|
287
|
+
checkOwnOrigin(transport, url);
|
|
288
|
+
const headers = new Headers(init.headers);
|
|
289
|
+
const token = transport.token();
|
|
290
|
+
if (token !== null && !headers.has("Authorization")) headers.set("Authorization", `Bearer ${token}`);
|
|
291
|
+
return transport.fetch(url.href, {
|
|
292
|
+
...init,
|
|
293
|
+
headers
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/** Throws TypeError when the URL is not on the site's own origin. */
|
|
297
|
+
function checkOwnOrigin(transport, url) {
|
|
298
|
+
if (url.origin !== transport.origin()) throw new TypeError(`@fluid-app/sites calls only its own origin, not ${url.origin}`);
|
|
299
|
+
}
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/api.ts
|
|
302
|
+
/**
|
|
303
|
+
* `fluid.api`: the Fluid API, as the viewer, within the site's grants
|
|
304
|
+
* (contracts sections 6 and 11).
|
|
305
|
+
*
|
|
306
|
+
* `/_fluid/api/v2026-10/<path>` on the site's own origin is the Member API's
|
|
307
|
+
* `/api/member/v2026-10/<path>`.
|
|
308
|
+
*/
|
|
309
|
+
/** The path every `fluid.api` call goes under, `/_fluid/api/v2026-10`. */
|
|
310
|
+
const API_PATH_PREFIX = `${RESERVED_ROUTES.api}${FLUID_API_VERSION}`;
|
|
311
|
+
/**
|
|
312
|
+
* Internal: the URL on the site's own origin that a Fluid API path calls.
|
|
313
|
+
*
|
|
314
|
+
* Throws TypeError when the path does not start with a single `/`, or does
|
|
315
|
+
* not stay under `API_PATH_PREFIX` once resolved.
|
|
316
|
+
*/
|
|
317
|
+
function apiUrl(origin, path, query = {}) {
|
|
318
|
+
if (!path.startsWith("/") || path.startsWith("//")) throw new TypeError(`fluid.api takes a path such as "/orders", not ${JSON.stringify(path)}`);
|
|
319
|
+
const url = new URL(`${API_PATH_PREFIX}${path}`, origin);
|
|
320
|
+
if (url.origin !== origin || !url.pathname.startsWith(`${API_PATH_PREFIX}/`)) throw new TypeError(`fluid.api path ${JSON.stringify(path)} leaves ${API_PATH_PREFIX}`);
|
|
321
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.append(name, value);
|
|
322
|
+
return url;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Internal: build `fluid.api` over a transport.
|
|
326
|
+
*
|
|
327
|
+
* transport - The page's origin, fetch and held token.
|
|
328
|
+
*
|
|
329
|
+
* Returns `fluid.api`.
|
|
330
|
+
*/
|
|
331
|
+
function createApi(transport) {
|
|
332
|
+
return {
|
|
333
|
+
async fetch(path, init) {
|
|
334
|
+
return sendToOwnOrigin(transport, apiUrl(transport.origin(), path), init);
|
|
335
|
+
},
|
|
336
|
+
async get(path, query) {
|
|
337
|
+
return readJson(await sendToOwnOrigin(transport, apiUrl(transport.origin(), path, query), {
|
|
338
|
+
method: "GET",
|
|
339
|
+
headers: { Accept: "application/json" }
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/auth.ts
|
|
346
|
+
/**
|
|
347
|
+
* `fluid.auth`: who is here, and sign-in and sign-out through the shell
|
|
348
|
+
* (contracts sections 2 and 11).
|
|
349
|
+
*/
|
|
350
|
+
/**
|
|
351
|
+
* Internal: build `fluid.auth` over a transport and the link to the shell.
|
|
352
|
+
*
|
|
353
|
+
* transport - The page's origin, fetch and held token.
|
|
354
|
+
* shell - The port to the shell, when one is connected.
|
|
355
|
+
*
|
|
356
|
+
* Returns `fluid.auth`.
|
|
357
|
+
*/
|
|
358
|
+
function createAuth(transport, shell) {
|
|
359
|
+
return {
|
|
360
|
+
async me() {
|
|
361
|
+
return readJson(await sendToOwnOrigin(transport, new URL(RESERVED_ROUTES.me, transport.origin()), { headers: { Accept: "application/json" } }));
|
|
362
|
+
},
|
|
363
|
+
signIn(opts) {
|
|
364
|
+
if (!shell.postOverPort({
|
|
365
|
+
type: "sign_in",
|
|
366
|
+
returnTo: opts?.returnTo ?? null
|
|
367
|
+
})) warnNoShell("signIn");
|
|
368
|
+
},
|
|
369
|
+
async signOut(_opts) {
|
|
370
|
+
if (!shell.postOverPort({ type: "sign_out" })) warnNoShell("signOut");
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/** Log that a call needs the shell's port and there is none. */
|
|
375
|
+
function warnNoShell(method) {
|
|
376
|
+
console.warn(`fluid.auth.${method} does nothing here: the page is not in a Fluid storefront shell, or the shell has not connected`);
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/cart.ts
|
|
380
|
+
/**
|
|
381
|
+
* How long a cart call waits for its `cart_result` before it rejects. The
|
|
382
|
+
* shell answers once FairShare's own call settles, which waits up to three
|
|
383
|
+
* seconds for FairShare to start and then for the storefront's API; thirty
|
|
384
|
+
* seconds is well past both, and a shell that never answers (one without the
|
|
385
|
+
* relay, or a port replaced mid-call) does not leave the call pending forever.
|
|
386
|
+
*/
|
|
387
|
+
const CART_RESULT_TIMEOUT_MS = 3e4;
|
|
388
|
+
/**
|
|
389
|
+
* Internal: build `fluid.cart` over the link to the shell.
|
|
390
|
+
*
|
|
391
|
+
* options - The shell link, when calls may go, and the clock.
|
|
392
|
+
*
|
|
393
|
+
* Returns `fluid.cart`.
|
|
394
|
+
*/
|
|
395
|
+
function createCart(options) {
|
|
396
|
+
const { shell, ready } = options;
|
|
397
|
+
const clock = options.clock ?? {
|
|
398
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
399
|
+
clearTimeout: (handle) => clearTimeout(handle)
|
|
400
|
+
};
|
|
401
|
+
const pending = /* @__PURE__ */ new Map();
|
|
402
|
+
const changeListeners = /* @__PURE__ */ new Set();
|
|
403
|
+
let lastId = 0;
|
|
404
|
+
shell.onPortMessage((data) => {
|
|
405
|
+
if (typeof data !== "object" || data === null) return;
|
|
406
|
+
const message = data;
|
|
407
|
+
if (message["type"] === "cart_changed") {
|
|
408
|
+
for (const listener of changeListeners) listener(message["cart"]);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (message["type"] !== "cart_result") return;
|
|
412
|
+
const id = message["id"];
|
|
413
|
+
const call = typeof id === "string" ? pending.get(id) : void 0;
|
|
414
|
+
if (call === void 0) return;
|
|
415
|
+
pending.delete(id);
|
|
416
|
+
clock.clearTimeout(call.timer);
|
|
417
|
+
if (message["ok"] === true) call.resolve(message["value"]);
|
|
418
|
+
else call.reject(new Error(typeof message["error"] === "string" ? message["error"] : "cart_failed"));
|
|
419
|
+
});
|
|
420
|
+
const send = async (op, args) => {
|
|
421
|
+
await ready();
|
|
422
|
+
lastId += 1;
|
|
423
|
+
const id = `cart-${lastId}`;
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const message = {
|
|
426
|
+
type: "cart",
|
|
427
|
+
id,
|
|
428
|
+
op,
|
|
429
|
+
args
|
|
430
|
+
};
|
|
431
|
+
const timer = clock.setTimeout(() => {
|
|
432
|
+
pending.delete(id);
|
|
433
|
+
reject(/* @__PURE__ */ new Error(`fluid.cart.${op}: the shell did not answer within ${CART_RESULT_TIMEOUT_MS / 1e3} seconds`));
|
|
434
|
+
}, CART_RESULT_TIMEOUT_MS);
|
|
435
|
+
pending.set(id, {
|
|
436
|
+
resolve,
|
|
437
|
+
reject,
|
|
438
|
+
timer
|
|
439
|
+
});
|
|
440
|
+
if (shell.postOverPort(message)) return;
|
|
441
|
+
pending.delete(id);
|
|
442
|
+
clock.clearTimeout(timer);
|
|
443
|
+
reject(new Error(noShellMessage(op)));
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
const sendAndLog = (op) => {
|
|
447
|
+
send(op, []).catch((error) => console.warn(`fluid.cart.${op} failed`, error));
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
add: (...args) => send("add", args),
|
|
451
|
+
update: (...args) => send("update", args),
|
|
452
|
+
remove: (...args) => send("remove", args),
|
|
453
|
+
get: () => send("get", []),
|
|
454
|
+
open: () => sendAndLog("open"),
|
|
455
|
+
close: () => sendAndLog("close"),
|
|
456
|
+
checkout: () => sendAndLog("checkout"),
|
|
457
|
+
onChange(listener) {
|
|
458
|
+
changeListeners.add(listener);
|
|
459
|
+
return () => void changeListeners.delete(listener);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** Why a cart call failed with no port. */
|
|
464
|
+
function noShellMessage(op) {
|
|
465
|
+
return `fluid.cart.${op} needs the storefront shell: the page is not in a Fluid storefront shell, or the shell has not connected`;
|
|
466
|
+
}
|
|
467
|
+
/** FairShare's declarative cart attributes, the `data-fluid-add-to-cart` family. */
|
|
468
|
+
const CART_ATTRIBUTES = "[data-fluid-cart],[data-fluid-add-combined],[data-fluid-add-enrollment-pack],[data-fluid-add-to-cart]";
|
|
469
|
+
/**
|
|
470
|
+
* Internal: relay a click on FairShare's declarative cart attributes, read the
|
|
471
|
+
* way FairShare's own script reads them (`web-widgets/src/web-components/cdn.ts`):
|
|
472
|
+
*
|
|
473
|
+
* - `data-fluid-add-to-cart="501"` or `"501,502"` → `add([{ variant_id, quantity, subscribe, ... }])`,
|
|
474
|
+
* with `data-fluid-quantity`, `data-fluid-subscribe` (one value, or one per
|
|
475
|
+
* variant), `data-fluid-subscription-plan-id` and `data-fluid-bundled-items`
|
|
476
|
+
* (JSON); then `open`, unless `data-fluid-open-cart-after-add="false"`.
|
|
477
|
+
* - `data-fluid-cart="open"` or `"close"` → `open` or `close`.
|
|
478
|
+
*
|
|
479
|
+
* Enrollment packs (`data-fluid-add-enrollment-pack`, `data-fluid-add-combined`)
|
|
480
|
+
* and `data-fluid-cart="toggle"` have no cart op to relay, so they log a
|
|
481
|
+
* warning and do nothing.
|
|
482
|
+
*
|
|
483
|
+
* source - Where clicks bubble to, usually `document`.
|
|
484
|
+
* cart - `fluid.cart`.
|
|
485
|
+
*
|
|
486
|
+
* Returns nothing.
|
|
487
|
+
*/
|
|
488
|
+
function relayCartClicks(source, cart) {
|
|
489
|
+
source.addEventListener("click", (event) => {
|
|
490
|
+
const clicked = event.target;
|
|
491
|
+
if (typeof clicked?.closest !== "function") return;
|
|
492
|
+
const target = clicked.closest(CART_ATTRIBUTES);
|
|
493
|
+
if (target === null) return;
|
|
494
|
+
const control = target.getAttribute("data-fluid-cart");
|
|
495
|
+
if (control === "open") cart.open();
|
|
496
|
+
else if (control === "close") cart.close();
|
|
497
|
+
else if (control !== null) console.warn(`fluid.cart has no relay for data-fluid-cart="${control}"`);
|
|
498
|
+
if (target.getAttribute("data-fluid-add-enrollment-pack") !== null || target.getAttribute("data-fluid-add-combined") !== null) {
|
|
499
|
+
console.warn("fluid.cart cannot add an enrollment pack from a site: the cart relay has no op for it");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const variants = target.getAttribute("data-fluid-add-to-cart");
|
|
503
|
+
if (variants === null) return;
|
|
504
|
+
const items = readAddToCartItems(target, variants);
|
|
505
|
+
if (items === null) {
|
|
506
|
+
console.error(`fluid.cart: data-fluid-add-to-cart="${variants}" is not a comma list of variant ids`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
cart.add(items).catch((error) => console.error("fluid.cart: failed to add to cart", error));
|
|
510
|
+
if (target.getAttribute("data-fluid-open-cart-after-add") !== "false") cart.open();
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Read the lines an add-to-cart element adds, as FairShare's script does.
|
|
515
|
+
*
|
|
516
|
+
* target - The element with `data-fluid-add-to-cart`.
|
|
517
|
+
* variants - Its value, a comma list of variant ids.
|
|
518
|
+
*
|
|
519
|
+
* Returns the lines, or null when a variant id is not a number.
|
|
520
|
+
*/
|
|
521
|
+
function readAddToCartItems(target, variants) {
|
|
522
|
+
const variantIds = variants.split(",").map((id) => parseInt(id.trim(), 10));
|
|
523
|
+
if (variantIds.length === 0 || variantIds.some(Number.isNaN)) return null;
|
|
524
|
+
const quantityAttribute = target.getAttribute("data-fluid-quantity");
|
|
525
|
+
const quantity = quantityAttribute ? parseInt(quantityAttribute, 10) : 1;
|
|
526
|
+
const planAttribute = target.getAttribute("data-fluid-subscription-plan-id");
|
|
527
|
+
const parsedPlan = planAttribute ? parseInt(planAttribute.trim(), 10) : NaN;
|
|
528
|
+
const planId = Number.isNaN(parsedPlan) ? void 0 : parsedPlan;
|
|
529
|
+
const subscribeAttribute = target.getAttribute("data-fluid-subscribe");
|
|
530
|
+
const subscribeValues = subscribeAttribute ? subscribeAttribute.includes(",") ? subscribeAttribute.split(",").map((flag) => flag.trim().toLowerCase() === "true") : variantIds.map(() => subscribeAttribute.toLowerCase() === "true") : null;
|
|
531
|
+
const bundledItems = readBundledItems(target);
|
|
532
|
+
return variantIds.map((variantId, index) => ({
|
|
533
|
+
variant_id: variantId,
|
|
534
|
+
quantity,
|
|
535
|
+
subscribe: subscribeValues?.[index] ?? planId !== void 0,
|
|
536
|
+
...planId === void 0 ? {} : { subscription_plan_id: planId },
|
|
537
|
+
...bundledItems === void 0 ? {} : { bundled_items: bundledItems }
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
/** `data-fluid-bundled-items`, parsed; undefined when absent or not a JSON array. */
|
|
541
|
+
function readBundledItems(target) {
|
|
542
|
+
const attribute = target.getAttribute("data-fluid-bundled-items");
|
|
543
|
+
if (attribute === null) return void 0;
|
|
544
|
+
try {
|
|
545
|
+
const parsed = JSON.parse(attribute);
|
|
546
|
+
if (Array.isArray(parsed)) return parsed;
|
|
547
|
+
} catch {}
|
|
548
|
+
console.error("fluid.cart: data-fluid-bundled-items is not a JSON array");
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/db/or-parser.ts
|
|
552
|
+
const COMPARISON_OPERATORS = new Set([
|
|
553
|
+
"eq",
|
|
554
|
+
"neq",
|
|
555
|
+
"gt",
|
|
556
|
+
"gte",
|
|
557
|
+
"lt",
|
|
558
|
+
"lte"
|
|
559
|
+
]);
|
|
560
|
+
const GROUP_PATTERN = /^(not\.)?(and|or)\(([\s\S]*)\)$/;
|
|
561
|
+
const NUMBER_PATTERN = /^-?(0|[1-9]\d*)(\.\d+)?$/;
|
|
562
|
+
/**
|
|
563
|
+
* Internal: the condition a Supabase filter operator makes of a column and a
|
|
564
|
+
* typed value, as `.not(column, operator, value)` and `.or(...)` name them.
|
|
565
|
+
*
|
|
566
|
+
* column - The column's name.
|
|
567
|
+
* operator - `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `is`, `like` or
|
|
568
|
+
* `ilike`.
|
|
569
|
+
* value - The value: a list for `in`, `null` for `is`, text for `like`.
|
|
570
|
+
*
|
|
571
|
+
* Returns the condition.
|
|
572
|
+
* Throws NotSupportedError for any other operator, and for `is` with a value
|
|
573
|
+
* that is not null.
|
|
574
|
+
* Throws TypeError when the value does not fit the operator.
|
|
575
|
+
*/
|
|
576
|
+
function conditionOf(column, operator, value) {
|
|
577
|
+
if (COMPARISON_OPERATORS.has(operator)) return {
|
|
578
|
+
column,
|
|
579
|
+
op: operator,
|
|
580
|
+
value
|
|
581
|
+
};
|
|
582
|
+
switch (operator) {
|
|
583
|
+
case "in":
|
|
584
|
+
if (!Array.isArray(value)) throw new TypeError(`in on ${column} takes a list of values`);
|
|
585
|
+
return {
|
|
586
|
+
column,
|
|
587
|
+
op: "in",
|
|
588
|
+
value
|
|
589
|
+
};
|
|
590
|
+
case "is":
|
|
591
|
+
if (value !== null) throw new NotSupportedError(`is ${JSON.stringify(value)}: only is null`);
|
|
592
|
+
return {
|
|
593
|
+
column,
|
|
594
|
+
op: "is_null",
|
|
595
|
+
value: true
|
|
596
|
+
};
|
|
597
|
+
case "like":
|
|
598
|
+
case "ilike":
|
|
599
|
+
if (typeof value !== "string") throw new TypeError(`${operator} on ${column} takes a text pattern`);
|
|
600
|
+
return {
|
|
601
|
+
column,
|
|
602
|
+
op: operator,
|
|
603
|
+
value
|
|
604
|
+
};
|
|
605
|
+
default: throw new NotSupportedError(`the ${operator} filter operator`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Internal: read `.or(...)`'s filter string.
|
|
610
|
+
*
|
|
611
|
+
* text - Such as `"status.eq.open,and(priority.gt.2,owner.is.null)"`.
|
|
612
|
+
*
|
|
613
|
+
* Returns `{ or: [...] }`, one filter per top-level item.
|
|
614
|
+
* Throws TypeError when the string cannot be read, and NotSupportedError for
|
|
615
|
+
* an operator `fluid.db` leaves out.
|
|
616
|
+
*/
|
|
617
|
+
function parseOrFilter(text) {
|
|
618
|
+
return { or: splitItems(text).map(parseItem) };
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Internal: read a PostgREST list, `(a,b,"c,d")`, as `.not(column, "in",
|
|
622
|
+
* "(a,b)")` and `in.(a,b)` write it.
|
|
623
|
+
*
|
|
624
|
+
* Returns the values, each read as the module comment says.
|
|
625
|
+
* Throws TypeError when the text is not a parenthesised list.
|
|
626
|
+
*/
|
|
627
|
+
function parseList(text) {
|
|
628
|
+
if (!text.startsWith("(") || !text.endsWith(")")) throw new TypeError(`a list is written (a,b,c), not ${JSON.stringify(text)}`);
|
|
629
|
+
const inner = text.slice(1, -1);
|
|
630
|
+
return inner.trim() === "" ? [] : splitItems(inner).map(readScalar);
|
|
631
|
+
}
|
|
632
|
+
/** Split at the commas outside parentheses and quotes, trimming each item. */
|
|
633
|
+
function splitItems(text) {
|
|
634
|
+
const items = [];
|
|
635
|
+
let depth = 0;
|
|
636
|
+
let quoted = false;
|
|
637
|
+
let start = 0;
|
|
638
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
639
|
+
const character = text[index];
|
|
640
|
+
if (quoted) {
|
|
641
|
+
if (character === "\\") index += 1;
|
|
642
|
+
else if (character === "\"") quoted = false;
|
|
643
|
+
} else if (character === "\"") quoted = true;
|
|
644
|
+
else if (character === "(") depth += 1;
|
|
645
|
+
else if (character === ")") {
|
|
646
|
+
depth -= 1;
|
|
647
|
+
if (depth < 0) throw unreadable(text, "a ) with no (");
|
|
648
|
+
} else if (character === "," && depth === 0) {
|
|
649
|
+
items.push(text.slice(start, index).trim());
|
|
650
|
+
start = index + 1;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (quoted) throw unreadable(text, "a \" with no closing \"");
|
|
654
|
+
if (depth !== 0) throw unreadable(text, "a ( with no )");
|
|
655
|
+
items.push(text.slice(start).trim());
|
|
656
|
+
if (items.some((item) => item === "")) throw unreadable(text, "an empty item");
|
|
657
|
+
return items;
|
|
658
|
+
}
|
|
659
|
+
/** One item: a nested group, or `column[.not].op.value`. */
|
|
660
|
+
function parseItem(item) {
|
|
661
|
+
const group = GROUP_PATTERN.exec(item);
|
|
662
|
+
if (group !== null) {
|
|
663
|
+
const [, negated, kind, inner = ""] = group;
|
|
664
|
+
const children = splitItems(inner).map(parseItem);
|
|
665
|
+
const filter = kind === "and" ? { and: children } : { or: children };
|
|
666
|
+
return negated === void 0 ? filter : { not: filter };
|
|
667
|
+
}
|
|
668
|
+
const columnEnd = item.indexOf(".");
|
|
669
|
+
if (columnEnd <= 0) throw unreadable(item, "no column.operator.value in it");
|
|
670
|
+
const column = item.slice(0, columnEnd);
|
|
671
|
+
let rest = item.slice(columnEnd + 1);
|
|
672
|
+
const negated = rest.startsWith("not.");
|
|
673
|
+
if (negated) rest = rest.slice(4);
|
|
674
|
+
const operatorEnd = rest.indexOf(".");
|
|
675
|
+
if (operatorEnd <= 0) throw unreadable(item, "no operator.value in it");
|
|
676
|
+
const operator = rest.slice(0, operatorEnd);
|
|
677
|
+
const condition = conditionOf(column, operator, readValue(operator, rest.slice(operatorEnd + 1)));
|
|
678
|
+
return negated ? { not: condition } : condition;
|
|
679
|
+
}
|
|
680
|
+
/** An item's value, read for its operator. */
|
|
681
|
+
function readValue(operator, raw) {
|
|
682
|
+
if (operator === "in") return parseList(raw);
|
|
683
|
+
if (operator === "like" || operator === "ilike") return unquote(raw).replaceAll("*", "%");
|
|
684
|
+
return readScalar(raw);
|
|
685
|
+
}
|
|
686
|
+
/** A single value: quoted text, null, a boolean, a number, or text. */
|
|
687
|
+
function readScalar(raw) {
|
|
688
|
+
if (isQuoted(raw)) return unquote(raw);
|
|
689
|
+
if (raw === "null") return null;
|
|
690
|
+
if (raw === "true") return true;
|
|
691
|
+
if (raw === "false") return false;
|
|
692
|
+
if (NUMBER_PATTERN.test(raw)) {
|
|
693
|
+
const number = Number(raw);
|
|
694
|
+
if (!Number.isInteger(number) || Number.isSafeInteger(number)) return number;
|
|
695
|
+
}
|
|
696
|
+
return raw;
|
|
697
|
+
}
|
|
698
|
+
function isQuoted(raw) {
|
|
699
|
+
return raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"");
|
|
700
|
+
}
|
|
701
|
+
/** Drop the quotes and the backslashes that escape within them. */
|
|
702
|
+
function unquote(raw) {
|
|
703
|
+
return isQuoted(raw) ? raw.slice(1, -1).replaceAll(/\\(.)/g, "$1") : raw;
|
|
704
|
+
}
|
|
705
|
+
function unreadable(text, problem) {
|
|
706
|
+
return /* @__PURE__ */ new TypeError(`fluid.db cannot read the filter ${JSON.stringify(text)}: ${problem}`);
|
|
707
|
+
}
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/db/builder.ts
|
|
710
|
+
/**
|
|
711
|
+
* `fluid.db`: the site's database, Supabase-style (contracts sections 8 and
|
|
712
|
+
* 11).
|
|
713
|
+
*
|
|
714
|
+
* `fluid.db.from(table)` starts a `QueryBuilder`. Each call adds to one
|
|
715
|
+
* `DbRequestV1`; awaiting the builder sends it to `POST /_fluid/db` on the
|
|
716
|
+
* site's own origin, with the held token, and resolves `{ data, error }`
|
|
717
|
+
* whatever the HTTP status. Every Supabase method the contract leaves out,
|
|
718
|
+
* such as `upsert`, joins and `rpc`, throws `NotSupportedError` naming it.
|
|
719
|
+
*/
|
|
720
|
+
/** The `QueryBuilder` methods Supabase has and `fluid.db` leaves out. */
|
|
721
|
+
const NOT_SUPPORTED_QUERY_METHODS = [
|
|
722
|
+
"upsert",
|
|
723
|
+
"likeAllOf",
|
|
724
|
+
"likeAnyOf",
|
|
725
|
+
"ilikeAllOf",
|
|
726
|
+
"ilikeAnyOf",
|
|
727
|
+
"regexMatch",
|
|
728
|
+
"regexIMatch",
|
|
729
|
+
"isDistinct",
|
|
730
|
+
"contains",
|
|
731
|
+
"containedBy",
|
|
732
|
+
"rangeGt",
|
|
733
|
+
"rangeGte",
|
|
734
|
+
"rangeLt",
|
|
735
|
+
"rangeLte",
|
|
736
|
+
"rangeAdjacent",
|
|
737
|
+
"overlaps",
|
|
738
|
+
"textSearch",
|
|
739
|
+
"filter",
|
|
740
|
+
"abortSignal",
|
|
741
|
+
"csv",
|
|
742
|
+
"geojson",
|
|
743
|
+
"explain",
|
|
744
|
+
"rollback",
|
|
745
|
+
"returns",
|
|
746
|
+
"overrideTypes",
|
|
747
|
+
"maxAffected",
|
|
748
|
+
"setHeader",
|
|
749
|
+
"throwOnError"
|
|
750
|
+
];
|
|
751
|
+
/** The Supabase client methods beside `from` that `fluid.db` leaves out. */
|
|
752
|
+
const NOT_SUPPORTED_DB_METHODS = [
|
|
753
|
+
"rpc",
|
|
754
|
+
"schema",
|
|
755
|
+
"channel",
|
|
756
|
+
"getChannels",
|
|
757
|
+
"removeChannel",
|
|
758
|
+
"removeAllChannels"
|
|
759
|
+
];
|
|
760
|
+
/** Internal: one `fluid.db.from(table)` query, built up call by call. */
|
|
761
|
+
var DbQuery = class {
|
|
762
|
+
#transport;
|
|
763
|
+
#table;
|
|
764
|
+
#op;
|
|
765
|
+
#columns;
|
|
766
|
+
#count;
|
|
767
|
+
#returning = false;
|
|
768
|
+
#rows;
|
|
769
|
+
#set;
|
|
770
|
+
#filters = [];
|
|
771
|
+
#order = [];
|
|
772
|
+
#limit;
|
|
773
|
+
#offset;
|
|
774
|
+
constructor(transport, table) {
|
|
775
|
+
this.#transport = transport;
|
|
776
|
+
this.#table = table;
|
|
777
|
+
}
|
|
778
|
+
select(columns, opts) {
|
|
779
|
+
if (this.#op === "select" || this.#returning) throw new TypeError("select() is already on this query");
|
|
780
|
+
if (this.#op === void 0) {
|
|
781
|
+
this.#op = "select";
|
|
782
|
+
this.#columns = readColumns(columns);
|
|
783
|
+
this.#count = opts?.count;
|
|
784
|
+
return this;
|
|
785
|
+
}
|
|
786
|
+
if (readColumns(columns) !== void 0) throw new NotSupportedError(`select(${JSON.stringify(columns)}) after ${this.#op}: it returns every column`);
|
|
787
|
+
if (opts?.count !== void 0) throw new NotSupportedError(`count after ${this.#op}`);
|
|
788
|
+
this.#returning = true;
|
|
789
|
+
return this;
|
|
790
|
+
}
|
|
791
|
+
insert(rows) {
|
|
792
|
+
this.#begin("insert");
|
|
793
|
+
this.#rows = Array.isArray(rows) ? [...rows] : [rows];
|
|
794
|
+
return this;
|
|
795
|
+
}
|
|
796
|
+
update(set) {
|
|
797
|
+
this.#begin("update");
|
|
798
|
+
this.#set = set;
|
|
799
|
+
return this;
|
|
800
|
+
}
|
|
801
|
+
delete() {
|
|
802
|
+
this.#begin("delete");
|
|
803
|
+
return this;
|
|
804
|
+
}
|
|
805
|
+
eq(column, value) {
|
|
806
|
+
return this.#where({
|
|
807
|
+
column,
|
|
808
|
+
op: "eq",
|
|
809
|
+
value
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
neq(column, value) {
|
|
813
|
+
return this.#where({
|
|
814
|
+
column,
|
|
815
|
+
op: "neq",
|
|
816
|
+
value
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
gt(column, value) {
|
|
820
|
+
return this.#where({
|
|
821
|
+
column,
|
|
822
|
+
op: "gt",
|
|
823
|
+
value
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
gte(column, value) {
|
|
827
|
+
return this.#where({
|
|
828
|
+
column,
|
|
829
|
+
op: "gte",
|
|
830
|
+
value
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
lt(column, value) {
|
|
834
|
+
return this.#where({
|
|
835
|
+
column,
|
|
836
|
+
op: "lt",
|
|
837
|
+
value
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
lte(column, value) {
|
|
841
|
+
return this.#where({
|
|
842
|
+
column,
|
|
843
|
+
op: "lte",
|
|
844
|
+
value
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
in(column, values) {
|
|
848
|
+
return this.#where(conditionOf(column, "in", values));
|
|
849
|
+
}
|
|
850
|
+
is(column, value) {
|
|
851
|
+
return this.#where(conditionOf(column, "is", value));
|
|
852
|
+
}
|
|
853
|
+
like(column, pattern) {
|
|
854
|
+
return this.#where(conditionOf(column, "like", pattern));
|
|
855
|
+
}
|
|
856
|
+
ilike(column, pattern) {
|
|
857
|
+
return this.#where(conditionOf(column, "ilike", pattern));
|
|
858
|
+
}
|
|
859
|
+
not(column, op, value) {
|
|
860
|
+
const operand = op === "in" && typeof value === "string" ? parseList(value) : value;
|
|
861
|
+
return this.#where({ not: conditionOf(column, op, operand) });
|
|
862
|
+
}
|
|
863
|
+
or(filters) {
|
|
864
|
+
return this.#where(parseOrFilter(filters));
|
|
865
|
+
}
|
|
866
|
+
match(values) {
|
|
867
|
+
return this.#where({ and: Object.entries(values).map(([column, value]) => ({
|
|
868
|
+
column,
|
|
869
|
+
op: "eq",
|
|
870
|
+
value
|
|
871
|
+
})) });
|
|
872
|
+
}
|
|
873
|
+
order(column, opts) {
|
|
874
|
+
const nullsFirst = opts?.nullsFirst;
|
|
875
|
+
this.#order.push({
|
|
876
|
+
column,
|
|
877
|
+
ascending: opts?.ascending ?? true,
|
|
878
|
+
...nullsFirst === void 0 ? {} : { nulls: nullsFirst ? "first" : "last" }
|
|
879
|
+
});
|
|
880
|
+
return this;
|
|
881
|
+
}
|
|
882
|
+
limit(count) {
|
|
883
|
+
this.#limit = count;
|
|
884
|
+
return this;
|
|
885
|
+
}
|
|
886
|
+
range(from, to) {
|
|
887
|
+
this.#offset = from;
|
|
888
|
+
this.#limit = to - from + 1;
|
|
889
|
+
return this;
|
|
890
|
+
}
|
|
891
|
+
single() {
|
|
892
|
+
return this.#one(true);
|
|
893
|
+
}
|
|
894
|
+
maybeSingle() {
|
|
895
|
+
return this.#one(false);
|
|
896
|
+
}
|
|
897
|
+
then(onfulfilled, onrejected) {
|
|
898
|
+
return this.#send().then(onfulfilled, onrejected);
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* The `DbRequestV1` this query sends.
|
|
902
|
+
*
|
|
903
|
+
* Throws TypeError when the calls make no request, such as an update with
|
|
904
|
+
* no filter or an insert with a filter.
|
|
905
|
+
*/
|
|
906
|
+
#request() {
|
|
907
|
+
const op = this.#op ?? "select";
|
|
908
|
+
const table = this.#table;
|
|
909
|
+
const filter = combine(this.#filters);
|
|
910
|
+
const returning = this.#returning ? { returning: true } : {};
|
|
911
|
+
if (op === "select") return {
|
|
912
|
+
op,
|
|
913
|
+
table,
|
|
914
|
+
...this.#columns === void 0 ? {} : { columns: this.#columns },
|
|
915
|
+
...filter === void 0 ? {} : { filter },
|
|
916
|
+
...this.#order.length === 0 ? {} : { order: [...this.#order] },
|
|
917
|
+
...this.#limit === void 0 ? {} : { limit: this.#limit },
|
|
918
|
+
...this.#offset === void 0 ? {} : { offset: this.#offset },
|
|
919
|
+
...this.#count === void 0 ? {} : { count: this.#count }
|
|
920
|
+
};
|
|
921
|
+
if (this.#order.length > 0 || this.#limit !== void 0 || this.#offset !== void 0) throw new TypeError(`${op} takes no order, limit or range`);
|
|
922
|
+
if (op === "insert") {
|
|
923
|
+
if (filter !== void 0) throw new TypeError("insert takes no filter");
|
|
924
|
+
return {
|
|
925
|
+
op,
|
|
926
|
+
table,
|
|
927
|
+
rows: this.#rows ?? [],
|
|
928
|
+
...returning
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
if (filter === void 0) throw new TypeError(`${op} needs a filter, such as .eq("id", id), so it never touches every row`);
|
|
932
|
+
if (op === "update") return {
|
|
933
|
+
op,
|
|
934
|
+
table,
|
|
935
|
+
filter,
|
|
936
|
+
set: this.#set ?? {},
|
|
937
|
+
...returning
|
|
938
|
+
};
|
|
939
|
+
return {
|
|
940
|
+
op,
|
|
941
|
+
table,
|
|
942
|
+
filter,
|
|
943
|
+
...returning
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
#begin(op) {
|
|
947
|
+
if (this.#op !== void 0) throw new TypeError(`${op}() cannot follow ${this.#op}() on one query`);
|
|
948
|
+
this.#op = op;
|
|
949
|
+
}
|
|
950
|
+
#where(filter) {
|
|
951
|
+
this.#filters.push(filter);
|
|
952
|
+
return this;
|
|
953
|
+
}
|
|
954
|
+
async #send() {
|
|
955
|
+
const body = JSON.stringify(this.#request());
|
|
956
|
+
const url = new URL(RESERVED_ROUTES.db, this.#transport.origin());
|
|
957
|
+
let response;
|
|
958
|
+
try {
|
|
959
|
+
response = await sendToOwnOrigin(this.#transport, url, {
|
|
960
|
+
method: "POST",
|
|
961
|
+
headers: {
|
|
962
|
+
Accept: "application/json",
|
|
963
|
+
"Content-Type": "application/json"
|
|
964
|
+
},
|
|
965
|
+
body
|
|
966
|
+
});
|
|
967
|
+
} catch (error) {
|
|
968
|
+
return failure(`${RESERVED_ROUTES.db} could not be reached: ${String(error)}`, null);
|
|
969
|
+
}
|
|
970
|
+
const answer = await response.json().catch(() => null);
|
|
971
|
+
if (isDbResponse(answer)) return answer;
|
|
972
|
+
return failure(`${RESERVED_ROUTES.db} answered ${response.status}`, {
|
|
973
|
+
status: response.status,
|
|
974
|
+
body: answer
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
#one(required) {
|
|
978
|
+
const send = () => this.#send().then((response) => onlyRow(response, required));
|
|
979
|
+
return { then: (onfulfilled, onrejected) => send().then(onfulfilled, onrejected) };
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
for (const method of NOT_SUPPORTED_QUERY_METHODS) Object.defineProperty(DbQuery.prototype, method, { value: notSupported(method) });
|
|
983
|
+
/**
|
|
984
|
+
* Internal: build `fluid.db` over a transport.
|
|
985
|
+
*
|
|
986
|
+
* transport - The page's origin, fetch and held token.
|
|
987
|
+
*
|
|
988
|
+
* Returns `fluid.db`.
|
|
989
|
+
*/
|
|
990
|
+
function createDb(transport) {
|
|
991
|
+
const db = { from: (table) => new DbQuery(transport, table) };
|
|
992
|
+
for (const method of NOT_SUPPORTED_DB_METHODS) Object.defineProperty(db, method, { value: notSupported(method) });
|
|
993
|
+
return db;
|
|
994
|
+
}
|
|
995
|
+
function notSupported(method) {
|
|
996
|
+
return () => {
|
|
997
|
+
throw new NotSupportedError(method);
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* `select`'s column list: undefined for every column, else the names.
|
|
1002
|
+
*
|
|
1003
|
+
* Throws NotSupportedError for a join, an alias, a cast or a JSON path.
|
|
1004
|
+
*/
|
|
1005
|
+
function readColumns(columns) {
|
|
1006
|
+
if (columns === void 0 || columns.trim() === "*") return void 0;
|
|
1007
|
+
if (columns.includes("(")) throw new NotSupportedError(`joins, as in select(${JSON.stringify(columns)})`);
|
|
1008
|
+
const names = columns.split(",").map((name) => name.trim());
|
|
1009
|
+
for (const name of names) if (name.includes(":") || name.includes("->")) throw new NotSupportedError(`aliases, casts and JSON paths, as in select(${JSON.stringify(columns)})`);
|
|
1010
|
+
return names;
|
|
1011
|
+
}
|
|
1012
|
+
/** One filter alone, several under `and`, none as undefined. */
|
|
1013
|
+
function combine(filters) {
|
|
1014
|
+
if (filters.length === 0) return void 0;
|
|
1015
|
+
if (filters.length === 1) return filters[0];
|
|
1016
|
+
return { and: [...filters] };
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Whether an answer is a well-formed `DbResponseV1`: `data` null or an array
|
|
1020
|
+
* of plain-object rows, `count` absent or a finite number, and `error` null
|
|
1021
|
+
* or a `DbError` with a known code and a string message. `details` is any
|
|
1022
|
+
* JSON value, as the contract types it.
|
|
1023
|
+
*/
|
|
1024
|
+
function isDbResponse(answer) {
|
|
1025
|
+
if (!isPlainObject(answer)) return false;
|
|
1026
|
+
if (!("data" in answer) || !("error" in answer)) return false;
|
|
1027
|
+
const { data, error } = answer;
|
|
1028
|
+
const dataIsRows = data === null || Array.isArray(data) && data.every(isPlainObject);
|
|
1029
|
+
const countIsNumber = !("count" in answer) || typeof answer.count === "number" && Number.isFinite(answer.count);
|
|
1030
|
+
return dataIsRows && countIsNumber && (error === null || isDbError(error));
|
|
1031
|
+
}
|
|
1032
|
+
function isDbError(error) {
|
|
1033
|
+
return isPlainObject(error) && typeof error.code === "string" && Object.hasOwn(DB_ERROR_STATUS, error.code) && typeof error.message === "string";
|
|
1034
|
+
}
|
|
1035
|
+
function isPlainObject(value) {
|
|
1036
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1037
|
+
}
|
|
1038
|
+
/** A `DbResponseV1` for an answer that is not one: `unavailable`. */
|
|
1039
|
+
function failure(message, details) {
|
|
1040
|
+
return {
|
|
1041
|
+
data: null,
|
|
1042
|
+
error: {
|
|
1043
|
+
code: "unavailable",
|
|
1044
|
+
message,
|
|
1045
|
+
...details === null ? {} : { details }
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/** `single()` and `maybeSingle()`: the one row, or an error. */
|
|
1050
|
+
function onlyRow(response, required) {
|
|
1051
|
+
if (response.error !== null) return {
|
|
1052
|
+
data: null,
|
|
1053
|
+
error: response.error
|
|
1054
|
+
};
|
|
1055
|
+
const rows = response.data ?? [];
|
|
1056
|
+
if (rows.length === 1) return {
|
|
1057
|
+
data: rows[0] ?? null,
|
|
1058
|
+
error: null
|
|
1059
|
+
};
|
|
1060
|
+
if (rows.length === 0 && !required) return {
|
|
1061
|
+
data: null,
|
|
1062
|
+
error: null
|
|
1063
|
+
};
|
|
1064
|
+
return {
|
|
1065
|
+
data: null,
|
|
1066
|
+
error: {
|
|
1067
|
+
code: "validation",
|
|
1068
|
+
message: `expected ${required ? "exactly one row" : "at most one row"}, the query returned ${rows.length}`,
|
|
1069
|
+
details: { rows: rows.length }
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
//#endregion
|
|
1074
|
+
//#region src/fn.ts
|
|
1075
|
+
/**
|
|
1076
|
+
* `fluid.fn`: the site's own server functions (contracts sections 10 and 11).
|
|
1077
|
+
*
|
|
1078
|
+
* Server functions arrive after V1. Until then `/_fluid/fn/<name>` answers
|
|
1079
|
+
* 404 `fn_not_available`, and `fluid.fn.fetch` returns that response as it
|
|
1080
|
+
* is, so site code written for functions runs unchanged once they ship.
|
|
1081
|
+
*/
|
|
1082
|
+
/**
|
|
1083
|
+
* Internal: build `fluid.fn` over a transport.
|
|
1084
|
+
*
|
|
1085
|
+
* transport - The page's origin, fetch and held token.
|
|
1086
|
+
*
|
|
1087
|
+
* Returns `fluid.fn`.
|
|
1088
|
+
*/
|
|
1089
|
+
function createFn(transport) {
|
|
1090
|
+
return { async fetch(name, init) {
|
|
1091
|
+
if (name.length > 40 || !FN_NAME_PATTERN.test(name)) throw new TypeError(`fluid.fn takes a function name such as "rsvp", not ${JSON.stringify(name)}`);
|
|
1092
|
+
return sendToOwnOrigin(transport, new URL(`${RESERVED_ROUTES.fn}${name}`, transport.origin()), init);
|
|
1093
|
+
} };
|
|
1094
|
+
}
|
|
1095
|
+
//#endregion
|
|
1096
|
+
//#region src/shell/frame-session.ts
|
|
1097
|
+
/**
|
|
1098
|
+
* Internal: renew a private site's frame session cookie with each fresh
|
|
1099
|
+
* shell token (contracts section 5).
|
|
1100
|
+
*
|
|
1101
|
+
* `/_fluid/frame` set the cookie once, before the site's first page. The
|
|
1102
|
+
* cookie lives ten minutes and a token five, so the SDK posts each new token
|
|
1103
|
+
* to `POST /_fluid/frame`, and the gateway renews the cookie on the answer.
|
|
1104
|
+
*/
|
|
1105
|
+
/**
|
|
1106
|
+
* Internal: build the frame session renewer over a transport.
|
|
1107
|
+
*
|
|
1108
|
+
* transport - The page's origin and fetch.
|
|
1109
|
+
*
|
|
1110
|
+
* Returns the renewer.
|
|
1111
|
+
*/
|
|
1112
|
+
function createFrameSession(transport) {
|
|
1113
|
+
return { async renew(token) {
|
|
1114
|
+
try {
|
|
1115
|
+
const response = await sendToOwnOrigin(transport, new URL(RESERVED_ROUTES.frame, transport.origin()), {
|
|
1116
|
+
method: "POST",
|
|
1117
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1118
|
+
});
|
|
1119
|
+
if (response.ok) return true;
|
|
1120
|
+
console.warn(`@fluid-app/sites could not renew the frame session: /_fluid/frame answered ${response.status}`);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
console.warn("@fluid-app/sites could not renew the frame session", error);
|
|
1123
|
+
}
|
|
1124
|
+
return false;
|
|
1125
|
+
} };
|
|
1126
|
+
}
|
|
1127
|
+
//#endregion
|
|
1128
|
+
//#region src/shell/go.ts
|
|
1129
|
+
/**
|
|
1130
|
+
* Internal: follow the shell's `go` (contracts section 2, M-7 and "History:
|
|
1131
|
+
* only the shell adds entries").
|
|
1132
|
+
*
|
|
1133
|
+
* The shell sends `go` over the port on Back and Forward, on a re-sync after
|
|
1134
|
+
* a restore, and after `connect` to pass the address bar's fragment. The SDK
|
|
1135
|
+
* moves the site there: a fragment on the page it shows with `replaceState`
|
|
1136
|
+
* and a scroll, another page through the site's router when one listens on
|
|
1137
|
+
* `fluid.history.onGo`, else with `location.replace`.
|
|
1138
|
+
*/
|
|
1139
|
+
/**
|
|
1140
|
+
* Internal: read a `go` message from the port.
|
|
1141
|
+
*
|
|
1142
|
+
* data - What arrived on the port.
|
|
1143
|
+
*
|
|
1144
|
+
* Returns the message, or null when it is anything else, has the wrong
|
|
1145
|
+
* shape, or carries a fragment that is not an element id.
|
|
1146
|
+
*/
|
|
1147
|
+
function readGoMessage(data) {
|
|
1148
|
+
if (typeof data !== "object" || data === null) return null;
|
|
1149
|
+
const message = data;
|
|
1150
|
+
if (message["type"] !== "go") return null;
|
|
1151
|
+
const { path, hash } = message;
|
|
1152
|
+
if (typeof path !== "string" || path.length === 0 || path.length > 2048) return null;
|
|
1153
|
+
if (hash !== null && (typeof hash !== "string" || !ELEMENT_ID_FRAGMENT.test(hash))) return null;
|
|
1154
|
+
return {
|
|
1155
|
+
type: "go",
|
|
1156
|
+
path,
|
|
1157
|
+
hash
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Internal: move the frame to the page a `go` names.
|
|
1162
|
+
*
|
|
1163
|
+
* go - The message.
|
|
1164
|
+
* options - The page, the address bar, the unpatched `replaceState`, and the
|
|
1165
|
+
* site's router listeners.
|
|
1166
|
+
*
|
|
1167
|
+
* Returns nothing. A path that resolves off the frame's own origin is
|
|
1168
|
+
* ignored.
|
|
1169
|
+
*/
|
|
1170
|
+
function followGo(go, options) {
|
|
1171
|
+
const { page, addressBar, replaceState, routers } = options;
|
|
1172
|
+
const here = new URL(page.href());
|
|
1173
|
+
let target;
|
|
1174
|
+
try {
|
|
1175
|
+
target = new URL(go.path, here.origin);
|
|
1176
|
+
} catch {
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (target.origin !== here.origin) return;
|
|
1180
|
+
const path = `${target.pathname}${target.search}`;
|
|
1181
|
+
const fragment = go.hash ?? "";
|
|
1182
|
+
addressBar.following(path);
|
|
1183
|
+
if (path === `${here.pathname}${here.search}`) {
|
|
1184
|
+
replaceState(page.history.state, "", `${path}${fragment}`);
|
|
1185
|
+
if (go.hash !== null) page.scrollToId(go.hash.slice(1));
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (routers.length > 0) {
|
|
1189
|
+
for (const router of routers) router(path, go.hash);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
page.replaceLocation(`${path}${fragment}`);
|
|
1193
|
+
}
|
|
1194
|
+
//#endregion
|
|
1195
|
+
//#region src/me.ts
|
|
1196
|
+
/**
|
|
1197
|
+
* Internal: the page's first `/_fluid/me`, read once at load with no token
|
|
1198
|
+
* (contracts section 11, "The handshake").
|
|
1199
|
+
*
|
|
1200
|
+
* The handshake reads `shellOrigins` from it, and `fluid.storefront` reads
|
|
1201
|
+
* `storefrontUrl` and the credit from it, so a page sends it once.
|
|
1202
|
+
*/
|
|
1203
|
+
const firstAnswers = /* @__PURE__ */ new WeakMap();
|
|
1204
|
+
/**
|
|
1205
|
+
* Internal: read `/_fluid/me` now, without waiting for the token, once per
|
|
1206
|
+
* transport.
|
|
1207
|
+
*
|
|
1208
|
+
* transport - The page's origin and fetch.
|
|
1209
|
+
*
|
|
1210
|
+
* Returns the parsed answer, or null when the call fails or answers with a
|
|
1211
|
+
* status that is not 2xx.
|
|
1212
|
+
*/
|
|
1213
|
+
function readMeAtOnce(transport) {
|
|
1214
|
+
let answer = firstAnswers.get(transport);
|
|
1215
|
+
if (answer === void 0) {
|
|
1216
|
+
answer = sendMe(transport);
|
|
1217
|
+
firstAnswers.set(transport, answer);
|
|
1218
|
+
}
|
|
1219
|
+
return answer;
|
|
1220
|
+
}
|
|
1221
|
+
async function sendMe(transport) {
|
|
1222
|
+
try {
|
|
1223
|
+
const response = await sendAtOnce(transport, new URL(RESERVED_ROUTES.me, transport.origin()), { headers: { Accept: "application/json" } });
|
|
1224
|
+
if (!response.ok) return null;
|
|
1225
|
+
return await response.json();
|
|
1226
|
+
} catch {
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
//#endregion
|
|
1231
|
+
//#region src/storefront.ts
|
|
1232
|
+
/**
|
|
1233
|
+
* `fluid.storefront`: the company's storefront, as the SDK sees it, and the
|
|
1234
|
+
* visit's FairShare credit carried back to it (contracts sections 2, 11 and
|
|
1235
|
+
* 12).
|
|
1236
|
+
*
|
|
1237
|
+
* A link to the storefront cannot open from the frame: the sandbox refuses
|
|
1238
|
+
* `target="_top"`, and a plain link would load the storefront inside the
|
|
1239
|
+
* frame. So the SDK tells such a link apart, and hands its path to the shell
|
|
1240
|
+
* in a `navigate-top` message (shell/links.ts).
|
|
1241
|
+
*
|
|
1242
|
+
* The address carries the visit's credit as the storefront's own `/:credit`
|
|
1243
|
+
* links do: `https://yoli.com/janedoe/shop`. `fluid.storefront.url` builds
|
|
1244
|
+
* one, and in a V1 frame the SDK rewrites the page's storefront links into
|
|
1245
|
+
* that form on load and as the page changes, so a link opened in a new tab
|
|
1246
|
+
* keeps the credit too.
|
|
1247
|
+
*
|
|
1248
|
+
* No credit, or the storefront's default `home`, adds no prefix: Rails reads
|
|
1249
|
+
* `/shop` and `/home/shop` alike (`FluidCreditConcern`). A credit goes only
|
|
1250
|
+
* on a path Rails serves with one (`isCreditedStorefrontPath`), so a path the
|
|
1251
|
+
* storefront serves only without a credit, such as `/cart`, stays reachable.
|
|
1252
|
+
*/
|
|
1253
|
+
/**
|
|
1254
|
+
* Internal: build `fluid.storefront`.
|
|
1255
|
+
*
|
|
1256
|
+
* options - The transport and the shell's credit.
|
|
1257
|
+
*
|
|
1258
|
+
* Returns `fluid.storefront`.
|
|
1259
|
+
*/
|
|
1260
|
+
function createStorefront(options) {
|
|
1261
|
+
const { transport, portCredit } = options;
|
|
1262
|
+
return { async url(path) {
|
|
1263
|
+
await transport.tokenSettled();
|
|
1264
|
+
const visit = readStorefrontVisit(await readMeAtOnce(transport));
|
|
1265
|
+
if (visit === null) throw new Error("fluid.storefront.url needs the storefront's address, and /_fluid/me gave none");
|
|
1266
|
+
const address = new URL(path, `${visit.storefrontOrigin}/`);
|
|
1267
|
+
if (address.origin !== visit.storefrontOrigin) throw new TypeError(`fluid.storefront.url takes a storefront path such as "/shop", not ${JSON.stringify(path)}`);
|
|
1268
|
+
const credit = portCredit() ?? visit.credit;
|
|
1269
|
+
return creditedHref(address.href, [visit.storefrontOrigin], credit) ?? address.href;
|
|
1270
|
+
} };
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Internal: read the storefront from a `/_fluid/me` answer.
|
|
1274
|
+
*
|
|
1275
|
+
* answer - The parsed answer, unchecked.
|
|
1276
|
+
*
|
|
1277
|
+
* Returns the visit, or null when the answer has no usable `storefrontUrl`.
|
|
1278
|
+
*/
|
|
1279
|
+
function readStorefrontVisit(answer) {
|
|
1280
|
+
if (typeof answer !== "object" || answer === null) return null;
|
|
1281
|
+
const { storefrontUrl, shellOrigins, credit } = answer;
|
|
1282
|
+
const storefrontOrigin = originOf(storefrontUrl);
|
|
1283
|
+
if (storefrontOrigin === null) return null;
|
|
1284
|
+
return {
|
|
1285
|
+
storefrontOrigin,
|
|
1286
|
+
origins: storefrontOrigins(storefrontUrl, Array.isArray(shellOrigins) ? shellOrigins.filter((origin) => typeof origin === "string") : []),
|
|
1287
|
+
credit: typeof credit === "string" && credit !== "" ? credit : null
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Internal: a storefront link's address carrying the credit, or null to
|
|
1292
|
+
* leave it as it is.
|
|
1293
|
+
*
|
|
1294
|
+
* Only a path Rails serves with a credit takes one
|
|
1295
|
+
* (`isCreditedStorefrontPath`): the root, a credited page such as `/shop`
|
|
1296
|
+
* or `/products/tea`, and a member site at `/accounts/<handle>`. Every other
|
|
1297
|
+
* path, such as `/cart`, `/privacy-policy`, `/my/janedoe` or the account
|
|
1298
|
+
* screen `/accounts/orders`, is served only without one and stays as written.
|
|
1299
|
+
*
|
|
1300
|
+
* href - The link's address, absolute.
|
|
1301
|
+
* origins - The storefront's origins.
|
|
1302
|
+
* credit - The visit's credit, or null.
|
|
1303
|
+
*
|
|
1304
|
+
* Examples
|
|
1305
|
+
*
|
|
1306
|
+
* creditedHref("https://yoli.com/shop", ["https://yoli.com"], "janedoe")
|
|
1307
|
+
* # => "https://yoli.com/janedoe/shop"
|
|
1308
|
+
*
|
|
1309
|
+
* creditedHref("https://yoli.com/home/shop", ["https://yoli.com"], "janedoe")
|
|
1310
|
+
* # => "https://yoli.com/janedoe/shop"
|
|
1311
|
+
*
|
|
1312
|
+
* creditedHref("https://yoli.com/janedoe/shop", ["https://yoli.com"], "janedoe")
|
|
1313
|
+
* # => null
|
|
1314
|
+
*
|
|
1315
|
+
* creditedHref("https://yoli.com/privacy-policy", ["https://yoli.com"], "janedoe")
|
|
1316
|
+
* # => null
|
|
1317
|
+
*
|
|
1318
|
+
* Returns the credited address, or null when the link goes elsewhere,
|
|
1319
|
+
* already carries the credit, has no credited route, or there is no credit
|
|
1320
|
+
* to add. A link carrying the default `home` has it swapped for the credit,
|
|
1321
|
+
* as the storefront's own links do (`LinkableUrlGenerator`), when the
|
|
1322
|
+
* credited path is served; `/home/blog/<slug>`, whose credited article is
|
|
1323
|
+
* `/<credit>/posts/<slug>`, is left alone.
|
|
1324
|
+
*/
|
|
1325
|
+
function creditedHref(href, origins, credit) {
|
|
1326
|
+
if (credit === null || isDefaultCredit(credit)) return null;
|
|
1327
|
+
let url;
|
|
1328
|
+
try {
|
|
1329
|
+
url = new URL(href);
|
|
1330
|
+
} catch {
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
if (!origins.includes(url.origin)) return null;
|
|
1334
|
+
const written = url.pathname.split("/").slice(1);
|
|
1335
|
+
const trailingSlash = written.length > 1 && written.at(-1) === "";
|
|
1336
|
+
if (trailingSlash || written.length === 1 && written[0] === "") written.pop();
|
|
1337
|
+
const [first] = written.map(decodeSegment);
|
|
1338
|
+
if (first?.toLowerCase() === credit.toLowerCase()) return null;
|
|
1339
|
+
const path = first !== void 0 && isDefaultCredit(first) ? written.slice(1) : written;
|
|
1340
|
+
if (!isCreditedStorefrontPath(path.map(decodeSegment))) return null;
|
|
1341
|
+
url.pathname = [
|
|
1342
|
+
"",
|
|
1343
|
+
encodeURIComponent(credit),
|
|
1344
|
+
...path,
|
|
1345
|
+
...trailingSlash ? [""] : []
|
|
1346
|
+
].join("/");
|
|
1347
|
+
return url.href;
|
|
1348
|
+
}
|
|
1349
|
+
/** Whether a credit is the storefront's default, `home`. */
|
|
1350
|
+
function isDefaultCredit(credit) {
|
|
1351
|
+
return credit.toLowerCase() === DEFAULT_CREDIT;
|
|
1352
|
+
}
|
|
1353
|
+
/** A path segment, decoded; one that does not decode stays as it is. */
|
|
1354
|
+
function decodeSegment(segment) {
|
|
1355
|
+
try {
|
|
1356
|
+
return decodeURIComponent(segment);
|
|
1357
|
+
} catch {
|
|
1358
|
+
return segment;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Internal: rewrite the page's storefront links into their credited form,
|
|
1363
|
+
* once `/_fluid/me` answers, as links are added or changed, and again each
|
|
1364
|
+
* time a new credit arrives. A link with `data-fluid-credit="off"` keeps its
|
|
1365
|
+
* `href`. A link the site changes afterwards is read afresh.
|
|
1366
|
+
*
|
|
1367
|
+
* options - The page, the `/_fluid/me` answer, the shell's credit, and how
|
|
1368
|
+
* to hear of a new one.
|
|
1369
|
+
*
|
|
1370
|
+
* Returns a promise that resolves once the first pass has run.
|
|
1371
|
+
*/
|
|
1372
|
+
async function creditStorefrontLinks(options) {
|
|
1373
|
+
const { page, portCredit, onCredit } = options;
|
|
1374
|
+
const visit = readStorefrontVisit(await options.me);
|
|
1375
|
+
if (visit === null) return;
|
|
1376
|
+
const rewrites = /* @__PURE__ */ new WeakMap();
|
|
1377
|
+
const rewrite = (links) => {
|
|
1378
|
+
const credit = portCredit() ?? visit.credit;
|
|
1379
|
+
for (const link of links) {
|
|
1380
|
+
const href = link.getAttribute("href");
|
|
1381
|
+
if (href === null) continue;
|
|
1382
|
+
const earlier = rewrites.get(link);
|
|
1383
|
+
const ours = earlier !== void 0 && earlier.written === href;
|
|
1384
|
+
const original = ours ? earlier.original : href;
|
|
1385
|
+
const credited = link.getAttribute("data-fluid-credit")?.trim().toLowerCase() === "off" ? null : creditedHref(absolute(original, link.baseURI), visit.origins, credit);
|
|
1386
|
+
if (credited === null) {
|
|
1387
|
+
if (ours) link.setAttribute("href", original);
|
|
1388
|
+
rewrites.delete(link);
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
if (credited !== href) link.setAttribute("href", credited);
|
|
1392
|
+
rewrites.set(link, {
|
|
1393
|
+
original,
|
|
1394
|
+
written: credited
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
page.onLinksChange(rewrite);
|
|
1399
|
+
onCredit(() => rewrite(page.links()));
|
|
1400
|
+
rewrite(page.links());
|
|
1401
|
+
}
|
|
1402
|
+
/** An `href` resolved against its base, or the `href` as it is when it does not resolve. */
|
|
1403
|
+
function absolute(href, base) {
|
|
1404
|
+
try {
|
|
1405
|
+
return new URL(href, base).href;
|
|
1406
|
+
} catch {
|
|
1407
|
+
return href;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
const LINK_SELECTOR = "a[href], area[href]";
|
|
1411
|
+
/**
|
|
1412
|
+
* Internal: the credit link page of a real document.
|
|
1413
|
+
*
|
|
1414
|
+
* document - The frame's document.
|
|
1415
|
+
*
|
|
1416
|
+
* Returns the page.
|
|
1417
|
+
*/
|
|
1418
|
+
function browserCreditLinkPage(document) {
|
|
1419
|
+
return {
|
|
1420
|
+
links: () => document.querySelectorAll(LINK_SELECTOR),
|
|
1421
|
+
onLinksChange(listener) {
|
|
1422
|
+
const root = document.documentElement;
|
|
1423
|
+
if (root === null || typeof MutationObserver === "undefined") return;
|
|
1424
|
+
new MutationObserver((records) => {
|
|
1425
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1426
|
+
for (const record of records) {
|
|
1427
|
+
if (record.type === "attributes") {
|
|
1428
|
+
if (record.target instanceof Element) changed.add(record.target);
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
for (const node of record.addedNodes) {
|
|
1432
|
+
if (!(node instanceof Element)) continue;
|
|
1433
|
+
if (node.matches(LINK_SELECTOR)) changed.add(node);
|
|
1434
|
+
for (const link of node.querySelectorAll(LINK_SELECTOR)) changed.add(link);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
listener([...changed].filter((element) => element.matches(LINK_SELECTOR)));
|
|
1438
|
+
}).observe(root, {
|
|
1439
|
+
subtree: true,
|
|
1440
|
+
childList: true,
|
|
1441
|
+
attributes: true,
|
|
1442
|
+
attributeFilter: ["href", CREDIT_OPT_OUT_ATTRIBUTE]
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Internal: the storefront's origins, from a `/_fluid/me` answer: the origin
|
|
1449
|
+
* of `storefrontUrl`, then every shell origin, each once.
|
|
1450
|
+
*
|
|
1451
|
+
* storefrontUrl - `storefrontUrl` as `/_fluid/me` answered it, unchecked.
|
|
1452
|
+
* shellOrigins - The storefront origins that may frame the site.
|
|
1453
|
+
*
|
|
1454
|
+
* Examples
|
|
1455
|
+
*
|
|
1456
|
+
* storefrontOrigins("https://yoli.com/", ["https://yoli.com", "https://shop.yoli.com"])
|
|
1457
|
+
* # => ["https://yoli.com", "https://shop.yoli.com"]
|
|
1458
|
+
*
|
|
1459
|
+
* Returns the origins; a `storefrontUrl` that is not a URL adds none.
|
|
1460
|
+
*/
|
|
1461
|
+
function storefrontOrigins(storefrontUrl, shellOrigins) {
|
|
1462
|
+
const origins = /* @__PURE__ */ new Set();
|
|
1463
|
+
const primary = originOf(storefrontUrl);
|
|
1464
|
+
if (primary !== null) origins.add(primary);
|
|
1465
|
+
for (const origin of shellOrigins) {
|
|
1466
|
+
const shellOrigin = originOf(origin);
|
|
1467
|
+
if (shellOrigin !== null) origins.add(shellOrigin);
|
|
1468
|
+
}
|
|
1469
|
+
return [...origins];
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Internal: the path a `navigate-top` message carries for a link, when the
|
|
1473
|
+
* link points at the storefront.
|
|
1474
|
+
*
|
|
1475
|
+
* href - The link's absolute address.
|
|
1476
|
+
* origins - The storefront's origins.
|
|
1477
|
+
*
|
|
1478
|
+
* Examples
|
|
1479
|
+
*
|
|
1480
|
+
* storefrontPath("https://yoli.com/shop?c=2#top", ["https://yoli.com"])
|
|
1481
|
+
* # => "/shop?c=2#top"
|
|
1482
|
+
*
|
|
1483
|
+
* storefrontPath("https://other.test/shop", ["https://yoli.com"])
|
|
1484
|
+
* # => null
|
|
1485
|
+
*
|
|
1486
|
+
* Returns the path, query and fragment, or null when the link goes elsewhere
|
|
1487
|
+
* or its path is longer than a shell message allows.
|
|
1488
|
+
*/
|
|
1489
|
+
function storefrontPath(href, origins) {
|
|
1490
|
+
let url;
|
|
1491
|
+
try {
|
|
1492
|
+
url = new URL(href);
|
|
1493
|
+
} catch {
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
if (!origins.includes(url.origin)) return null;
|
|
1497
|
+
const path = `${url.pathname}${url.search}${url.hash}`;
|
|
1498
|
+
return path.length > 2048 ? null : path;
|
|
1499
|
+
}
|
|
1500
|
+
/** The http(s) origin of a URL, or null. */
|
|
1501
|
+
function originOf(candidate) {
|
|
1502
|
+
if (typeof candidate !== "string") return null;
|
|
1503
|
+
try {
|
|
1504
|
+
const url = new URL(candidate);
|
|
1505
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.origin : null;
|
|
1506
|
+
} catch {
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/shell/links.ts
|
|
1512
|
+
/**
|
|
1513
|
+
* Internal: links in a V1 frame (contracts section 2, M-6 and "History: only
|
|
1514
|
+
* the shell adds entries"; section 3, "How the gateway maps a path to a
|
|
1515
|
+
* file").
|
|
1516
|
+
*
|
|
1517
|
+
* A plain left click on a link to another of the site's pages (no modifier
|
|
1518
|
+
* key, no `download`, `target` empty or `_self`) moves the frame with
|
|
1519
|
+
* `location.replace`, and the new page posts `navigate` (history.ts), so the
|
|
1520
|
+
* shell, not the frame, adds the history entry. A link to a fragment of the
|
|
1521
|
+
* page it shows moves with `location.replace` too, and adds no entry.
|
|
1522
|
+
*
|
|
1523
|
+
* A plain left click on a link to the storefront posts `navigate-top`, and
|
|
1524
|
+
* the shell opens it in the top window: the sandbox refuses `target="_top"`.
|
|
1525
|
+
* The shell opens it only with user activation, so a click without one, such
|
|
1526
|
+
* as a script's `click()`, is left to the browser rather than cancelled.
|
|
1527
|
+
*
|
|
1528
|
+
* The path is the link's `href` as it reads at the click, so a link the SDK
|
|
1529
|
+
* rewrote carries the visit's credit already (storefront.ts, contracts
|
|
1530
|
+
* section 12), and one with `data-fluid-credit="off"` carries none of the
|
|
1531
|
+
* SDK's.
|
|
1532
|
+
*
|
|
1533
|
+
* The target is the link's effective one, so `<base target="_blank">` keeps
|
|
1534
|
+
* a link opening elsewhere. Every other click is left to the browser, and so
|
|
1535
|
+
* is any click the site's own handlers took with `preventDefault`, even ones
|
|
1536
|
+
* added after the SDK (history.ts).
|
|
1537
|
+
*/
|
|
1538
|
+
/** The targets that open in the frame or, for the storefront, in the top window. */
|
|
1539
|
+
const SAME_FRAME_TARGETS = new Set(["", "_self"]);
|
|
1540
|
+
const STOREFRONT_TARGETS = new Set([
|
|
1541
|
+
"",
|
|
1542
|
+
"_self",
|
|
1543
|
+
"_top",
|
|
1544
|
+
"_parent"
|
|
1545
|
+
]);
|
|
1546
|
+
/**
|
|
1547
|
+
* Internal: take plain left clicks on the site's own links and on storefront
|
|
1548
|
+
* links.
|
|
1549
|
+
*
|
|
1550
|
+
* options - The page, the link to the shell, the address bar, and the
|
|
1551
|
+
* storefront's origins as the shell connected with them.
|
|
1552
|
+
*
|
|
1553
|
+
* Returns nothing.
|
|
1554
|
+
*/
|
|
1555
|
+
function followLinkClicks(options) {
|
|
1556
|
+
const { page, shell, addressBar, storefrontOrigins } = options;
|
|
1557
|
+
page.onLinkClick((click) => {
|
|
1558
|
+
const { link } = click;
|
|
1559
|
+
if (link === null || link.download || !isPlainLeftClick(click)) return;
|
|
1560
|
+
let url;
|
|
1561
|
+
try {
|
|
1562
|
+
url = new URL(link.href);
|
|
1563
|
+
} catch {
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
const target = link.target.trim().toLowerCase();
|
|
1567
|
+
const here = new URL(page.href());
|
|
1568
|
+
if (url.origin === here.origin) {
|
|
1569
|
+
if (!SAME_FRAME_TARGETS.has(target)) return;
|
|
1570
|
+
click.preventDefault();
|
|
1571
|
+
const path = `${url.pathname}${url.search}`;
|
|
1572
|
+
if (!(path === `${here.pathname}${here.search}` && url.hash !== "")) addressBar.leavingFor(path);
|
|
1573
|
+
page.replaceLocation(url.href);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (!STOREFRONT_TARGETS.has(target) || !click.userActivated) return;
|
|
1577
|
+
const path = storefrontPath(url.href, storefrontOrigins());
|
|
1578
|
+
if (path === null) return;
|
|
1579
|
+
if (shell.postToShell({
|
|
1580
|
+
ns: "fluid-site",
|
|
1581
|
+
v: 1,
|
|
1582
|
+
type: "navigate-top",
|
|
1583
|
+
path
|
|
1584
|
+
})) click.preventDefault();
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
/** A primary-button click no one took, with no modifier key. */
|
|
1588
|
+
function isPlainLeftClick(click) {
|
|
1589
|
+
return !click.defaultPrevented && click.button === 0 && !click.metaKey && !click.ctrlKey && !click.shiftKey && !click.altKey;
|
|
1590
|
+
}
|
|
1591
|
+
//#endregion
|
|
1592
|
+
//#region src/shell/history.ts
|
|
1593
|
+
/**
|
|
1594
|
+
* Internal: history and the address bar in a V1 frame (contracts section 2,
|
|
1595
|
+
* "History: only the shell adds entries").
|
|
1596
|
+
*
|
|
1597
|
+
* The frame never adds a history entry; the shell adds one per in-site move.
|
|
1598
|
+
* So the SDK turns the site's `history.pushState` into `replaceState` and
|
|
1599
|
+
* posts `navigate`, moves to another of its own pages with `location.replace`
|
|
1600
|
+
* (links.ts), reports each page and its title as `location`, and follows the
|
|
1601
|
+
* shell's `go` on Back and Forward (go.ts).
|
|
1602
|
+
*
|
|
1603
|
+
* A move to another document posts its `navigate` from the new document,
|
|
1604
|
+
* once it connects, not from the old one: a message the old document posts
|
|
1605
|
+
* as it unloads can reach the shell with an `event.source` that is no longer
|
|
1606
|
+
* the frame's window, and the shell drops it (M-1). Chromium did. So the old
|
|
1607
|
+
* document leaves a note in `sessionStorage`, and the new one posts
|
|
1608
|
+
* `navigate` before its first `location`. When `sessionStorage` refuses the
|
|
1609
|
+
* note, it rides in `window.name`, which outlives a same-origin move in the
|
|
1610
|
+
* frame; the new document restores the name. Only a page that can leave no
|
|
1611
|
+
* note at all posts `navigate` itself.
|
|
1612
|
+
*/
|
|
1613
|
+
/**
|
|
1614
|
+
* The most moves held for a shell that has not connected: past it, the last
|
|
1615
|
+
* one is replaced, so a runaway router cannot flood the shell's history.
|
|
1616
|
+
*/
|
|
1617
|
+
const UNSENT_MOVES_MAX = 50;
|
|
1618
|
+
/** How long a move note stays good: the next document loads well within it. */
|
|
1619
|
+
const MOVE_NOTE_TTL_MS = 1e4;
|
|
1620
|
+
/**
|
|
1621
|
+
* Internal: the frame's own path and query, as every message carries it.
|
|
1622
|
+
*
|
|
1623
|
+
* href - An absolute address on the frame host.
|
|
1624
|
+
*
|
|
1625
|
+
* Examples
|
|
1626
|
+
*
|
|
1627
|
+
* framePath("https://acme-public.sites.fluid.app/events/42?tab=2#agenda")
|
|
1628
|
+
* # => "/events/42?tab=2"
|
|
1629
|
+
*
|
|
1630
|
+
* Returns the path and query.
|
|
1631
|
+
*/
|
|
1632
|
+
function framePath(href) {
|
|
1633
|
+
const url = new URL(href);
|
|
1634
|
+
return `${url.pathname}${url.search}`;
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Internal: take over the frame's history. Call it only in a frame: on a
|
|
1638
|
+
* page with no shell the site's history is its own.
|
|
1639
|
+
*
|
|
1640
|
+
* options - The page, and the link to the shell.
|
|
1641
|
+
*
|
|
1642
|
+
* Returns `fluid.history`.
|
|
1643
|
+
*/
|
|
1644
|
+
function startHistory(options) {
|
|
1645
|
+
const { page, shell } = options;
|
|
1646
|
+
const history = page.history;
|
|
1647
|
+
const replaceState = history.replaceState.bind(history);
|
|
1648
|
+
const addressBar = createAddressBar(page, shell);
|
|
1649
|
+
const routers = /* @__PURE__ */ new Set();
|
|
1650
|
+
let storefrontOrigins = [];
|
|
1651
|
+
history.pushState = (data, unused, url) => {
|
|
1652
|
+
replaceState(data, unused, url);
|
|
1653
|
+
addressBar.moved();
|
|
1654
|
+
};
|
|
1655
|
+
history.replaceState = (data, unused, url) => {
|
|
1656
|
+
replaceState(data, unused, url);
|
|
1657
|
+
addressBar.corrected();
|
|
1658
|
+
};
|
|
1659
|
+
page.onTitleChange(() => addressBar.report());
|
|
1660
|
+
shell.onConnect((connected) => {
|
|
1661
|
+
storefrontOrigins = connected.storefrontOrigins;
|
|
1662
|
+
addressBar.connected();
|
|
1663
|
+
});
|
|
1664
|
+
shell.onPortMessage((data) => {
|
|
1665
|
+
const go = readGoMessage(data);
|
|
1666
|
+
if (go !== null) followGo(go, {
|
|
1667
|
+
page,
|
|
1668
|
+
addressBar,
|
|
1669
|
+
replaceState,
|
|
1670
|
+
routers: [...routers]
|
|
1671
|
+
});
|
|
1672
|
+
});
|
|
1673
|
+
followLinkClicks({
|
|
1674
|
+
page,
|
|
1675
|
+
shell,
|
|
1676
|
+
addressBar,
|
|
1677
|
+
storefrontOrigins: () => storefrontOrigins
|
|
1678
|
+
});
|
|
1679
|
+
return { onGo(listener) {
|
|
1680
|
+
routers.add(listener);
|
|
1681
|
+
return () => void routers.delete(listener);
|
|
1682
|
+
} };
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Internal: the SDK's view of the shell's current entry.
|
|
1686
|
+
*
|
|
1687
|
+
* page - The frame.
|
|
1688
|
+
* shell - The link to the shell; a post before `connect` is dropped.
|
|
1689
|
+
*
|
|
1690
|
+
* Returns the address bar.
|
|
1691
|
+
*/
|
|
1692
|
+
function createAddressBar(page, shell) {
|
|
1693
|
+
let entryPath = framePath(page.href());
|
|
1694
|
+
const unsentMoves = [];
|
|
1695
|
+
let lastReport = null;
|
|
1696
|
+
const note = readMoveNote(page.takeNote());
|
|
1697
|
+
if (note !== null && note.to === entryPath && note.from !== entryPath) unsentMoves.push(entryPath);
|
|
1698
|
+
const sendNavigate = (path) => path.length > 2048 || shell.postToShell({
|
|
1699
|
+
ns: "fluid-site",
|
|
1700
|
+
v: 1,
|
|
1701
|
+
type: "navigate",
|
|
1702
|
+
path
|
|
1703
|
+
});
|
|
1704
|
+
const addressBar = {
|
|
1705
|
+
moved() {
|
|
1706
|
+
const path = framePath(page.href());
|
|
1707
|
+
if (path !== entryPath) {
|
|
1708
|
+
entryPath = path;
|
|
1709
|
+
if (unsentMoves.length > 0 || !sendNavigate(path)) if (unsentMoves.length < UNSENT_MOVES_MAX) unsentMoves.push(path);
|
|
1710
|
+
else unsentMoves[unsentMoves.length - 1] = path;
|
|
1711
|
+
}
|
|
1712
|
+
addressBar.report();
|
|
1713
|
+
},
|
|
1714
|
+
corrected() {
|
|
1715
|
+
entryPath = framePath(page.href());
|
|
1716
|
+
if (unsentMoves.length > 0) unsentMoves[unsentMoves.length - 1] = entryPath;
|
|
1717
|
+
addressBar.report();
|
|
1718
|
+
},
|
|
1719
|
+
leavingFor(path) {
|
|
1720
|
+
if (path === entryPath) return;
|
|
1721
|
+
const moveNote = {
|
|
1722
|
+
from: entryPath,
|
|
1723
|
+
to: path,
|
|
1724
|
+
at: Date.now()
|
|
1725
|
+
};
|
|
1726
|
+
entryPath = path;
|
|
1727
|
+
if (!page.leaveNote(JSON.stringify(moveNote))) sendNavigate(path);
|
|
1728
|
+
},
|
|
1729
|
+
following(path) {
|
|
1730
|
+
entryPath = path;
|
|
1731
|
+
unsentMoves.length = 0;
|
|
1732
|
+
},
|
|
1733
|
+
report() {
|
|
1734
|
+
const path = framePath(page.href());
|
|
1735
|
+
if (path.length > 2048) return;
|
|
1736
|
+
const title = page.title().slice(0, 200);
|
|
1737
|
+
const report = JSON.stringify([path, title]);
|
|
1738
|
+
if (report === lastReport) return;
|
|
1739
|
+
if (shell.postToShell({
|
|
1740
|
+
ns: "fluid-site",
|
|
1741
|
+
v: 1,
|
|
1742
|
+
type: "location",
|
|
1743
|
+
path,
|
|
1744
|
+
title
|
|
1745
|
+
})) lastReport = report;
|
|
1746
|
+
},
|
|
1747
|
+
connected() {
|
|
1748
|
+
while (unsentMoves.length > 0 && sendNavigate(unsentMoves[0])) unsentMoves.shift();
|
|
1749
|
+
lastReport = null;
|
|
1750
|
+
addressBar.report();
|
|
1751
|
+
}
|
|
1752
|
+
};
|
|
1753
|
+
return addressBar;
|
|
1754
|
+
}
|
|
1755
|
+
/** A move note that is well formed and fresh, or null. */
|
|
1756
|
+
function readMoveNote(text) {
|
|
1757
|
+
if (text === null) return null;
|
|
1758
|
+
try {
|
|
1759
|
+
const parsed = JSON.parse(text);
|
|
1760
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1761
|
+
const { from, to, at } = parsed;
|
|
1762
|
+
if (typeof from !== "string" || typeof to !== "string") return null;
|
|
1763
|
+
if (typeof at !== "number") return null;
|
|
1764
|
+
const age = Date.now() - at;
|
|
1765
|
+
return age >= 0 && age <= MOVE_NOTE_TTL_MS ? {
|
|
1766
|
+
from,
|
|
1767
|
+
to,
|
|
1768
|
+
at
|
|
1769
|
+
} : null;
|
|
1770
|
+
} catch {
|
|
1771
|
+
return null;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
/** The `sessionStorage` key of the move note. */
|
|
1775
|
+
const MOVE_NOTE_KEY = "fluid-site:move";
|
|
1776
|
+
/** The prefix of a move note carried in `window.name` when storage refuses it. */
|
|
1777
|
+
const MOVE_NOTE_NAME_PREFIX = "fluid-site:move:";
|
|
1778
|
+
/**
|
|
1779
|
+
* Internal: the history page of a real browser window.
|
|
1780
|
+
*
|
|
1781
|
+
* window - The frame's window.
|
|
1782
|
+
*
|
|
1783
|
+
* Returns the page.
|
|
1784
|
+
*/
|
|
1785
|
+
function browserHistoryPage(window) {
|
|
1786
|
+
const { document } = window;
|
|
1787
|
+
return {
|
|
1788
|
+
href: () => window.location.href,
|
|
1789
|
+
replaceLocation: (url) => window.location.replace(url),
|
|
1790
|
+
history: window.history,
|
|
1791
|
+
title: () => document.title,
|
|
1792
|
+
onTitleChange(listener) {
|
|
1793
|
+
if (typeof MutationObserver === "undefined") return;
|
|
1794
|
+
new MutationObserver(listener).observe(document.head ?? document, {
|
|
1795
|
+
subtree: true,
|
|
1796
|
+
childList: true,
|
|
1797
|
+
characterData: true
|
|
1798
|
+
});
|
|
1799
|
+
},
|
|
1800
|
+
scrollToId: (id) => document.getElementById(id)?.scrollIntoView(),
|
|
1801
|
+
onLinkClick(listener) {
|
|
1802
|
+
afterSiteClickHandlers(window, (event) => listener(browserLinkClick(event, window)));
|
|
1803
|
+
},
|
|
1804
|
+
leaveNote(note) {
|
|
1805
|
+
try {
|
|
1806
|
+
window.sessionStorage.setItem(MOVE_NOTE_KEY, note);
|
|
1807
|
+
return true;
|
|
1808
|
+
} catch {
|
|
1809
|
+
window.name = `${MOVE_NOTE_NAME_PREFIX}${JSON.stringify([note, window.name])}`;
|
|
1810
|
+
return true;
|
|
1811
|
+
}
|
|
1812
|
+
},
|
|
1813
|
+
takeNote() {
|
|
1814
|
+
const named = takeNamedNote(window);
|
|
1815
|
+
try {
|
|
1816
|
+
const note = window.sessionStorage.getItem(MOVE_NOTE_KEY);
|
|
1817
|
+
window.sessionStorage.removeItem(MOVE_NOTE_KEY);
|
|
1818
|
+
return note ?? named;
|
|
1819
|
+
} catch {
|
|
1820
|
+
return named;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
/** Take a move note from `window.name`, restoring the name it replaced, or null. */
|
|
1826
|
+
function takeNamedNote(window) {
|
|
1827
|
+
if (!window.name.startsWith(MOVE_NOTE_NAME_PREFIX)) return null;
|
|
1828
|
+
try {
|
|
1829
|
+
const parsed = JSON.parse(window.name.slice(16));
|
|
1830
|
+
if (Array.isArray(parsed) && typeof parsed[0] === "string" && typeof parsed[1] === "string") {
|
|
1831
|
+
window.name = parsed[1];
|
|
1832
|
+
return parsed[0];
|
|
1833
|
+
}
|
|
1834
|
+
} catch {}
|
|
1835
|
+
window.name = "";
|
|
1836
|
+
return null;
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Internal: run a listener on each click after every bubble-phase click
|
|
1840
|
+
* handler the site has on the window, including ones it added after the SDK,
|
|
1841
|
+
* so a site handler's `preventDefault` keeps the SDK off the link.
|
|
1842
|
+
*
|
|
1843
|
+
* A capture listener, which runs before any bubble one, adds a fresh one-shot
|
|
1844
|
+
* bubble listener for this click: the window's bubble listeners are read when
|
|
1845
|
+
* the click reaches the window again, so it runs last, still inside the click
|
|
1846
|
+
* and its user activation. A click whose propagation the site stops never
|
|
1847
|
+
* reaches it; the next click's capture listener removes it, in a microtask
|
|
1848
|
+
* (removing a listener mid-dispatch can cut the listener walk short), so
|
|
1849
|
+
* stopped clicks never pile listeners up. Only listeners of clicks whose
|
|
1850
|
+
* dispatch has finished are removed, so a click the site dispatches from
|
|
1851
|
+
* inside another click's handler leaves the outer click's listener in place.
|
|
1852
|
+
*
|
|
1853
|
+
* target - The window.
|
|
1854
|
+
* listener - Called with the click.
|
|
1855
|
+
*
|
|
1856
|
+
* Returns nothing.
|
|
1857
|
+
*/
|
|
1858
|
+
function afterSiteClickHandlers(target, listener) {
|
|
1859
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1860
|
+
target.addEventListener("click", (captured) => {
|
|
1861
|
+
for (const [event, stale] of pending) {
|
|
1862
|
+
if (event.eventPhase !== 0) continue;
|
|
1863
|
+
pending.delete(event);
|
|
1864
|
+
queueMicrotask(() => target.removeEventListener("click", stale));
|
|
1865
|
+
}
|
|
1866
|
+
const last = (event) => {
|
|
1867
|
+
if (event !== captured) return;
|
|
1868
|
+
target.removeEventListener("click", last);
|
|
1869
|
+
pending.delete(captured);
|
|
1870
|
+
listener(event);
|
|
1871
|
+
};
|
|
1872
|
+
pending.set(captured, last);
|
|
1873
|
+
target.addEventListener("click", last);
|
|
1874
|
+
}, { capture: true });
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Internal: the browsing context a link opens in, as the browser picks it:
|
|
1878
|
+
* its own `target`, else the first `<base target>`, else "".
|
|
1879
|
+
*
|
|
1880
|
+
* element - The link.
|
|
1881
|
+
*
|
|
1882
|
+
* Returns the target, as written.
|
|
1883
|
+
*/
|
|
1884
|
+
function linkTarget(element) {
|
|
1885
|
+
if (element.hasAttribute("target")) return element.getAttribute("target") ?? "";
|
|
1886
|
+
return element.ownerDocument.querySelector("base[target]")?.getAttribute("target") ?? "";
|
|
1887
|
+
}
|
|
1888
|
+
/** A click as links.ts reads it, with the link it landed in, across shadow roots. */
|
|
1889
|
+
function browserLinkClick(event, window) {
|
|
1890
|
+
const element = event.composedPath().find((node) => node instanceof Element && node.matches("a[href], area[href]"));
|
|
1891
|
+
let href = null;
|
|
1892
|
+
if (element !== void 0) try {
|
|
1893
|
+
href = new URL(element.getAttribute("href") ?? "", element.baseURI).href;
|
|
1894
|
+
} catch {
|
|
1895
|
+
href = null;
|
|
1896
|
+
}
|
|
1897
|
+
return {
|
|
1898
|
+
button: event.button,
|
|
1899
|
+
metaKey: event.metaKey,
|
|
1900
|
+
ctrlKey: event.ctrlKey,
|
|
1901
|
+
shiftKey: event.shiftKey,
|
|
1902
|
+
altKey: event.altKey,
|
|
1903
|
+
defaultPrevented: event.defaultPrevented,
|
|
1904
|
+
userActivated: window.navigator.userActivation?.isActive ?? event.isTrusted,
|
|
1905
|
+
link: element === void 0 || href === null ? null : {
|
|
1906
|
+
href,
|
|
1907
|
+
target: linkTarget(element),
|
|
1908
|
+
download: element.hasAttribute("download")
|
|
1909
|
+
},
|
|
1910
|
+
preventDefault: () => event.preventDefault()
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
//#endregion
|
|
1914
|
+
//#region src/shell/token.ts
|
|
1915
|
+
/**
|
|
1916
|
+
* Internal: the shell token the SDK holds in memory, and the visit's credit
|
|
1917
|
+
* that arrives with it (contracts sections 2 and 5).
|
|
1918
|
+
*
|
|
1919
|
+
* The token lives in a closure, never in storage, a cookie or a URL. Every
|
|
1920
|
+
* `/_fluid/*` call but the first `/_fluid/me` waits until the holder is
|
|
1921
|
+
* settled: the first `token` message arrived, or three seconds passed, or the
|
|
1922
|
+
* SDK learned it has no shell to wait for.
|
|
1923
|
+
*/
|
|
1924
|
+
/** How long a call waits for the first token before it goes as a guest. */
|
|
1925
|
+
const TOKEN_WAIT_MS = 3e3;
|
|
1926
|
+
/**
|
|
1927
|
+
* Internal: build a token holder.
|
|
1928
|
+
*
|
|
1929
|
+
* clock - The timers to wait on; defaults to the page's.
|
|
1930
|
+
*
|
|
1931
|
+
* Returns the holder.
|
|
1932
|
+
*/
|
|
1933
|
+
function createTokenHolder(clock = { setTimeout: (callback, ms) => setTimeout(callback, ms) }) {
|
|
1934
|
+
let heldToken = null;
|
|
1935
|
+
let visitCredit = null;
|
|
1936
|
+
let waiting = false;
|
|
1937
|
+
const creditListeners = [];
|
|
1938
|
+
let settle = () => void 0;
|
|
1939
|
+
const whenSettled = new Promise((resolve) => {
|
|
1940
|
+
settle = resolve;
|
|
1941
|
+
});
|
|
1942
|
+
return {
|
|
1943
|
+
token: () => heldToken,
|
|
1944
|
+
credit: () => visitCredit,
|
|
1945
|
+
onCredit(listener) {
|
|
1946
|
+
creditListeners.push(listener);
|
|
1947
|
+
},
|
|
1948
|
+
settled: () => whenSettled,
|
|
1949
|
+
startWaiting() {
|
|
1950
|
+
if (waiting) return;
|
|
1951
|
+
waiting = true;
|
|
1952
|
+
clock.setTimeout(settle, TOKEN_WAIT_MS);
|
|
1953
|
+
},
|
|
1954
|
+
settleAsGuest: () => settle(),
|
|
1955
|
+
receive(message) {
|
|
1956
|
+
const token = message.token === "" ? null : message.token;
|
|
1957
|
+
const fresh = token !== null && token !== heldToken;
|
|
1958
|
+
heldToken = token;
|
|
1959
|
+
const newCredit = message.credit !== visitCredit;
|
|
1960
|
+
visitCredit = message.credit;
|
|
1961
|
+
settle();
|
|
1962
|
+
if (newCredit) for (const listener of creditListeners) listener(message.credit);
|
|
1963
|
+
return fresh;
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Internal: read a `token` message from the port, or null when the message
|
|
1969
|
+
* is anything else or has the wrong shape.
|
|
1970
|
+
*
|
|
1971
|
+
* data - What arrived on the port.
|
|
1972
|
+
*
|
|
1973
|
+
* Returns the message, or null.
|
|
1974
|
+
*/
|
|
1975
|
+
function readTokenMessage(data) {
|
|
1976
|
+
if (typeof data !== "object" || data === null) return null;
|
|
1977
|
+
const message = data;
|
|
1978
|
+
if (message["type"] !== "token") return null;
|
|
1979
|
+
const { token, expiresAt, credit } = message;
|
|
1980
|
+
if (token !== null && typeof token !== "string") return null;
|
|
1981
|
+
if (expiresAt !== null && typeof expiresAt !== "string") return null;
|
|
1982
|
+
if (typeof credit !== "string" || !isCreditShaped(credit)) return null;
|
|
1983
|
+
return {
|
|
1984
|
+
type: "token",
|
|
1985
|
+
token,
|
|
1986
|
+
expiresAt,
|
|
1987
|
+
credit
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
//#endregion
|
|
1991
|
+
//#region src/shell/handshake.ts
|
|
1992
|
+
/**
|
|
1993
|
+
* Internal: the handshake with the V1 shell (contracts section 2, M-3).
|
|
1994
|
+
*
|
|
1995
|
+
* On load the SDK reads `/_fluid/me` at once, with no token, for
|
|
1996
|
+
* `shellOrigins`; then it says `hello` to `window.parent`. It accepts
|
|
1997
|
+
* `connect` only from `window.parent` and from an origin in `shellOrigins`,
|
|
1998
|
+
* keeps the port it carries, and posts every later window message to that
|
|
1999
|
+
* origin. Every document says `hello`, so a multi-page site runs this on each
|
|
2000
|
+
* page.
|
|
2001
|
+
*
|
|
2002
|
+
* The shell's script may not be listening yet when the frame first says
|
|
2003
|
+
* `hello`, so the SDK says it again on a short backoff until the first
|
|
2004
|
+
* `connect` arrives or the repeats run out. The shell answers each `hello`
|
|
2005
|
+
* with a fresh port, so a late `connect` replaces the port held.
|
|
2006
|
+
*
|
|
2007
|
+
* The link tells the rest of the SDK when a shell connects, with the
|
|
2008
|
+
* storefront origins it read from `/_fluid/me`, and hands it every port
|
|
2009
|
+
* message that is not a `token`, such as `cart_result` or `go`.
|
|
2010
|
+
*/
|
|
2011
|
+
/** The first wait before `hello` is said again. */
|
|
2012
|
+
const HELLO_FIRST_RETRY_MS = 100;
|
|
2013
|
+
/** The longest wait between two `hello`s. */
|
|
2014
|
+
const HELLO_RETRY_CAP_MS = 1e3;
|
|
2015
|
+
/** How long after the first `hello` the SDK keeps saying it. */
|
|
2016
|
+
const HELLO_RETRY_TOTAL_MS = 1e4;
|
|
2017
|
+
/**
|
|
2018
|
+
* Internal: start the handshake. A page with no parent is not in a shell:
|
|
2019
|
+
* the holder settles at once and calls go as a guest.
|
|
2020
|
+
*
|
|
2021
|
+
* options - The page, its transport, the token holder and the frame session.
|
|
2022
|
+
*
|
|
2023
|
+
* Returns the link to the shell; it connects later, or never.
|
|
2024
|
+
*/
|
|
2025
|
+
function startHandshake(options) {
|
|
2026
|
+
const { page, transport, holder, frameSession } = options;
|
|
2027
|
+
const parent = page.parent;
|
|
2028
|
+
let port = null;
|
|
2029
|
+
let shellOrigin = null;
|
|
2030
|
+
let stopSayingHello = () => void 0;
|
|
2031
|
+
const connectListeners = [];
|
|
2032
|
+
const portListeners = [];
|
|
2033
|
+
const link = {
|
|
2034
|
+
postOverPort(message) {
|
|
2035
|
+
if (port === null) return false;
|
|
2036
|
+
port.postMessage(message);
|
|
2037
|
+
return true;
|
|
2038
|
+
},
|
|
2039
|
+
postToShell(message) {
|
|
2040
|
+
if (parent === null || parent === void 0 || shellOrigin === null) return false;
|
|
2041
|
+
parent.postMessage(message, shellOrigin);
|
|
2042
|
+
return true;
|
|
2043
|
+
},
|
|
2044
|
+
onConnect(listener) {
|
|
2045
|
+
connectListeners.push(listener);
|
|
2046
|
+
},
|
|
2047
|
+
onPortMessage(listener) {
|
|
2048
|
+
portListeners.push(listener);
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
if (parent === null || parent === void 0 || parent === page) {
|
|
2052
|
+
holder.settleAsGuest();
|
|
2053
|
+
return link;
|
|
2054
|
+
}
|
|
2055
|
+
const shell = parent;
|
|
2056
|
+
holder.startWaiting();
|
|
2057
|
+
readShellFacts(transport).then((facts) => {
|
|
2058
|
+
if (facts === null) return holder.settleAsGuest();
|
|
2059
|
+
page.addEventListener("message", (event) => {
|
|
2060
|
+
if (event.source !== shell) return;
|
|
2061
|
+
if (!facts.shellOrigins.includes(event.origin)) return;
|
|
2062
|
+
if (!isConnect(event.data)) return;
|
|
2063
|
+
const next = event.ports[0];
|
|
2064
|
+
if (next === void 0) return;
|
|
2065
|
+
stopSayingHello();
|
|
2066
|
+
port?.close();
|
|
2067
|
+
port = next;
|
|
2068
|
+
shellOrigin = event.origin;
|
|
2069
|
+
next.onmessage = ({ data }) => {
|
|
2070
|
+
const tokenMessage = readTokenMessage(data);
|
|
2071
|
+
if (tokenMessage === null) {
|
|
2072
|
+
for (const listener of portListeners) listener(data);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
if (holder.receive(tokenMessage) && facts.privateSite && tokenMessage.token !== null) frameSession.renew(tokenMessage.token);
|
|
2076
|
+
};
|
|
2077
|
+
const connected = { storefrontOrigins: facts.storefrontOrigins };
|
|
2078
|
+
for (const listener of connectListeners) listener(connected);
|
|
2079
|
+
});
|
|
2080
|
+
const hello = {
|
|
2081
|
+
ns: SHELL_MESSAGE_NAMESPACE,
|
|
2082
|
+
v: 1,
|
|
2083
|
+
type: "hello"
|
|
2084
|
+
};
|
|
2085
|
+
stopSayingHello = sayHelloUntilConnected(() => shell.postMessage(hello, "*"));
|
|
2086
|
+
});
|
|
2087
|
+
return link;
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Say `hello` now, then again after 100 ms, 200 ms, 400 ms, 800 ms and every
|
|
2091
|
+
* second after that, until ten seconds have passed since the first.
|
|
2092
|
+
*
|
|
2093
|
+
* sayHello - Posts one `hello` to the shell.
|
|
2094
|
+
*
|
|
2095
|
+
* Returns a function that stops the repeats.
|
|
2096
|
+
*/
|
|
2097
|
+
function sayHelloUntilConnected(sayHello) {
|
|
2098
|
+
let timer;
|
|
2099
|
+
let elapsed = 0;
|
|
2100
|
+
let wait = HELLO_FIRST_RETRY_MS;
|
|
2101
|
+
const sayAgainLater = () => {
|
|
2102
|
+
if (elapsed + wait > HELLO_RETRY_TOTAL_MS) return;
|
|
2103
|
+
timer = setTimeout(() => {
|
|
2104
|
+
elapsed += wait;
|
|
2105
|
+
wait = Math.min(wait * 2, HELLO_RETRY_CAP_MS);
|
|
2106
|
+
sayHello();
|
|
2107
|
+
sayAgainLater();
|
|
2108
|
+
}, wait);
|
|
2109
|
+
};
|
|
2110
|
+
sayHello();
|
|
2111
|
+
sayAgainLater();
|
|
2112
|
+
return () => clearTimeout(timer);
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Read `shellOrigins` from `/_fluid/me` now, with no token, whether the
|
|
2116
|
+
* site is private, and the storefront's origins. A guest gets no `site` on a
|
|
2117
|
+
* site that is not public, so no `site` means private.
|
|
2118
|
+
*
|
|
2119
|
+
* Returns the facts, or null when the call fails or the answer has no
|
|
2120
|
+
* origins.
|
|
2121
|
+
*/
|
|
2122
|
+
async function readShellFacts(transport) {
|
|
2123
|
+
const answer = await readMeAtOnce(transport);
|
|
2124
|
+
if (typeof answer !== "object" || answer === null) return null;
|
|
2125
|
+
const { shellOrigins, site, storefrontUrl } = answer;
|
|
2126
|
+
if (!Array.isArray(shellOrigins) || !shellOrigins.every((origin) => typeof origin === "string")) return null;
|
|
2127
|
+
return {
|
|
2128
|
+
shellOrigins,
|
|
2129
|
+
privateSite: (typeof site === "object" && site !== null ? site["visibility"] : null) !== "public",
|
|
2130
|
+
storefrontOrigins: storefrontOrigins(storefrontUrl, shellOrigins)
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
/** Whether a window message is the shell's `connect`. */
|
|
2134
|
+
function isConnect(data) {
|
|
2135
|
+
if (typeof data !== "object" || data === null) return false;
|
|
2136
|
+
const message = data;
|
|
2137
|
+
return message["ns"] === "fluid-site" && message["v"] === 1 && message["type"] === "connect";
|
|
2138
|
+
}
|
|
2139
|
+
//#endregion
|
|
2140
|
+
//#region src/window.ts
|
|
2141
|
+
/**
|
|
2142
|
+
* Internal: put the SDK's namespaces on `host.fluid`.
|
|
2143
|
+
*
|
|
2144
|
+
* `host.fluid` stays a plain object: the SDK creates one when there is none,
|
|
2145
|
+
* merges into the one already there, and never freezes or seals it, so a
|
|
2146
|
+
* script loaded later, such as the Passport, can add its own namespace. A
|
|
2147
|
+
* `host.fluid` that is not an object is replaced; one that refuses a key
|
|
2148
|
+
* (frozen by an older SDK) keeps what it has.
|
|
2149
|
+
*
|
|
2150
|
+
* host - The page's global object, usually `window`.
|
|
2151
|
+
* namespaces - The SDK's namespaces, such as `{ auth, api }`.
|
|
2152
|
+
*
|
|
2153
|
+
* Returns the object at `host.fluid`.
|
|
2154
|
+
*/
|
|
2155
|
+
function installFluid(host, namespaces) {
|
|
2156
|
+
const existing = host.fluid;
|
|
2157
|
+
const target = typeof existing === "object" && existing !== null ? existing : {};
|
|
2158
|
+
if (target !== existing) host.fluid = target;
|
|
2159
|
+
for (const [name, namespace] of Object.entries(namespaces)) try {
|
|
2160
|
+
target[name] = namespace;
|
|
2161
|
+
} catch {}
|
|
2162
|
+
return target;
|
|
2163
|
+
}
|
|
2164
|
+
//#endregion
|
|
2165
|
+
//#region src/index.ts
|
|
2166
|
+
/**
|
|
2167
|
+
* `@fluid-app/sites`: the member-sites browser SDK (contracts section 11).
|
|
2168
|
+
*
|
|
2169
|
+
* It speaks only to the site's own origin, so it works the same on a V1 frame
|
|
2170
|
+
* host and a V2 site host. Importing it puts `fluid` on `window.fluid`,
|
|
2171
|
+
* merged into any `window.fluid` already there, and, in a V1 frame, starts
|
|
2172
|
+
* the handshake with the shell that hands it the viewer's token, relays cart
|
|
2173
|
+
* calls and FairShare's add-to-cart clicks to the shell, leaves history
|
|
2174
|
+
* entries to the shell, and makes the page's storefront links carry the
|
|
2175
|
+
* visit's FairShare credit.
|
|
2176
|
+
*
|
|
2177
|
+
* ```ts
|
|
2178
|
+
* import { fluid } from "@fluid-app/sites";
|
|
2179
|
+
*
|
|
2180
|
+
* const me = await fluid.auth.me();
|
|
2181
|
+
* const orders = await fluid.api.get("/orders", { page: "1" });
|
|
2182
|
+
* const { data, error } = await fluid.db
|
|
2183
|
+
* .from("rsvps")
|
|
2184
|
+
* .select("name,guests")
|
|
2185
|
+
* .eq("event_id", eventId);
|
|
2186
|
+
* ```
|
|
2187
|
+
*/
|
|
2188
|
+
const holder = createTokenHolder();
|
|
2189
|
+
const transport = pageTransport(holder);
|
|
2190
|
+
const shell = typeof window === "undefined" ? noShell() : startHandshake({
|
|
2191
|
+
page: window,
|
|
2192
|
+
transport,
|
|
2193
|
+
holder,
|
|
2194
|
+
frameSession: createFrameSession(transport)
|
|
2195
|
+
});
|
|
2196
|
+
/** The SDK, also at `window.fluid`. */
|
|
2197
|
+
const fluid = {
|
|
2198
|
+
auth: createAuth(transport, shell),
|
|
2199
|
+
api: createApi(transport),
|
|
2200
|
+
db: createDb(transport),
|
|
2201
|
+
fn: createFn(transport),
|
|
2202
|
+
cart: createCart({
|
|
2203
|
+
shell,
|
|
2204
|
+
ready: () => holder.settled()
|
|
2205
|
+
}),
|
|
2206
|
+
history: isFramed() ? startHistory({
|
|
2207
|
+
page: browserHistoryPage(window),
|
|
2208
|
+
shell
|
|
2209
|
+
}) : { onGo: () => () => void 0 },
|
|
2210
|
+
storefront: createStorefront({
|
|
2211
|
+
transport,
|
|
2212
|
+
portCredit: holder.credit
|
|
2213
|
+
})
|
|
2214
|
+
};
|
|
2215
|
+
if (typeof document !== "undefined") relayCartClicks(document, fluid.cart);
|
|
2216
|
+
if (isFramed() && typeof document !== "undefined") creditStorefrontLinks({
|
|
2217
|
+
page: browserCreditLinkPage(document),
|
|
2218
|
+
me: readMeAtOnce(transport),
|
|
2219
|
+
portCredit: holder.credit,
|
|
2220
|
+
onCredit: holder.onCredit
|
|
2221
|
+
});
|
|
2222
|
+
if (typeof window !== "undefined") installFluid(window, { ...fluid });
|
|
2223
|
+
/** Outside a browser there is no shell: calls go as a guest at once. */
|
|
2224
|
+
function noShell() {
|
|
2225
|
+
holder.settleAsGuest();
|
|
2226
|
+
return {
|
|
2227
|
+
postOverPort: () => false,
|
|
2228
|
+
postToShell: () => false,
|
|
2229
|
+
onConnect: () => void 0,
|
|
2230
|
+
onPortMessage: () => void 0
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Whether the page runs in a frame, as in a V1 shell. A top-level page, and
|
|
2235
|
+
* every V2 page, keeps its own history.
|
|
2236
|
+
*/
|
|
2237
|
+
function isFramed() {
|
|
2238
|
+
return typeof window !== "undefined" && window.parent !== void 0 && window.parent !== null && window.parent !== window;
|
|
2239
|
+
}
|
|
2240
|
+
//#endregion
|
|
2241
|
+
export { API_PATH_PREFIX, CART_RESULT_TIMEOUT_MS, FluidRequestError, NotSupportedError, fluid };
|