@jitto/analyze-embed 0.0.0-stage → 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/LICENSE +72 -0
- package/README.md +324 -2
- package/dist/core/embed-target.d.ts +13 -0
- package/dist/core/embed-target.js +35 -0
- package/dist/core/frame.d.ts +28 -0
- package/dist/core/frame.js +93 -0
- package/dist/core/mount.d.ts +10 -0
- package/dist/core/mount.js +226 -0
- package/dist/core/timer.d.ts +9 -0
- package/dist/core/timer.js +13 -0
- package/dist/core/token.d.ts +39 -0
- package/dist/core/token.js +142 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/react.d.ts +8 -0
- package/dist/react.js +69 -0
- package/dist/types.d.ts +324 -0
- package/dist/types.js +61 -0
- package/package.json +49 -4
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mountJittoDashboard`: the plain JavaScript embed, on which the React wrapper sits. One package gives a React and a
|
|
3
|
+
* plain-JavaScript embed on one core.
|
|
4
|
+
*
|
|
5
|
+
* Not yet in this version: `setFilters` and the `filters` option. The dashboard's filters cannot yet be set by name
|
|
6
|
+
* from outside its frame; `setFilters` rejects and `filters` is ignored with a console warning. `setTheme` is
|
|
7
|
+
* applied in place, best effort.
|
|
8
|
+
*/
|
|
9
|
+
import { isCanonicalHttpsOrigin } from "./embed-target.js";
|
|
10
|
+
import { DashboardFrame } from "./frame.js";
|
|
11
|
+
import { timerDelay } from "./timer.js";
|
|
12
|
+
import { DEFAULT_RETRY_DELAYS_MS, DEFAULT_TOKEN_DEADLINE_MS, requestToken, TokenFailure, } from "./token.js";
|
|
13
|
+
const DEFAULT_LOAD_DEADLINE_MS = 30_000;
|
|
14
|
+
const SIZE_POLL_MS = 500;
|
|
15
|
+
const REFRESH_AT = 0.8;
|
|
16
|
+
const FAILED_TEXT = 'This dashboard could not be loaded.';
|
|
17
|
+
const CALLBACK_BY_EVENT = {
|
|
18
|
+
loaded: 'onLoaded',
|
|
19
|
+
failed: 'onFailed',
|
|
20
|
+
filterChanged: 'onFilterChanged',
|
|
21
|
+
filterRejected: 'onFilterRejected',
|
|
22
|
+
sizeChanged: 'onSizeChanged',
|
|
23
|
+
};
|
|
24
|
+
/** Theme tokens to the frame's own names. Colours that are not CSS colours are dropped with a warning. */
|
|
25
|
+
function themeTokens(theme) {
|
|
26
|
+
const names = [
|
|
27
|
+
['colorPrimary', 'colorPrimary'],
|
|
28
|
+
['colorBackground', 'colorBgLayout'],
|
|
29
|
+
['colorSurface', 'colorBgContainer'],
|
|
30
|
+
['colorText', 'colorText'],
|
|
31
|
+
['colorBorder', 'colorBorder'],
|
|
32
|
+
];
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [ours, theirs] of names) {
|
|
35
|
+
const value = theme[ours];
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
continue;
|
|
38
|
+
if (typeof value === 'string' && typeof CSS !== 'undefined' && CSS.supports('color', value))
|
|
39
|
+
out[theirs] = value;
|
|
40
|
+
else
|
|
41
|
+
console.warn(`Jitto: theme ${ours} is not a CSS colour and is ignored.`);
|
|
42
|
+
}
|
|
43
|
+
if (typeof theme.fontFamily === 'string' && theme.fontFamily)
|
|
44
|
+
out.fontFamily = theme.fontFamily;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export const mountJittoDashboard = (element, options) => {
|
|
48
|
+
const opts = options;
|
|
49
|
+
const dashboardId = opts.dashboardId;
|
|
50
|
+
// Read once: the frame posts only to the origin it was built for, so every token, the refreshed ones included, is
|
|
51
|
+
// checked against that same origin. A host that changes `embedOrigin` on its options object later must mount again.
|
|
52
|
+
const embedOrigin = opts.embedOrigin;
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
const abort = new AbortController();
|
|
55
|
+
const listeners = new Map();
|
|
56
|
+
let ended = false;
|
|
57
|
+
let loaded = false;
|
|
58
|
+
let frame = null;
|
|
59
|
+
let current = null;
|
|
60
|
+
let lastSize = null;
|
|
61
|
+
const timers = new Set();
|
|
62
|
+
const box = document.createElement('div');
|
|
63
|
+
box.style.width = '100%';
|
|
64
|
+
box.style.height = '100%';
|
|
65
|
+
element.appendChild(box);
|
|
66
|
+
function later(fn, ms) {
|
|
67
|
+
const t = setTimeout(() => {
|
|
68
|
+
timers.delete(t);
|
|
69
|
+
fn();
|
|
70
|
+
}, timerDelay(ms));
|
|
71
|
+
timers.add(t);
|
|
72
|
+
}
|
|
73
|
+
function emit(event, payload) {
|
|
74
|
+
if (ended && event !== 'failed')
|
|
75
|
+
return;
|
|
76
|
+
const callback = opts[CALLBACK_BY_EVENT[event]];
|
|
77
|
+
try {
|
|
78
|
+
callback?.(payload);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.error(err);
|
|
82
|
+
}
|
|
83
|
+
for (const handler of [...(listeners.get(event) ?? [])]) {
|
|
84
|
+
try {
|
|
85
|
+
handler(payload);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error(err);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function stop() {
|
|
93
|
+
ended = true;
|
|
94
|
+
abort.abort();
|
|
95
|
+
for (const t of timers)
|
|
96
|
+
clearTimeout(t);
|
|
97
|
+
timers.clear();
|
|
98
|
+
frame?.remove();
|
|
99
|
+
frame = null;
|
|
100
|
+
}
|
|
101
|
+
function fail(failure) {
|
|
102
|
+
if (ended)
|
|
103
|
+
return;
|
|
104
|
+
stop();
|
|
105
|
+
box.textContent = FAILED_TEXT;
|
|
106
|
+
box.style.display = 'flex';
|
|
107
|
+
box.style.alignItems = 'center';
|
|
108
|
+
box.style.justifyContent = 'center';
|
|
109
|
+
box.style.color = '#666';
|
|
110
|
+
box.style.fontFamily = 'sans-serif';
|
|
111
|
+
const payload = { dashboardId, reason: failure.reason, message: failure.message };
|
|
112
|
+
if (failure.status !== undefined)
|
|
113
|
+
payload.status = failure.status;
|
|
114
|
+
if (failure.code !== undefined)
|
|
115
|
+
payload.code = failure.code;
|
|
116
|
+
if (failure.correlationId !== undefined)
|
|
117
|
+
payload.correlationId = failure.correlationId;
|
|
118
|
+
emit('failed', payload);
|
|
119
|
+
}
|
|
120
|
+
async function watchSize() {
|
|
121
|
+
if (ended || !frame)
|
|
122
|
+
return;
|
|
123
|
+
const size = await frame.size(SIZE_POLL_MS * 4);
|
|
124
|
+
if (ended)
|
|
125
|
+
return;
|
|
126
|
+
if (size && size.height > 0) {
|
|
127
|
+
if (!loaded) {
|
|
128
|
+
loaded = true;
|
|
129
|
+
emit('loaded', { dashboardId, durationMs: Date.now() - started });
|
|
130
|
+
}
|
|
131
|
+
if (!lastSize || lastSize.width !== size.width || lastSize.height !== size.height) {
|
|
132
|
+
lastSize = size;
|
|
133
|
+
emit('sizeChanged', { dashboardId, width: size.width, height: size.height });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
later(() => void watchSize(), SIZE_POLL_MS);
|
|
137
|
+
}
|
|
138
|
+
function scheduleRefresh(token) {
|
|
139
|
+
later(() => void refresh(), Math.max(1000, token.expiresInSeconds * 1000 * REFRESH_AT));
|
|
140
|
+
}
|
|
141
|
+
async function refresh() {
|
|
142
|
+
if (ended || !current)
|
|
143
|
+
return;
|
|
144
|
+
try {
|
|
145
|
+
const next = await requestToken({
|
|
146
|
+
fetchToken: opts.fetchToken,
|
|
147
|
+
dashboardId,
|
|
148
|
+
embedOrigin,
|
|
149
|
+
deadlineMs: opts.tokenDeadlineMs ?? DEFAULT_TOKEN_DEADLINE_MS,
|
|
150
|
+
retryDelaysMs: opts.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
|
|
151
|
+
signal: abort.signal,
|
|
152
|
+
validUntil: current.expiresAt,
|
|
153
|
+
});
|
|
154
|
+
if (!next || ended)
|
|
155
|
+
return;
|
|
156
|
+
current = next;
|
|
157
|
+
await frame?.giveToken(next.token);
|
|
158
|
+
scheduleRefresh(next);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
fail(err instanceof TokenFailure ? err : new TokenFailure('network', 'The dashboard stopped working.'));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function start() {
|
|
165
|
+
if (!isCanonicalHttpsOrigin(embedOrigin)) {
|
|
166
|
+
fail(new TokenFailure('token', 'The dashboard host is not a valid origin.'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (opts.filters && Object.keys(opts.filters).length > 0) {
|
|
170
|
+
console.warn('Jitto: filters cannot be set from the page in this version and are ignored.');
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const first = await requestToken({
|
|
174
|
+
fetchToken: opts.fetchToken,
|
|
175
|
+
dashboardId,
|
|
176
|
+
embedOrigin,
|
|
177
|
+
deadlineMs: opts.tokenDeadlineMs ?? DEFAULT_TOKEN_DEADLINE_MS,
|
|
178
|
+
retryDelaysMs: opts.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
|
|
179
|
+
signal: abort.signal,
|
|
180
|
+
});
|
|
181
|
+
if (!first || ended)
|
|
182
|
+
return;
|
|
183
|
+
current = first;
|
|
184
|
+
frame = new DashboardFrame(box, first.embedUrl, dashboardId, embedOrigin);
|
|
185
|
+
later(() => {
|
|
186
|
+
if (!loaded)
|
|
187
|
+
fail(new TokenFailure('network', 'The dashboard did not answer in time.'));
|
|
188
|
+
}, opts.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS);
|
|
189
|
+
await frame.giveToken(first.token);
|
|
190
|
+
if (opts.theme)
|
|
191
|
+
await frame.applyTheme(themeTokens(opts.theme), opts.theme.mode);
|
|
192
|
+
scheduleRefresh(first);
|
|
193
|
+
void watchSize();
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
fail(err instanceof TokenFailure ? err : new TokenFailure('network', 'The dashboard could not be loaded.'));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
void start();
|
|
200
|
+
const handle = {
|
|
201
|
+
dashboardId,
|
|
202
|
+
setFilters(_filters) {
|
|
203
|
+
return Promise.reject(new Error('Jitto: setFilters is not available in this version.'));
|
|
204
|
+
},
|
|
205
|
+
setTheme(theme) {
|
|
206
|
+
if (ended)
|
|
207
|
+
return;
|
|
208
|
+
void frame?.applyTheme(themeTokens(theme), theme.mode);
|
|
209
|
+
},
|
|
210
|
+
on(event, handler) {
|
|
211
|
+
const set = listeners.get(event) ?? new Set();
|
|
212
|
+
set.add(handler);
|
|
213
|
+
listeners.set(event, set);
|
|
214
|
+
return () => {
|
|
215
|
+
set.delete(handler);
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
unmount() {
|
|
219
|
+
if (!ended)
|
|
220
|
+
stop();
|
|
221
|
+
listeners.clear();
|
|
222
|
+
box.remove();
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
return handle;
|
|
226
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delays the SDK may hand to `setTimeout`. Browsers and Node keep a timer's delay in a signed 32-bit integer: a longer
|
|
3
|
+
* delay (about 24.8 days) runs at once, so a long token lifetime, `Retry-After` or deadline would turn into a tight
|
|
4
|
+
* loop of token requests. Every timer in the core goes through `timerDelay`.
|
|
5
|
+
*/
|
|
6
|
+
/** The longest delay `setTimeout` honours: 2^31 - 1 ms. */
|
|
7
|
+
export declare const MAX_TIMER_MS = 2147483647;
|
|
8
|
+
/** `ms` as a delay `setTimeout` honours: never below 0 or above `MAX_TIMER_MS`; `NaN` is 0. */
|
|
9
|
+
export declare function timerDelay(ms: number): number;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delays the SDK may hand to `setTimeout`. Browsers and Node keep a timer's delay in a signed 32-bit integer: a longer
|
|
3
|
+
* delay (about 24.8 days) runs at once, so a long token lifetime, `Retry-After` or deadline would turn into a tight
|
|
4
|
+
* loop of token requests. Every timer in the core goes through `timerDelay`.
|
|
5
|
+
*/
|
|
6
|
+
/** The longest delay `setTimeout` honours: 2^31 - 1 ms. */
|
|
7
|
+
export const MAX_TIMER_MS = 2_147_483_647;
|
|
8
|
+
/** `ms` as a delay `setTimeout` honours: never below 0 or above `MAX_TIMER_MS`; `NaN` is 0. */
|
|
9
|
+
export function timerDelay(ms) {
|
|
10
|
+
if (Number.isNaN(ms))
|
|
11
|
+
return 0;
|
|
12
|
+
return Math.min(Math.max(ms, 0), MAX_TIMER_MS);
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token procedure: tokens are fetched with a deadline, retried only when a retry can help, and refreshed in
|
|
3
|
+
* place. One call of the host's `fetchToken` with a deadline, retries for a deadline, a
|
|
4
|
+
* network failure and 429/500/502 only, a refresh never outlasting the current token, and every answer checked
|
|
5
|
+
* before its token is used. The SDK never decodes, logs or stores a token.
|
|
6
|
+
*/
|
|
7
|
+
import type { FailureReason, FetchToken, TokenRequestError } from '../types.ts';
|
|
8
|
+
export declare const DEFAULT_EXPIRES_IN_SECONDS = 300;
|
|
9
|
+
export declare const DEFAULT_TOKEN_DEADLINE_MS = 20000;
|
|
10
|
+
export declare const DEFAULT_RETRY_DELAYS_MS: readonly number[];
|
|
11
|
+
/** A token the SDK accepted, with the time it stops being valid. */
|
|
12
|
+
export interface AcceptedToken {
|
|
13
|
+
token: string;
|
|
14
|
+
embedUrl: string;
|
|
15
|
+
expiresInSeconds: number;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
/** Why the procedure gave up: the `failed` payload's fields. */
|
|
19
|
+
export declare class TokenFailure extends Error {
|
|
20
|
+
readonly reason: FailureReason;
|
|
21
|
+
readonly status: number | undefined;
|
|
22
|
+
readonly code: string | undefined;
|
|
23
|
+
readonly correlationId: string | undefined;
|
|
24
|
+
constructor(reason: FailureReason, message: string, detail?: Partial<TokenRequestError>);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get a token, with retries. `validUntil` is the current token's expiry on a refresh (each call's deadline is cut to
|
|
28
|
+
* the time left, and a retry whose wait would end at or after it is not made), or `undefined` on mount.
|
|
29
|
+
* Throws `TokenFailure` with the last failure's reason; aborting `signal` ends it quietly with `null`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function requestToken(opts: {
|
|
32
|
+
fetchToken: FetchToken;
|
|
33
|
+
dashboardId: string;
|
|
34
|
+
embedOrigin: string;
|
|
35
|
+
deadlineMs: number;
|
|
36
|
+
retryDelaysMs: readonly number[];
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
validUntil?: number;
|
|
39
|
+
}): Promise<AcceptedToken | null>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token procedure: tokens are fetched with a deadline, retried only when a retry can help, and refreshed in
|
|
3
|
+
* place. One call of the host's `fetchToken` with a deadline, retries for a deadline, a
|
|
4
|
+
* network failure and 429/500/502 only, a refresh never outlasting the current token, and every answer checked
|
|
5
|
+
* before its token is used. The SDK never decodes, logs or stores a token.
|
|
6
|
+
*/
|
|
7
|
+
import { FAILURE_REASON_BY_CODE, FAILURE_REASON_BY_STATUS, RETRYABLE_STATUSES } from "../types.js";
|
|
8
|
+
import { isExpectedEmbedTarget } from "./embed-target.js";
|
|
9
|
+
import { timerDelay } from "./timer.js";
|
|
10
|
+
export const DEFAULT_EXPIRES_IN_SECONDS = 300;
|
|
11
|
+
export const DEFAULT_TOKEN_DEADLINE_MS = 20_000;
|
|
12
|
+
export const DEFAULT_RETRY_DELAYS_MS = [2000, 5000, 15000];
|
|
13
|
+
/** Why the procedure gave up: the `failed` payload's fields. */
|
|
14
|
+
export class TokenFailure extends Error {
|
|
15
|
+
reason;
|
|
16
|
+
status;
|
|
17
|
+
code;
|
|
18
|
+
correlationId;
|
|
19
|
+
constructor(reason, message, detail = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'TokenFailure';
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
this.status = detail.status;
|
|
24
|
+
this.code = detail.code;
|
|
25
|
+
this.correlationId = detail.correlationId;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function reasonFor(err) {
|
|
29
|
+
const code = err.code;
|
|
30
|
+
if (code && Object.prototype.hasOwnProperty.call(FAILURE_REASON_BY_CODE, code)) {
|
|
31
|
+
return FAILURE_REASON_BY_CODE[code];
|
|
32
|
+
}
|
|
33
|
+
const byStatus = FAILURE_REASON_BY_STATUS;
|
|
34
|
+
if (err.status !== undefined && byStatus[String(err.status)])
|
|
35
|
+
return byStatus[String(err.status)];
|
|
36
|
+
return FAILURE_REASON_BY_STATUS.default;
|
|
37
|
+
}
|
|
38
|
+
/** The answer, checked; or why it is refused (reason `token`, never retried). */
|
|
39
|
+
function accept(answer, dashboardId, embedOrigin, now) {
|
|
40
|
+
if (typeof answer !== 'object' || answer === null)
|
|
41
|
+
return new TokenFailure('token', 'The dashboard token answer was not usable.');
|
|
42
|
+
const a = answer;
|
|
43
|
+
if (typeof a.token !== 'string' || a.token === '')
|
|
44
|
+
return new TokenFailure('token', 'The dashboard token was missing.');
|
|
45
|
+
if (a.dashboardId !== dashboardId)
|
|
46
|
+
return new TokenFailure('token', 'The dashboard token was for another dashboard.');
|
|
47
|
+
if (typeof a.embedUrl !== 'string' || !isExpectedEmbedTarget(a.embedUrl, embedOrigin)) {
|
|
48
|
+
return new TokenFailure('token', 'The dashboard token named an unexpected host.');
|
|
49
|
+
}
|
|
50
|
+
let seconds = DEFAULT_EXPIRES_IN_SECONDS;
|
|
51
|
+
if (a.expiresIn !== undefined) {
|
|
52
|
+
if (typeof a.expiresIn !== 'number' || !Number.isFinite(a.expiresIn) || a.expiresIn <= 0) {
|
|
53
|
+
return new TokenFailure('token', 'The dashboard token had no usable lifetime.');
|
|
54
|
+
}
|
|
55
|
+
seconds = a.expiresIn;
|
|
56
|
+
}
|
|
57
|
+
return { token: a.token, embedUrl: a.embedUrl, expiresInSeconds: seconds, expiresAt: now + seconds * 1000 };
|
|
58
|
+
}
|
|
59
|
+
async function attempt(fetchToken, dashboardId, embedOrigin, deadlineMs, outer) {
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
let onOuterAbort = () => { };
|
|
62
|
+
let timer;
|
|
63
|
+
// The deadline, or the mount ending: either one stops the wait at once, so an unmount leaves no timer behind even
|
|
64
|
+
// when the host's fetchToken ignores its signal.
|
|
65
|
+
const stop = new Promise((resolve) => {
|
|
66
|
+
timer = setTimeout(() => {
|
|
67
|
+
controller.abort();
|
|
68
|
+
resolve('deadline');
|
|
69
|
+
}, timerDelay(deadlineMs));
|
|
70
|
+
onOuterAbort = () => {
|
|
71
|
+
controller.abort();
|
|
72
|
+
resolve('ended');
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
outer.addEventListener('abort', onOuterAbort, { once: true });
|
|
76
|
+
try {
|
|
77
|
+
const result = await Promise.race([fetchToken(dashboardId, controller.signal), stop]);
|
|
78
|
+
if (result === 'ended') {
|
|
79
|
+
return { ok: false, failure: new TokenFailure('network', 'The dashboard was removed.'), retry: false, retryAfterMs: 0 };
|
|
80
|
+
}
|
|
81
|
+
if (result === 'deadline') {
|
|
82
|
+
return { ok: false, failure: new TokenFailure('token', 'The dashboard token did not arrive in time.'), retry: true, retryAfterMs: 0 };
|
|
83
|
+
}
|
|
84
|
+
const checked = accept(result, dashboardId, embedOrigin, Date.now());
|
|
85
|
+
if (checked instanceof TokenFailure)
|
|
86
|
+
return { ok: false, failure: checked, retry: false, retryAfterMs: 0 };
|
|
87
|
+
return { ok: true, value: checked };
|
|
88
|
+
}
|
|
89
|
+
catch (raw) {
|
|
90
|
+
const err = (raw ?? {});
|
|
91
|
+
const status = typeof err.status === 'number' ? err.status : undefined;
|
|
92
|
+
if (status === undefined) {
|
|
93
|
+
// No HTTP answer at all: the network failed. Retried.
|
|
94
|
+
const failure = new TokenFailure('network', 'The dashboard token could not be requested.', err);
|
|
95
|
+
return { ok: false, failure, retry: true, retryAfterMs: 0 };
|
|
96
|
+
}
|
|
97
|
+
const failure = new TokenFailure(reasonFor(err), 'The dashboard token was refused.', err);
|
|
98
|
+
const retry = RETRYABLE_STATUSES.includes(status);
|
|
99
|
+
const retryAfterMs = typeof err.retryAfterSeconds === 'number' ? err.retryAfterSeconds * 1000 : 0;
|
|
100
|
+
return { ok: false, failure, retry, retryAfterMs };
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
outer.removeEventListener('abort', onOuterAbort);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function sleep(ms, signal) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const t = setTimeout(resolve, timerDelay(ms));
|
|
110
|
+
signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get a token, with retries. `validUntil` is the current token's expiry on a refresh (each call's deadline is cut to
|
|
115
|
+
* the time left, and a retry whose wait would end at or after it is not made), or `undefined` on mount.
|
|
116
|
+
* Throws `TokenFailure` with the last failure's reason; aborting `signal` ends it quietly with `null`.
|
|
117
|
+
*/
|
|
118
|
+
export async function requestToken(opts) {
|
|
119
|
+
let last;
|
|
120
|
+
for (let i = 0; i <= opts.retryDelaysMs.length; i += 1) {
|
|
121
|
+
if (opts.signal.aborted)
|
|
122
|
+
return null;
|
|
123
|
+
let deadline = opts.deadlineMs;
|
|
124
|
+
if (opts.validUntil !== undefined)
|
|
125
|
+
deadline = Math.min(deadline, Math.max(0, opts.validUntil - Date.now()));
|
|
126
|
+
if (deadline <= 0)
|
|
127
|
+
break;
|
|
128
|
+
const outcome = await attempt(opts.fetchToken, opts.dashboardId, opts.embedOrigin, deadline, opts.signal);
|
|
129
|
+
if (opts.signal.aborted)
|
|
130
|
+
return null;
|
|
131
|
+
if (outcome.ok)
|
|
132
|
+
return outcome.value;
|
|
133
|
+
last = outcome.failure;
|
|
134
|
+
if (!outcome.retry || i === opts.retryDelaysMs.length)
|
|
135
|
+
break;
|
|
136
|
+
const wait = Math.max(opts.retryDelaysMs[i] ?? 0, outcome.retryAfterMs);
|
|
137
|
+
if (opts.validUntil !== undefined && Date.now() + wait >= opts.validUntil)
|
|
138
|
+
break;
|
|
139
|
+
await sleep(wait, opts.signal);
|
|
140
|
+
}
|
|
141
|
+
throw last ?? new TokenFailure('token', 'The dashboard token expired before a new one arrived.');
|
|
142
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** `@jitto/analyze-embed`: one way to embed Jitto dashboards. Plain JavaScript; the React wrapper is `@jitto/analyze-embed/react`. */
|
|
2
|
+
export { mountJittoDashboard } from './core/mount.ts';
|
|
3
|
+
export { FAILURE_REASON_BY_CODE, FAILURE_REASON_BY_STATUS, RETRYABLE_STATUSES } from './types.ts';
|
|
4
|
+
export type { DateRange, EmbedCallbacks, EmbedEventHandler, EmbedEventMap, EmbedEventName, EmbedOptions, EmbedTokenResponse, FailureReason, FetchToken, Filters, FilterValue, JittoDashboardHandle, JittoDashboardProps, MountJittoDashboard, ProblemCode, SetFiltersError, SetFiltersOutcome, ThemeTokens, TokenRequestError, } from './types.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** `@jitto/analyze-embed`: one way to embed Jitto dashboards. Plain JavaScript; the React wrapper is `@jitto/analyze-embed/react`. */
|
|
2
|
+
export { mountJittoDashboard } from "./core/mount.js";
|
|
3
|
+
export { FAILURE_REASON_BY_CODE, FAILURE_REASON_BY_STATUS, RETRYABLE_STATUSES } from "./types.js";
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<JittoDashboard>`: the React wrapper over `mountJittoDashboard`. It reacts to prop changes by value.
|
|
3
|
+
* `dashboardId` or `embedOrigin` changing remounts; `theme` changing by value is applied in place; callbacks,
|
|
4
|
+
* `fetchToken` and the timing options are read when used, so new functions take effect with no remount. A ref
|
|
5
|
+
* gives the same `JittoDashboardHandle`.
|
|
6
|
+
*/
|
|
7
|
+
import type { JittoDashboardHandle, JittoDashboardProps } from './types.ts';
|
|
8
|
+
export declare const JittoDashboard: import("react").ForwardRefExoticComponent<JittoDashboardProps & import("react").RefAttributes<JittoDashboardHandle>>;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<JittoDashboard>`: the React wrapper over `mountJittoDashboard`. It reacts to prop changes by value.
|
|
4
|
+
* `dashboardId` or `embedOrigin` changing remounts; `theme` changing by value is applied in place; callbacks,
|
|
5
|
+
* `fetchToken` and the timing options are read when used, so new functions take effect with no remount. A ref
|
|
6
|
+
* gives the same `JittoDashboardHandle`.
|
|
7
|
+
*/
|
|
8
|
+
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
|
|
9
|
+
import { mountJittoDashboard } from "./core/mount.js";
|
|
10
|
+
// A layout effect on the client; on the server React 18 warns about useLayoutEffect, and nothing needs to run there.
|
|
11
|
+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
12
|
+
function sameValue(a, b) {
|
|
13
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
14
|
+
}
|
|
15
|
+
function JittoDashboardInner(props, ref) {
|
|
16
|
+
const container = useRef(null);
|
|
17
|
+
const handle = useRef(null);
|
|
18
|
+
// The live options: the core keeps this one object for the whole mount and reads callbacks and fetchToken from it
|
|
19
|
+
// when it uses them. So it is updated in place (a new object per render would never reach the core), and a prop that
|
|
20
|
+
// is dropped or undefined is deleted, so a dropped callback is no longer called. It is synced in a layout effect,
|
|
21
|
+
// declared before the mount, and not during render: a render React discards must not change what the core reads.
|
|
22
|
+
const live = useRef({ ...props });
|
|
23
|
+
useIsomorphicLayoutEffect(() => {
|
|
24
|
+
const target = live.current;
|
|
25
|
+
const next = props;
|
|
26
|
+
for (const key of Object.keys(target))
|
|
27
|
+
if (next[key] === undefined)
|
|
28
|
+
delete target[key];
|
|
29
|
+
for (const key of Object.keys(next))
|
|
30
|
+
if (next[key] !== undefined)
|
|
31
|
+
target[key] = next[key];
|
|
32
|
+
});
|
|
33
|
+
const lastTheme = useRef(props.theme);
|
|
34
|
+
const lastFilters = useRef(props.filters);
|
|
35
|
+
// A layout effect, declared before useImperativeHandle: that hook runs in the same phase and reads handle.current,
|
|
36
|
+
// so the mount must have set it first, or the ref stays null (and after a dashboardId change points at the old handle).
|
|
37
|
+
useIsomorphicLayoutEffect(() => {
|
|
38
|
+
if (!container.current)
|
|
39
|
+
return undefined;
|
|
40
|
+
handle.current = mountJittoDashboard(container.current, live.current);
|
|
41
|
+
lastTheme.current = props.theme;
|
|
42
|
+
return () => {
|
|
43
|
+
handle.current?.unmount();
|
|
44
|
+
handle.current = null;
|
|
45
|
+
};
|
|
46
|
+
// Remount on these two only; everything else is read live or applied in place.
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [props.dashboardId, props.embedOrigin]);
|
|
49
|
+
// `filters` is not available yet: a change after the mount is ignored, and the console says so, as the mount does.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (sameValue(lastFilters.current, props.filters))
|
|
52
|
+
return;
|
|
53
|
+
lastFilters.current = props.filters;
|
|
54
|
+
if (props.filters && Object.keys(props.filters).length > 0) {
|
|
55
|
+
console.warn('Jitto: filters cannot be set from the page in this version and are ignored.');
|
|
56
|
+
}
|
|
57
|
+
}, [props.filters]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!handle.current || sameValue(lastTheme.current, props.theme))
|
|
60
|
+
return;
|
|
61
|
+
lastTheme.current = props.theme;
|
|
62
|
+
if (props.theme)
|
|
63
|
+
handle.current.setTheme(props.theme);
|
|
64
|
+
}, [props.theme]);
|
|
65
|
+
useImperativeHandle(ref, () => handle.current, [props.dashboardId, props.embedOrigin]);
|
|
66
|
+
return _jsx("div", { ref: container, className: props.className, style: props.style });
|
|
67
|
+
}
|
|
68
|
+
export const JittoDashboard = forwardRef(JittoDashboardInner);
|
|
69
|
+
JittoDashboard.displayName = 'JittoDashboard';
|