@civitai/sdk 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BREAKING.md +69 -0
- package/README.md +186 -0
- package/dist/app/index.d.ts +50 -0
- package/dist/app/index.js +64 -0
- package/dist/core/errors.d.ts +19 -0
- package/dist/core/errors.js +42 -0
- package/dist/core/get-transport.d.ts +11 -0
- package/dist/core/get-transport.js +101 -0
- package/dist/core/handshake.d.ts +135 -0
- package/dist/core/handshake.js +60 -0
- package/dist/core/messaging.d.ts +30 -0
- package/dist/core/messaging.js +23 -0
- package/dist/core/origin-matcher.d.ts +12 -0
- package/dist/core/origin-matcher.js +71 -0
- package/dist/core/transport.d.ts +60 -0
- package/dist/core/transport.js +37 -0
- package/dist/core/transports/iframe-transport.d.ts +27 -0
- package/dist/core/transports/iframe-transport.js +342 -0
- package/dist/host/index.d.ts +47 -0
- package/dist/host/index.js +92 -0
- package/dist/host/protocol.d.ts +86 -0
- package/dist/host/protocol.js +1 -0
- package/dist/http/index.d.ts +23 -0
- package/dist/http/index.js +76 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +12 -0
- package/dist/orchestration/index.d.ts +53 -0
- package/dist/orchestration/index.js +81 -0
- package/dist/orchestration/steps.generated.d.ts +5 -0
- package/dist/orchestration/steps.generated.js +2 -0
- package/dist/session/index.d.ts +29 -0
- package/dist/session/index.js +23 -0
- package/dist/sign-in/index.d.ts +45 -0
- package/dist/sign-in/index.js +209 -0
- package/dist/site/index.d.ts +12 -0
- package/dist/site/index.js +8 -0
- package/dist/testing.d.ts +36 -0
- package/dist/testing.js +108 -0
- package/package.json +68 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's side of the handshake, as it arrives on the wire. The shapes are
|
|
3
|
+
* the host's and must track it.
|
|
4
|
+
*/
|
|
5
|
+
/** Discriminator only — the payload is still unvalidated wire data. */
|
|
6
|
+
export function isHostMessage(data, type) {
|
|
7
|
+
return (typeof data === 'object' &&
|
|
8
|
+
data !== null &&
|
|
9
|
+
'type' in data &&
|
|
10
|
+
data.type === type);
|
|
11
|
+
}
|
|
12
|
+
const FRAGMENT_MARKER_KEY = 'civitai-block';
|
|
13
|
+
const FRAGMENT_VERSION = 'v1';
|
|
14
|
+
const FRAGMENT_KEYS = [FRAGMENT_MARKER_KEY, 'theme', 'renderMode', 'blockInstanceId'];
|
|
15
|
+
/** `null` when the hash is not one the host wrote — it may be the app's own routing. */
|
|
16
|
+
function ourFragment(hash) {
|
|
17
|
+
if (typeof hash !== 'string' || hash.length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
const body = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
20
|
+
if (body.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
let params;
|
|
23
|
+
try {
|
|
24
|
+
params = new URLSearchParams(body);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return params.get(FRAGMENT_MARKER_KEY) === FRAGMENT_VERSION ? params : null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The theme and render mode the host put in the URL so a block can paint before
|
|
33
|
+
* `BLOCK_INIT` arrives. Frozen at mount — a later toggle arrives as
|
|
34
|
+
* `THEME_CHANGE`, never as a re-navigation of a third-party frame.
|
|
35
|
+
*/
|
|
36
|
+
export function parseBlockInitFragment(hash) {
|
|
37
|
+
const params = ourFragment(hash);
|
|
38
|
+
if (!params)
|
|
39
|
+
return {};
|
|
40
|
+
const out = {};
|
|
41
|
+
const theme = params.get('theme');
|
|
42
|
+
if (theme === 'light' || theme === 'dark')
|
|
43
|
+
out.theme = theme;
|
|
44
|
+
const renderMode = params.get('renderMode');
|
|
45
|
+
if (renderMode === 'iframe' || renderMode === 'inline')
|
|
46
|
+
out.renderMode = renderMode;
|
|
47
|
+
const blockInstanceId = params.get('blockInstanceId');
|
|
48
|
+
if (blockInstanceId)
|
|
49
|
+
out.blockInstanceId = blockInstanceId;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** The hash with the host's own keys removed, or `null` when it wrote none. */
|
|
53
|
+
export function stripBlockInitFragment(hash) {
|
|
54
|
+
const params = ourFragment(hash);
|
|
55
|
+
if (!params)
|
|
56
|
+
return null;
|
|
57
|
+
for (const key of FRAGMENT_KEYS)
|
|
58
|
+
params.delete(key);
|
|
59
|
+
return params.toString();
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { BlockTransport } from './transport.js';
|
|
2
|
+
export interface NotifyOptions {
|
|
3
|
+
transport?: BlockTransport;
|
|
4
|
+
}
|
|
5
|
+
export interface CallOptions extends NotifyOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Cancels the call. Request timeouts belong to the host, which owes a reply
|
|
8
|
+
* to every request; this is the caller's own deadline, not a bound we impose.
|
|
9
|
+
*/
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A domain's requests: name → what it takes and what it answers with. The same
|
|
14
|
+
* shape the DOM uses for `WindowEventMap`, so a generic function can be typed
|
|
15
|
+
* by the name it is given.
|
|
16
|
+
*/
|
|
17
|
+
export type RequestMap = Record<string, {
|
|
18
|
+
params: unknown;
|
|
19
|
+
result: unknown;
|
|
20
|
+
}>;
|
|
21
|
+
/** A domain's unsolicited messages: name → payload. Both directions use it. */
|
|
22
|
+
export type PushMap = Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* The typed entry points for one domain. A domain declares its own maps and
|
|
25
|
+
* calls these; nothing in `core` needs to know the domains exist.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createCaller<R extends RequestMap>(): <K extends keyof R & string>(type: K, params: R[K]['params'], opts?: CallOptions) => Promise<R[K]['result']>;
|
|
28
|
+
/** A message the host does not answer, so there is nothing to await. */
|
|
29
|
+
export declare function createNotifier<N extends PushMap>(): <K extends keyof N & string>(type: K, payload: N[K], opts?: NotifyOptions) => void;
|
|
30
|
+
export declare function createListener<P extends PushMap>(): <K extends keyof P & string>(transport: BlockTransport, type: K, handler: (payload: P[K]) => void) => () => void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getTransport } from './get-transport.js';
|
|
2
|
+
/**
|
|
3
|
+
* The typed entry points for one domain. A domain declares its own maps and
|
|
4
|
+
* calls these; nothing in `core` needs to know the domains exist.
|
|
5
|
+
*/
|
|
6
|
+
export function createCaller() {
|
|
7
|
+
return async function call(type, params, opts = {}) {
|
|
8
|
+
const transport = opts.transport ?? getTransport();
|
|
9
|
+
return (await transport.request(type, params, { signal: opts.signal }));
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** A message the host does not answer, so there is nothing to await. */
|
|
13
|
+
export function createNotifier() {
|
|
14
|
+
return function notify(type, payload, opts = {}) {
|
|
15
|
+
const transport = opts.transport ?? getTransport();
|
|
16
|
+
transport.notify({ type, payload });
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function createListener() {
|
|
20
|
+
return function on(transport, type, handler) {
|
|
21
|
+
return transport.on(type, (payload) => handler(payload));
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches exact origins and `https://*.example.com` wildcards. Scheme-pinned and
|
|
3
|
+
* anchored on a dot boundary, so `*.civitaic.com` rejects both
|
|
4
|
+
* `civitaic.com.attacker.tld` and `evilcivitaic.com`.
|
|
5
|
+
*/
|
|
6
|
+
export declare class OriginMatcher {
|
|
7
|
+
private readonly exact;
|
|
8
|
+
private readonly wildcards;
|
|
9
|
+
constructor(allowedParentOrigins: readonly string[]);
|
|
10
|
+
/** True when `origin` is allowed by an exact or wildcard allowlist entry. */
|
|
11
|
+
matches(origin: string): boolean;
|
|
12
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches exact origins and `https://*.example.com` wildcards. Scheme-pinned and
|
|
3
|
+
* anchored on a dot boundary, so `*.civitaic.com` rejects both
|
|
4
|
+
* `civitaic.com.attacker.tld` and `evilcivitaic.com`.
|
|
5
|
+
*/
|
|
6
|
+
export class OriginMatcher {
|
|
7
|
+
exact;
|
|
8
|
+
wildcards;
|
|
9
|
+
constructor(allowedParentOrigins) {
|
|
10
|
+
const exact = new Set();
|
|
11
|
+
const wildcards = [];
|
|
12
|
+
for (const raw of allowedParentOrigins) {
|
|
13
|
+
const entry = raw.trim();
|
|
14
|
+
if (!entry)
|
|
15
|
+
continue;
|
|
16
|
+
const wildcard = parseWildcard(entry);
|
|
17
|
+
if (wildcard) {
|
|
18
|
+
wildcards.push(wildcard);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
exact.add(entry);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
this.exact = exact;
|
|
25
|
+
this.wildcards = wildcards;
|
|
26
|
+
}
|
|
27
|
+
/** True when `origin` is allowed by an exact or wildcard allowlist entry. */
|
|
28
|
+
matches(origin) {
|
|
29
|
+
if (this.exact.has(origin))
|
|
30
|
+
return true;
|
|
31
|
+
for (const wc of this.wildcards) {
|
|
32
|
+
if (!origin.startsWith(wc.scheme))
|
|
33
|
+
continue;
|
|
34
|
+
const host = origin.slice(wc.scheme.length);
|
|
35
|
+
if (host.includes('/'))
|
|
36
|
+
continue;
|
|
37
|
+
// Dot-anchored against the END of the host, so a port never matches a
|
|
38
|
+
// wildcard — list a ported parent as an exact entry. The length test
|
|
39
|
+
// requires a leading label, excluding the apex itself.
|
|
40
|
+
if (host.length > wc.suffix.length && host.endsWith(wc.suffix)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parses a `https://*.example.com`-style entry into `{scheme, suffix}`.
|
|
49
|
+
* Returns `null` for non-wildcard (exact) entries.
|
|
50
|
+
* Throws for malformed wildcards (`*` only, `https://*`, `https://*.`).
|
|
51
|
+
*/
|
|
52
|
+
function parseWildcard(entry) {
|
|
53
|
+
const star = entry.indexOf('*');
|
|
54
|
+
if (star === -1)
|
|
55
|
+
return null;
|
|
56
|
+
// Wildcard must be of the exact form `<scheme>://*.<suffix>`.
|
|
57
|
+
const marker = '://*.';
|
|
58
|
+
const markerAt = entry.indexOf(marker);
|
|
59
|
+
if (markerAt === -1) {
|
|
60
|
+
throw new Error(`IframeTransport: invalid wildcard origin "${entry}". ` +
|
|
61
|
+
'Wildcard entries must look like "https://*.example.com".');
|
|
62
|
+
}
|
|
63
|
+
const scheme = entry.slice(0, markerAt + 3); // include "://"
|
|
64
|
+
const bareSuffix = entry.slice(markerAt + marker.length); // after "://*."
|
|
65
|
+
if (!scheme || scheme === '://' || !bareSuffix) {
|
|
66
|
+
throw new Error(`IframeTransport: invalid wildcard origin "${entry}". ` +
|
|
67
|
+
'A wildcard needs a scheme and a non-empty domain suffix, e.g. "https://*.example.com".');
|
|
68
|
+
}
|
|
69
|
+
// Dot-anchor the suffix so `*.civitaic.com` only matches on a label boundary.
|
|
70
|
+
return { scheme, suffix: `.${bareSuffix}` };
|
|
71
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { BlockContext, BlockInitPayload, BlockSettings, BlockToken, ColorDomain, Theme, ViewerInfo, WrappedToken } from './handshake.js';
|
|
2
|
+
/**
|
|
3
|
+
* Synchronous snapshot. Before `BLOCK_INIT` every field is a sentinel empty, so
|
|
4
|
+
* anything rendering UI must gate on `ready`; senders can rely on the outbound queue.
|
|
5
|
+
*/
|
|
6
|
+
export interface BlockSnapshot {
|
|
7
|
+
ready: boolean;
|
|
8
|
+
renderMode: 'iframe' | 'inline';
|
|
9
|
+
context: BlockContext;
|
|
10
|
+
token: BlockToken;
|
|
11
|
+
settings: BlockSettings;
|
|
12
|
+
/** `null` for anonymous viewers. For identity, read `/me` from the API rather than this. */
|
|
13
|
+
viewer: ViewerInfo | null;
|
|
14
|
+
theme: Theme;
|
|
15
|
+
blockInstanceId: string;
|
|
16
|
+
/**
|
|
17
|
+
* The validated parent origin; `null` until init. A money-scoped bearer token
|
|
18
|
+
* is sent here, so it is only ever an origin that passed the allowlist —
|
|
19
|
+
* never `document.referrer`, a cross-origin `location` or anything supplied
|
|
20
|
+
* by a caller.
|
|
21
|
+
*/
|
|
22
|
+
hostOrigin: string | null;
|
|
23
|
+
/** Informational only — derive SFW from `maxBrowsingLevel`, not this. */
|
|
24
|
+
domain?: ColorDomain | null;
|
|
25
|
+
/** Domain ceiling bitmask. `undefined` means consumers must fail closed to SFW. */
|
|
26
|
+
maxBrowsingLevel?: number;
|
|
27
|
+
/** The domain ceiling narrowed to this viewer. Always a subset of `maxBrowsingLevel`. */
|
|
28
|
+
effectiveBrowsingLevel?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface RequestOptions {
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
export interface BlockTransport {
|
|
34
|
+
/** Read synchronously at render time; `subscribe` fires on every change. */
|
|
35
|
+
readonly snapshot: {
|
|
36
|
+
get(): BlockSnapshot;
|
|
37
|
+
subscribe(listener: () => void): () => void;
|
|
38
|
+
};
|
|
39
|
+
/** A message the host does not answer. */
|
|
40
|
+
notify(message: {
|
|
41
|
+
type: string;
|
|
42
|
+
payload?: unknown;
|
|
43
|
+
}): void;
|
|
44
|
+
/**
|
|
45
|
+
* Resolves with the value the host answered, or rejects: a `BridgeError` for a
|
|
46
|
+
* failure it reported, the signal's `reason` if the caller aborts. Framing —
|
|
47
|
+
* correlation ids, envelopes — is this layer's business, not a caller's.
|
|
48
|
+
*/
|
|
49
|
+
request(type: string, params: unknown, opts?: RequestOptions): Promise<unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* Unsolicited host pushes, already origin-checked — a caller never needs its
|
|
52
|
+
* own window listener.
|
|
53
|
+
*/
|
|
54
|
+
on(type: string, handler: (payload: unknown) => void): () => void;
|
|
55
|
+
}
|
|
56
|
+
export declare const EMPTY_SNAPSHOT: BlockSnapshot;
|
|
57
|
+
/** Convert a wire `BlockInitPayload` (ISO string expiresAt) into a `BlockSnapshot`. */
|
|
58
|
+
export declare function snapshotFromInit(payload: BlockInitPayload, hostOrigin: string): BlockSnapshot;
|
|
59
|
+
/** Wire `WrappedToken` (ISO expiry) → runtime `BlockToken` (Date expiry). */
|
|
60
|
+
export declare function tokenFromWrapped(wrapped: WrappedToken): BlockToken;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const EMPTY_SNAPSHOT = {
|
|
2
|
+
ready: false,
|
|
3
|
+
renderMode: 'iframe',
|
|
4
|
+
context: { slotId: '' },
|
|
5
|
+
token: { raw: '', scopes: [], expiresAt: new Date(0) },
|
|
6
|
+
settings: { publisherSettings: {}, userSettings: {} },
|
|
7
|
+
viewer: null,
|
|
8
|
+
theme: 'light',
|
|
9
|
+
blockInstanceId: '',
|
|
10
|
+
hostOrigin: null,
|
|
11
|
+
};
|
|
12
|
+
/** Convert a wire `BlockInitPayload` (ISO string expiresAt) into a `BlockSnapshot`. */
|
|
13
|
+
export function snapshotFromInit(payload, hostOrigin) {
|
|
14
|
+
return {
|
|
15
|
+
ready: true,
|
|
16
|
+
hostOrigin,
|
|
17
|
+
renderMode: payload.renderMode,
|
|
18
|
+
context: payload.context,
|
|
19
|
+
token: tokenFromWrapped(payload.token),
|
|
20
|
+
settings: payload.settings,
|
|
21
|
+
viewer: payload.viewer,
|
|
22
|
+
theme: payload.theme,
|
|
23
|
+
blockInstanceId: payload.blockInstanceId,
|
|
24
|
+
domain: payload.domain,
|
|
25
|
+
maxBrowsingLevel: payload.maxBrowsingLevel,
|
|
26
|
+
effectiveBrowsingLevel: payload.effectiveBrowsingLevel,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Wire `WrappedToken` (ISO expiry) → runtime `BlockToken` (Date expiry). */
|
|
30
|
+
export function tokenFromWrapped(wrapped) {
|
|
31
|
+
return {
|
|
32
|
+
raw: wrapped.raw,
|
|
33
|
+
scopes: wrapped.scopes,
|
|
34
|
+
expiresAt: new Date(wrapped.expiresAt),
|
|
35
|
+
buzzBudget: wrapped.buzzBudget,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type BlockSnapshot, type BlockTransport, type RequestOptions } from '../transport.js';
|
|
2
|
+
export interface IframeTransportOptions {
|
|
3
|
+
/** At least one entry. Other origins — including the local one — drop silently. */
|
|
4
|
+
allowedParentOrigins: string[];
|
|
5
|
+
/** Override for tests / SSR. Defaults to `globalThis.window`. */
|
|
6
|
+
window?: Window;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validates `event.origin` on every inbound message, queues outbound until
|
|
10
|
+
* `BLOCK_INIT`, and correlates replies by `requestId`.
|
|
11
|
+
*/
|
|
12
|
+
export declare class IframeTransport implements BlockTransport {
|
|
13
|
+
#private;
|
|
14
|
+
readonly snapshot: {
|
|
15
|
+
get: () => BlockSnapshot;
|
|
16
|
+
subscribe: (listener: () => void) => () => boolean;
|
|
17
|
+
};
|
|
18
|
+
constructor(opts: IframeTransportOptions);
|
|
19
|
+
notify(message: {
|
|
20
|
+
type: string;
|
|
21
|
+
payload?: unknown;
|
|
22
|
+
}): void;
|
|
23
|
+
request(type: string, params: unknown, opts?: RequestOptions): Promise<unknown>;
|
|
24
|
+
on(type: string, handler: (payload: unknown) => void): () => void;
|
|
25
|
+
/** Test-only: tear down listeners + reject pending. */
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { isHostMessage, parseBlockInitFragment, stripBlockInitFragment, } from '../handshake.js';
|
|
2
|
+
import { EMPTY_SNAPSHOT, snapshotFromInit, tokenFromWrapped, } from '../transport.js';
|
|
3
|
+
import { BridgeError, classifyHostError } from '../errors.js';
|
|
4
|
+
import { OriginMatcher } from '../origin-matcher.js';
|
|
5
|
+
/**
|
|
6
|
+
* The framing the host had before this package owned the protocol: fields
|
|
7
|
+
* straight in the payload, and failure as the server's own sentence. Converting
|
|
8
|
+
* it here is what keeps the rest of this file — and every domain — speaking one
|
|
9
|
+
* shape; when the host modernises a message, its domain drops the declaration
|
|
10
|
+
* and this stops being reached.
|
|
11
|
+
*/
|
|
12
|
+
function fromLegacyReply(reply) {
|
|
13
|
+
const { requestId, error, ...fields } = reply;
|
|
14
|
+
// An empty string is how the host spells "no failure" on these replies.
|
|
15
|
+
if (typeof error === 'string' && error !== '') {
|
|
16
|
+
return { requestId, error: { code: classifyHostError(error), message: error } };
|
|
17
|
+
}
|
|
18
|
+
// Some of these replies nest their value under `result` and others put its
|
|
19
|
+
// fields straight in the payload; the host was never consistent about it.
|
|
20
|
+
return { requestId, result: 'result' in fields ? fields.result : fields };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The host's pre-protocol messages, each with the reply it answers with —
|
|
24
|
+
* `<REQUEST>_RESULT` holds across most of them but not all. It is the migration
|
|
25
|
+
* ledger for this protocol: an entry leaves the day the host modernises that
|
|
26
|
+
* reply, and when it empties `fromLegacyReply` goes with it.
|
|
27
|
+
*/
|
|
28
|
+
const LEGACY_REPLIES = {
|
|
29
|
+
OPEN_BUZZ_PURCHASE: 'BUZZ_PURCHASE_RESULT',
|
|
30
|
+
OPEN_RESOURCE_PICKER: 'RESOURCE_PICKER_RESULT',
|
|
31
|
+
REQUEST_TOKEN: 'TOKEN_REFRESH_RESPONSE',
|
|
32
|
+
SAVE_IMAGE: 'SAVE_IMAGE_RESULT',
|
|
33
|
+
};
|
|
34
|
+
function replyTypeFor(type) {
|
|
35
|
+
return LEGACY_REPLIES[type] ?? `${type}_RESULT`;
|
|
36
|
+
}
|
|
37
|
+
/** Turns a reply into the value it carries, or the failure it reports. */
|
|
38
|
+
function unwrap(type, reply, framing) {
|
|
39
|
+
if (reply == null || typeof reply !== 'object') {
|
|
40
|
+
throw new BridgeError('malformed', type, `${type} replied with no payload`);
|
|
41
|
+
}
|
|
42
|
+
const envelope = framing === 'legacy'
|
|
43
|
+
? fromLegacyReply(reply)
|
|
44
|
+
: reply;
|
|
45
|
+
if (envelope.error !== undefined) {
|
|
46
|
+
throw new BridgeError(envelope.error.code, type, envelope.error.message || `${type} failed`);
|
|
47
|
+
}
|
|
48
|
+
if (envelope.result === undefined) {
|
|
49
|
+
throw new BridgeError('malformed', type, `${type} reply carried no result`);
|
|
50
|
+
}
|
|
51
|
+
return envelope.result;
|
|
52
|
+
}
|
|
53
|
+
let requestIdCounter = 0;
|
|
54
|
+
/**
|
|
55
|
+
* Monotonic request ID with a random prefix so concurrent block instances
|
|
56
|
+
* sharing the dev console don't collide in logs.
|
|
57
|
+
*/
|
|
58
|
+
function nextRequestId() {
|
|
59
|
+
requestIdCounter += 1;
|
|
60
|
+
return `${Math.random().toString(36).slice(2, 8)}-${requestIdCounter}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validates `event.origin` on every inbound message, queues outbound until
|
|
64
|
+
* `BLOCK_INIT`, and correlates replies by `requestId`.
|
|
65
|
+
*/
|
|
66
|
+
export class IframeTransport {
|
|
67
|
+
#originMatcher;
|
|
68
|
+
/** A wildcard entry is not a concrete origin, so it cannot be a `targetOrigin`. */
|
|
69
|
+
#exactAllowedOrigins;
|
|
70
|
+
#window;
|
|
71
|
+
#snapshot = EMPTY_SNAPSHOT;
|
|
72
|
+
#listeners = new Set();
|
|
73
|
+
snapshot = {
|
|
74
|
+
get: () => this.#snapshot,
|
|
75
|
+
subscribe: (listener) => {
|
|
76
|
+
this.#listeners.add(listener);
|
|
77
|
+
return () => this.#listeners.delete(listener);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
/** Origin of the parent — captured from the first valid `BLOCK_INIT`. */
|
|
81
|
+
#parentOrigin = null;
|
|
82
|
+
/** Messages queued before `BLOCK_INIT` lands. Flushed in arrival order. */
|
|
83
|
+
#outbound = [];
|
|
84
|
+
#pending = new Map();
|
|
85
|
+
/**
|
|
86
|
+
* Handlers for UNSOLICITED parent→block pushes (e.g. `IMAGE_SCAN_RESOLVED`) —
|
|
87
|
+
* messages the host initiates on its own schedule, NOT replies to a pending
|
|
88
|
+
* `request`. Keyed by message type; each entry a set of subscribers.
|
|
89
|
+
*/
|
|
90
|
+
#pushListeners = new Map();
|
|
91
|
+
/** Latches on the first `BLOCK_INIT`; the host re-posts it until it sees `BLOCK_READY`. */
|
|
92
|
+
#initResolved = false;
|
|
93
|
+
#messageListener;
|
|
94
|
+
constructor(opts) {
|
|
95
|
+
if (!opts.allowedParentOrigins.length) {
|
|
96
|
+
throw new Error('IframeTransport: allowedParentOrigins must contain at least one entry. ' +
|
|
97
|
+
'Configure NEXT_PUBLIC_BLOCK_ALLOWED_PARENT_ORIGINS (or the framework equivalent).');
|
|
98
|
+
}
|
|
99
|
+
this.#originMatcher = new OriginMatcher(opts.allowedParentOrigins);
|
|
100
|
+
this.#exactAllowedOrigins = opts.allowedParentOrigins
|
|
101
|
+
.map((entry) => entry.trim())
|
|
102
|
+
.filter((entry) => entry.length > 0 && !entry.includes('*'));
|
|
103
|
+
this.#window = opts.window ?? globalThis.window;
|
|
104
|
+
if (!this.#window) {
|
|
105
|
+
throw new Error('IframeTransport: no window available; cannot mount on the server.');
|
|
106
|
+
}
|
|
107
|
+
// Seeds theme/renderMode/blockInstanceId before the listener attaches, so a
|
|
108
|
+
// racing BLOCK_INIT finds them. `ready` stays false and nothing secret is
|
|
109
|
+
// ever sourced from the URL.
|
|
110
|
+
this.#seedFromFragment();
|
|
111
|
+
this.#messageListener = (event) => this.#handleMessage(event);
|
|
112
|
+
this.#window.addEventListener('message', this.#messageListener);
|
|
113
|
+
// Must follow addEventListener: a host that answers synchronously would
|
|
114
|
+
// otherwise post into a frame with no listener. Best-effort — the host still
|
|
115
|
+
// retries on its own.
|
|
116
|
+
this.#announceReady();
|
|
117
|
+
}
|
|
118
|
+
/** No-op when absent, foreign or an unknown version — we then just await BLOCK_INIT. */
|
|
119
|
+
#seedFromFragment() {
|
|
120
|
+
let hash;
|
|
121
|
+
try {
|
|
122
|
+
hash = this.#window.location?.hash;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// A location read can throw in exotic embeddings; the fast path is
|
|
126
|
+
// optional, so degrade to "no fragment".
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const fragment = parseBlockInitFragment(hash);
|
|
130
|
+
if (fragment.theme === undefined &&
|
|
131
|
+
fragment.renderMode === undefined &&
|
|
132
|
+
fragment.blockInstanceId === undefined) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.#snapshot = {
|
|
136
|
+
...this.#snapshot,
|
|
137
|
+
...(fragment.theme !== undefined ? { theme: fragment.theme } : {}),
|
|
138
|
+
...(fragment.renderMode !== undefined ? { renderMode: fragment.renderMode } : {}),
|
|
139
|
+
...(fragment.blockInstanceId !== undefined
|
|
140
|
+
? { blockInstanceId: fragment.blockInstanceId }
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
// Cosmetic only, so a sandbox rejecting replaceState is swallowed.
|
|
144
|
+
try {
|
|
145
|
+
const remainder = stripBlockInitFragment(hash);
|
|
146
|
+
if (remainder !== null) {
|
|
147
|
+
const loc = this.#window.location;
|
|
148
|
+
const base = `${loc.pathname}${loc.search}`;
|
|
149
|
+
this.#window.history.replaceState(this.#window.history.state, '', remainder.length > 0 ? `${base}#${remainder}` : base);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Sandboxed opaque origin, or no History API. Nothing depends on this.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Aimed at each exact allowlist entry, since `parentOrigin` is not yet known.
|
|
158
|
+
* Falls back to `'*'` only for a wildcard-only allowlist, which is safe here
|
|
159
|
+
* solely because the message carries no payload.
|
|
160
|
+
*/
|
|
161
|
+
#announceReady() {
|
|
162
|
+
let parent;
|
|
163
|
+
try {
|
|
164
|
+
parent = this.#window.parent;
|
|
165
|
+
// Not framed (or self-framed) — nobody to announce to.
|
|
166
|
+
if (!parent || parent === this.#window)
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const targets = this.#exactAllowedOrigins.length > 0 ? this.#exactAllowedOrigins : ['*'];
|
|
173
|
+
for (const target of targets) {
|
|
174
|
+
try {
|
|
175
|
+
parent.postMessage({ type: 'BLOCK_HELLO' }, target);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// An unreachable/mismatched target throws nothing in practice; guard
|
|
179
|
+
// anyway so one bad allowlist entry can't abort the remaining posts.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
notify(message) {
|
|
184
|
+
this.#dispatch(message.type, message.payload);
|
|
185
|
+
}
|
|
186
|
+
async request(type, params, opts = {}) {
|
|
187
|
+
const framing = type in LEGACY_REPLIES ? 'legacy' : 'envelope';
|
|
188
|
+
return unwrap(type, await this.#exchange(type, params, opts), framing);
|
|
189
|
+
}
|
|
190
|
+
#exchange(type, params, opts) {
|
|
191
|
+
const responseType = replyTypeFor(type);
|
|
192
|
+
const { signal } = opts;
|
|
193
|
+
if (signal?.aborted)
|
|
194
|
+
return Promise.reject(signal.reason);
|
|
195
|
+
const requestId = nextRequestId();
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const onAbort = () => {
|
|
198
|
+
if (this.#pending.delete(requestId))
|
|
199
|
+
reject(signal.reason);
|
|
200
|
+
};
|
|
201
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
202
|
+
this.#pending.set(requestId, {
|
|
203
|
+
resolve: resolve,
|
|
204
|
+
reject,
|
|
205
|
+
detach: () => signal?.removeEventListener('abort', onAbort),
|
|
206
|
+
responseType,
|
|
207
|
+
});
|
|
208
|
+
this.#dispatch(type, { ...params, requestId });
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
on(type, handler) {
|
|
212
|
+
let set = this.#pushListeners.get(type);
|
|
213
|
+
if (!set) {
|
|
214
|
+
set = new Set();
|
|
215
|
+
this.#pushListeners.set(type, set);
|
|
216
|
+
}
|
|
217
|
+
set.add(handler);
|
|
218
|
+
return () => {
|
|
219
|
+
const s = this.#pushListeners.get(type);
|
|
220
|
+
if (!s)
|
|
221
|
+
return;
|
|
222
|
+
s.delete(handler);
|
|
223
|
+
if (s.size === 0)
|
|
224
|
+
this.#pushListeners.delete(type);
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** Test-only: tear down listeners + reject pending. */
|
|
228
|
+
dispose() {
|
|
229
|
+
this.#window.removeEventListener('message', this.#messageListener);
|
|
230
|
+
for (const pending of this.#pending.values()) {
|
|
231
|
+
pending.detach();
|
|
232
|
+
pending.reject(new Error('IframeTransport disposed'));
|
|
233
|
+
}
|
|
234
|
+
this.#pending.clear();
|
|
235
|
+
this.#listeners.clear();
|
|
236
|
+
this.#pushListeners.clear();
|
|
237
|
+
}
|
|
238
|
+
#dispatch(type, payload) {
|
|
239
|
+
if (!this.#parentOrigin) {
|
|
240
|
+
this.#outbound.push({ type, payload });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.#postToParent({ type, payload });
|
|
244
|
+
}
|
|
245
|
+
#flushOutbound() {
|
|
246
|
+
while (this.#outbound.length) {
|
|
247
|
+
const msg = this.#outbound.shift();
|
|
248
|
+
this.#postToParent(msg);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
#postToParent(msg) {
|
|
252
|
+
// `parentOrigin` is captured from a validated BLOCK_INIT; safe to use as targetOrigin.
|
|
253
|
+
this.#window.parent.postMessage(msg, this.#parentOrigin);
|
|
254
|
+
}
|
|
255
|
+
#handleMessage(event) {
|
|
256
|
+
if (!this.#originMatcher.matches(event.origin))
|
|
257
|
+
return;
|
|
258
|
+
const data = event.data;
|
|
259
|
+
if (data == null || typeof data !== 'object' || typeof data.type !== 'string')
|
|
260
|
+
return;
|
|
261
|
+
if (isHostMessage(data, 'BLOCK_INIT')) {
|
|
262
|
+
if (!this.#initResolved) {
|
|
263
|
+
// Build before latching: a throw here must leave the transport exactly as
|
|
264
|
+
// it was, so the host's re-post can still heal it.
|
|
265
|
+
const snapshot = snapshotFromInit(data.payload, event.origin);
|
|
266
|
+
this.#initResolved = true;
|
|
267
|
+
this.#parentOrigin = event.origin;
|
|
268
|
+
this.#snapshot = snapshot;
|
|
269
|
+
this.#emit();
|
|
270
|
+
this.#flushOutbound();
|
|
271
|
+
// height 0 is a placeholder; the real measurement follows once the app mounts.
|
|
272
|
+
this.#dispatch('BLOCK_READY', { height: 0 });
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Host-pushed token rotation (no requestId). Always apply to the
|
|
277
|
+
// snapshot; never matches a pending request.
|
|
278
|
+
if (isHostMessage(data, 'TOKEN_REFRESH')) {
|
|
279
|
+
this.#applyTokenRefresh(data.payload.token);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Not gated on init: a pre-init push is still the freshest value, and it
|
|
283
|
+
// cannot half-init anything because BLOCK_INIT replaces the snapshot wholesale.
|
|
284
|
+
if (isHostMessage(data, 'THEME_CHANGE')) {
|
|
285
|
+
this.#applyThemeChange(data.payload.theme);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Matching the TYPE too, not just the id: `IMAGE_SCAN_RESOLVED` is a push
|
|
289
|
+
// that deliberately reuses its `OPEN_IMAGE_UPLOAD`'s requestId, so an
|
|
290
|
+
// id-only match could resolve the upload with the scan verdict's payload.
|
|
291
|
+
const payload = data.payload;
|
|
292
|
+
let pending;
|
|
293
|
+
let matchedRequestId = null;
|
|
294
|
+
if (payload && typeof payload.requestId === 'string') {
|
|
295
|
+
const candidate = this.#pending.get(payload.requestId);
|
|
296
|
+
if (candidate && candidate.responseType === data.type) {
|
|
297
|
+
pending = candidate;
|
|
298
|
+
matchedRequestId = payload.requestId;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Applies whether or not a requestId matched — the host may answer unsolicited.
|
|
302
|
+
// Before resolving, so awaiting code sees the new token.
|
|
303
|
+
if (isHostMessage(data, 'TOKEN_REFRESH_RESPONSE')) {
|
|
304
|
+
this.#applyTokenRefresh(data.payload.token);
|
|
305
|
+
}
|
|
306
|
+
if (pending && matchedRequestId !== null) {
|
|
307
|
+
pending.detach();
|
|
308
|
+
this.#pending.delete(matchedRequestId);
|
|
309
|
+
pending.resolve(payload);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const handlers = this.#pushListeners.get(data.type);
|
|
313
|
+
if (handlers && handlers.size > 0) {
|
|
314
|
+
for (const handler of [...handlers])
|
|
315
|
+
handler(data.payload);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
#applyTokenRefresh(wrapped) {
|
|
320
|
+
// Whole token: scopes and buzzBudget can move at refresh, not just expiry.
|
|
321
|
+
this.#snapshot = { ...this.#snapshot, token: tokenFromWrapped(wrapped) };
|
|
322
|
+
this.#emit();
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Emits only on a real change, and updates both `snapshot.theme` and
|
|
326
|
+
* `context.theme` — the host sends both and callers read them
|
|
327
|
+
* interchangeably. Never introduces `context.theme` where the host omitted it.
|
|
328
|
+
*/
|
|
329
|
+
#applyThemeChange(theme) {
|
|
330
|
+
if (this.#snapshot.theme === theme)
|
|
331
|
+
return;
|
|
332
|
+
const next = { ...this.#snapshot, theme };
|
|
333
|
+
if ('theme' in next.context)
|
|
334
|
+
next.context = { ...next.context, theme };
|
|
335
|
+
this.#snapshot = next;
|
|
336
|
+
this.#emit();
|
|
337
|
+
}
|
|
338
|
+
#emit() {
|
|
339
|
+
for (const listener of this.#listeners)
|
|
340
|
+
listener();
|
|
341
|
+
}
|
|
342
|
+
}
|