@tai42/studio-sdk 20.0.0 → 21.0.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/dist/components/components.css +7 -0
- package/dist/components/execution-key-picker.js.map +1 -1
- package/dist/components/extension-grouping.d.ts +8 -12
- package/dist/components/extension-grouping.d.ts.map +1 -1
- package/dist/components/extension-grouping.js +12 -15
- package/dist/components/extension-grouping.js.map +1 -1
- package/dist/components/extension-picker.d.ts +3 -4
- package/dist/components/extension-picker.d.ts.map +1 -1
- package/dist/components/extension-picker.js +10 -10
- package/dist/components/extension-picker.js.map +1 -1
- package/dist/components/fleet-report.d.ts +10 -5
- package/dist/components/fleet-report.d.ts.map +1 -1
- package/dist/components/fleet-report.js +20 -3
- package/dist/components/fleet-report.js.map +1 -1
- package/dist/components/json-tree-model.d.ts +19 -7
- package/dist/components/json-tree-model.d.ts.map +1 -1
- package/dist/components/json-tree-model.js +32 -8
- package/dist/components/json-tree-model.js.map +1 -1
- package/dist/components/json-tree.d.ts +10 -1
- package/dist/components/json-tree.d.ts.map +1 -1
- package/dist/components/json-tree.js +7 -4
- package/dist/components/json-tree.js.map +1 -1
- package/dist/components/select.d.ts +6 -0
- package/dist/components/select.d.ts.map +1 -1
- package/dist/components/select.js +3 -2
- package/dist/components/select.js.map +1 -1
- package/dist/components/served-media.d.ts +50 -4
- package/dist/components/served-media.d.ts.map +1 -1
- package/dist/components/served-media.js +69 -19
- package/dist/components/served-media.js.map +1 -1
- package/dist/env-markers.d.ts +25 -0
- package/dist/env-markers.d.ts.map +1 -0
- package/dist/env-markers.js +63 -0
- package/dist/env-markers.js.map +1 -0
- package/dist/hooks/interactions-stream.d.ts.map +1 -1
- package/dist/hooks/interactions-stream.js +9 -7
- package/dist/hooks/interactions-stream.js.map +1 -1
- package/dist/hooks/useApi.d.ts +2264 -912
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/hooks/useCapabilities.d.ts +5 -9
- package/dist/hooks/useCapabilities.d.ts.map +1 -1
- package/dist/hooks/useCapabilities.js +8 -33
- package/dist/hooks/useCapabilities.js.map +1 -1
- package/dist/host.d.ts +2 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +8 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/plugin/coverage.d.ts +14 -0
- package/dist/plugin/coverage.d.ts.map +1 -0
- package/dist/plugin/coverage.js +14 -0
- package/dist/plugin/coverage.js.map +1 -0
- package/dist/plugin/registry.d.ts +11 -1
- package/dist/plugin/registry.d.ts.map +1 -1
- package/dist/plugin/registry.js +33 -4
- package/dist/plugin/registry.js.map +1 -1
- package/dist/plugin/types.d.ts +43 -1
- package/dist/plugin/types.d.ts.map +1 -1
- package/dist/state-binding/UpdateRow.d.ts +2 -2
- package/dist/state-binding/UpdateRow.js +1 -1
- package/dist/state-binding/UpdateRow.js.map +1 -1
- package/dist/state-binding/adapter.d.ts +12 -8
- package/dist/state-binding/adapter.d.ts.map +1 -1
- package/dist/state-binding/adapter.js +30 -13
- package/dist/state-binding/adapter.js.map +1 -1
- package/package.json +5 -4
- package/src/components/components.css +7 -0
- package/src/components/execution-key-picker.tsx +1 -1
- package/src/components/extension-grouping.ts +22 -22
- package/src/components/extension-picker.tsx +13 -14
- package/src/components/fleet-report.tsx +66 -7
- package/src/components/json-tree-model.ts +36 -8
- package/src/components/json-tree.tsx +24 -4
- package/src/components/select.tsx +11 -1
- package/src/components/served-media.tsx +150 -16
- package/src/env-markers.ts +70 -0
- package/src/hooks/interactions-stream.ts +9 -7
- package/src/hooks/useCapabilities.ts +8 -31
- package/src/host.ts +2 -0
- package/src/index.ts +16 -2
- package/src/plugin/coverage.ts +23 -0
- package/src/plugin/registry.ts +58 -3
- package/src/plugin/types.ts +45 -1
- package/src/state-binding/README.md +10 -7
- package/src/state-binding/UpdateRow.tsx +3 -3
- package/src/state-binding/adapter.ts +31 -16
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* framing and points the operator back at re-running the deregister, because a
|
|
15
15
|
* reload would re-attach the very server being detached — the wrong remediation.
|
|
16
16
|
*
|
|
17
|
-
* A `converged` summary (every worker applied, or a lone-worker local-only change)
|
|
18
|
-
* renders NOTHING — the calling surface shows its own success. A `degraded` summary
|
|
17
|
+
* A `converged` summary (every worker applied, or a lone-worker local-only change) with
|
|
18
|
+
* no worker awaiting a respawn renders NOTHING — the calling surface shows its own success. A `degraded` summary
|
|
19
19
|
* (the bus was reached but a sibling did not confirm) or an `unreachable` summary (the
|
|
20
20
|
* bus itself failed) renders a loud `role="alert"` panel naming the honest per-worker
|
|
21
21
|
* outcomes: `failed` / `missing` / `departed` / `timed_out` / `resyncing` / `recycling` /
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* server-supplied string (a worker name, an error/detail message) renders as escaped
|
|
24
24
|
* React text, never an HTML sink.
|
|
25
25
|
*
|
|
26
|
+
* Whatever the status, a worker whose reload named recycle-class settings awaiting a
|
|
27
|
+
* respawn is listed in a warning status panel beside it: the reload applied every other
|
|
28
|
+
* change, and those settings take effect only when that worker restarts.
|
|
29
|
+
*
|
|
26
30
|
* Every state — the headline and each worker's outcome — is a `tai-status` line: an
|
|
27
31
|
* icon MARK plus a word LABEL, with the tone color only reinforcing them, so the
|
|
28
32
|
* report is readable without color perception.
|
|
@@ -30,6 +34,7 @@
|
|
|
30
34
|
import type {
|
|
31
35
|
FleetFailureOutcome,
|
|
32
36
|
FleetReportSummary,
|
|
37
|
+
FleetWorkerAwaitingRespawn,
|
|
33
38
|
FleetWorkerFailure,
|
|
34
39
|
} from '@tai42/api-client';
|
|
35
40
|
import type { ReactNode } from 'react';
|
|
@@ -74,9 +79,10 @@ const OUTCOME_STATUS: Record<FleetFailureOutcome, StatusTone> = {
|
|
|
74
79
|
};
|
|
75
80
|
|
|
76
81
|
/**
|
|
77
|
-
* Render a fleet broadcast's failure state
|
|
78
|
-
* report
|
|
79
|
-
*
|
|
82
|
+
* Render a fleet broadcast's failure state and its workers awaiting a respawn. Returns
|
|
83
|
+
* `null` for an absent report or a converged one with no worker awaiting a respawn
|
|
84
|
+
* (nothing to warn about) so callers can always mount it unconditionally beside their
|
|
85
|
+
* own success line.
|
|
80
86
|
*/
|
|
81
87
|
export interface FleetReportProps {
|
|
82
88
|
readonly summary: FleetReportSummary | null;
|
|
@@ -193,8 +199,50 @@ function DegradedReport({
|
|
|
193
199
|
);
|
|
194
200
|
}
|
|
195
201
|
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
/**
|
|
203
|
+
* The panel for workers whose reload named recycle-class settings awaiting a respawn:
|
|
204
|
+
* the reload applied everything else, and these take effect only when the worker
|
|
205
|
+
* restarts. Guidance the operator acts on, not a failure, so it is a warning status
|
|
206
|
+
* rather than an alert.
|
|
207
|
+
*/
|
|
208
|
+
function AwaitingRespawnReport({
|
|
209
|
+
awaiting,
|
|
210
|
+
}: {
|
|
211
|
+
readonly awaiting: readonly FleetWorkerAwaitingRespawn[];
|
|
212
|
+
}): ReactNode {
|
|
213
|
+
const count = awaiting.length;
|
|
214
|
+
return (
|
|
215
|
+
<div role="status" className="tai-warn-state tai-stack tai-stack-2">
|
|
216
|
+
<strong className="tai-status tai-status-warn">
|
|
217
|
+
<AlertTriangleIcon />
|
|
218
|
+
{`${String(count)} worker${count === 1 ? ' needs' : 's need'} a restart to apply some settings`}
|
|
219
|
+
</strong>
|
|
220
|
+
<p>
|
|
221
|
+
These settings changed in the stored environment after the worker started. The reload
|
|
222
|
+
applied every other change; these take effect only when the worker restarts. Restart it
|
|
223
|
+
through your deployment’s supervisor (on a bare server, restart the server).
|
|
224
|
+
</p>
|
|
225
|
+
<ul className="tai-stack tai-stack-2">
|
|
226
|
+
{awaiting.map((worker) => (
|
|
227
|
+
<li key={worker.name} className="tai-row">
|
|
228
|
+
<span className="tai-mono">{worker.name}</span>
|
|
229
|
+
<span className="tai-muted">waits for a restart:</span>
|
|
230
|
+
<span className="tai-mono">{worker.keys.join(', ')}</span>
|
|
231
|
+
</li>
|
|
232
|
+
))}
|
|
233
|
+
</ul>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function FailureReport({
|
|
239
|
+
summary,
|
|
240
|
+
action,
|
|
241
|
+
}: {
|
|
242
|
+
readonly summary: FleetReportSummary;
|
|
243
|
+
readonly action: FleetAction;
|
|
244
|
+
}): ReactNode {
|
|
245
|
+
if (summary.status === 'converged') return null;
|
|
198
246
|
|
|
199
247
|
// Only a saved config change carries the "Change saved" framing; a reload or a
|
|
200
248
|
// deregister saved nothing.
|
|
@@ -218,3 +266,14 @@ export function FleetReport({ summary, action = 'save' }: FleetReportProps): Rea
|
|
|
218
266
|
/>
|
|
219
267
|
);
|
|
220
268
|
}
|
|
269
|
+
|
|
270
|
+
export function FleetReport({ summary, action = 'save' }: FleetReportProps): ReactNode {
|
|
271
|
+
if (summary === null) return null;
|
|
272
|
+
if (summary.status === 'converged' && summary.awaiting.length === 0) return null;
|
|
273
|
+
return (
|
|
274
|
+
<>
|
|
275
|
+
<FailureReport summary={summary} action={action} />
|
|
276
|
+
{summary.awaiting.length > 0 ? <AwaitingRespawnReport awaiting={summary.awaiting} /> : null}
|
|
277
|
+
</>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bounded-traversal model behind `JsonTree`: container inspection, the paged
|
|
3
3
|
* child slice, path identity, and the open-state computation for the depth
|
|
4
|
-
* default, expand-all and collapse-all baselines
|
|
4
|
+
* default, expand-all and collapse-all baselines and the caller's named paths.
|
|
5
|
+
* Pure — no React, no DOM.
|
|
5
6
|
*/
|
|
6
7
|
import { isRecord } from '../guards';
|
|
7
8
|
|
|
@@ -71,8 +72,30 @@ export function childPath(parent: string, name: string): string {
|
|
|
71
72
|
return parent === '' ? segment : `${parent}/${segment}`;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The identities of every container a caller's named paths open: each path is a
|
|
77
|
+
* list of keys from the root (an array item named by its index), and the root,
|
|
78
|
+
* every container along the path and the one it ends at are all opened.
|
|
79
|
+
*/
|
|
80
|
+
export function namedOpenPaths(paths: readonly (readonly string[])[]): ReadonlySet<string> {
|
|
81
|
+
const open = new Set<string>();
|
|
82
|
+
for (const segments of paths) {
|
|
83
|
+
let path = '';
|
|
84
|
+
open.add(path);
|
|
85
|
+
for (const segment of segments) {
|
|
86
|
+
path = childPath(path, segment);
|
|
87
|
+
open.add(path);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return open;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The open state the nodes start from: the three a call site's `defaultExpanded`
|
|
95
|
+
* selects (the depth-guarded default, expand-all, collapsed), and `closed`, the
|
|
96
|
+
* reader's collapse-all, which also shuts the caller's named paths.
|
|
97
|
+
*/
|
|
98
|
+
export type Baseline = 'depth' | 'expanded' | 'collapsed' | 'closed';
|
|
76
99
|
|
|
77
100
|
export function initialBaseline(defaultExpanded: boolean | undefined): Baseline {
|
|
78
101
|
if (defaultExpanded === true) return 'expanded';
|
|
@@ -119,21 +142,26 @@ export function expandedOpenPaths(root: unknown): ReadonlySet<string> {
|
|
|
119
142
|
}
|
|
120
143
|
|
|
121
144
|
/**
|
|
122
|
-
* Whether a node is open, from the baseline
|
|
123
|
-
* explicit toggle always wins;
|
|
124
|
-
*
|
|
125
|
-
*
|
|
145
|
+
* Whether a node is open, from the baseline, the caller's named paths and the
|
|
146
|
+
* reader's own toggles. An explicit toggle always wins; the reader's collapse-all
|
|
147
|
+
* opens nothing; otherwise a node on a named path is open beside any baseline a call
|
|
148
|
+
* site selects, and the baseline decides the rest — a collapsed baseline opens
|
|
149
|
+
* nothing else, the depth-guarded default opens through {@link AUTO_EXPAND_DEPTH},
|
|
150
|
+
* expand-all opens the budgeted breadth-first set.
|
|
126
151
|
*/
|
|
127
152
|
export function computeOpen(
|
|
128
153
|
baseline: Baseline,
|
|
129
154
|
overrides: ReadonlyMap<string, boolean>,
|
|
130
155
|
expandedPaths: ReadonlySet<string> | null,
|
|
156
|
+
namedPaths: ReadonlySet<string>,
|
|
131
157
|
path: string,
|
|
132
158
|
depth: number,
|
|
133
159
|
): boolean {
|
|
134
160
|
const override = overrides.get(path);
|
|
135
161
|
if (override !== undefined) return override;
|
|
136
|
-
if (baseline === '
|
|
162
|
+
if (baseline === 'closed') return false;
|
|
163
|
+
if (namedPaths.has(path)) return true;
|
|
137
164
|
if (baseline === 'collapsed') return false;
|
|
165
|
+
if (baseline === 'expanded') return expandedPaths?.has(path) ?? false;
|
|
138
166
|
return depth <= AUTO_EXPAND_DEPTH;
|
|
139
167
|
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* renders no children. `defaultExpanded` overrides per call site.
|
|
13
13
|
* - Expand-all opens breadth-first only until {@link AUTO_EXPAND_NODE_BUDGET} nodes
|
|
14
14
|
* render and never past {@link MAX_AUTO_DEPTH}; a node beyond either opens on click.
|
|
15
|
+
* - `openPaths` opens exactly the containers along the paths a caller names, at any
|
|
16
|
+
* depth, beside the baseline; collapse-all and the reader's toggles close them.
|
|
15
17
|
* - A container over {@link PAGE_SIZE} children renders one page plus a "show more".
|
|
16
18
|
* - Copy-whole and copy-node write JSON to the clipboard; a refused write shows an alert.
|
|
17
19
|
*
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
expandedOpenPaths,
|
|
31
33
|
initialBaseline,
|
|
32
34
|
isContainer,
|
|
35
|
+
namedOpenPaths,
|
|
33
36
|
PAGE_SIZE,
|
|
34
37
|
} from './json-tree-model';
|
|
35
38
|
import { COPY_LABEL, CopyButton, JsonNode } from './json-tree-nodes';
|
|
@@ -44,6 +47,13 @@ export interface JsonTreeProps {
|
|
|
44
47
|
* opens through {@link AUTO_EXPAND_DEPTH} and collapses everything below.
|
|
45
48
|
*/
|
|
46
49
|
readonly defaultExpanded?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Paths whose containers open beside the baseline, at any depth: each path lists
|
|
52
|
+
* the keys from the root (an array item by its index, as a string), and the root,
|
|
53
|
+
* every container along it and the one it ends at open. The reader's own toggle
|
|
54
|
+
* still wins, and collapse-all closes them like every other node.
|
|
55
|
+
*/
|
|
56
|
+
readonly openPaths?: readonly (readonly string[])[];
|
|
47
57
|
/** The region's accessible name, applied only while the pane actually scrolls. */
|
|
48
58
|
readonly label?: string;
|
|
49
59
|
}
|
|
@@ -51,7 +61,15 @@ export interface JsonTreeProps {
|
|
|
51
61
|
/** The region's name when the caller supplies none. */
|
|
52
62
|
const DEFAULT_LABEL = 'JSON';
|
|
53
63
|
|
|
54
|
-
|
|
64
|
+
/** The paths a tree without `openPaths` opens beside its baseline. */
|
|
65
|
+
const NO_PATHS: readonly (readonly string[])[] = [];
|
|
66
|
+
|
|
67
|
+
export function JsonTree({
|
|
68
|
+
data,
|
|
69
|
+
defaultExpanded,
|
|
70
|
+
openPaths = NO_PATHS,
|
|
71
|
+
label,
|
|
72
|
+
}: JsonTreeProps): ReactElement {
|
|
55
73
|
const [baseline, setBaseline] = useState<Baseline>(() => initialBaseline(defaultExpanded));
|
|
56
74
|
const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(() => new Map());
|
|
57
75
|
|
|
@@ -76,10 +94,12 @@ export function JsonTree({ data, defaultExpanded, label }: JsonTreeProps): React
|
|
|
76
94
|
[baseline, data],
|
|
77
95
|
);
|
|
78
96
|
|
|
97
|
+
const namedPaths = useMemo(() => namedOpenPaths(openPaths), [openPaths]);
|
|
98
|
+
|
|
79
99
|
const isOpen = useCallback(
|
|
80
100
|
(path: string, depth: number): boolean =>
|
|
81
|
-
computeOpen(baseline, overrides, expandedPaths, path, depth),
|
|
82
|
-
[baseline, overrides, expandedPaths],
|
|
101
|
+
computeOpen(baseline, overrides, expandedPaths, namedPaths, path, depth),
|
|
102
|
+
[baseline, overrides, expandedPaths, namedPaths],
|
|
83
103
|
);
|
|
84
104
|
|
|
85
105
|
const setOpen = useCallback((path: string, open: boolean): void => {
|
|
@@ -107,7 +127,7 @@ export function JsonTree({ data, defaultExpanded, label }: JsonTreeProps): React
|
|
|
107
127
|
setOverrides(new Map());
|
|
108
128
|
};
|
|
109
129
|
const collapseAll = (): void => {
|
|
110
|
-
setBaseline('
|
|
130
|
+
setBaseline('closed');
|
|
111
131
|
setOverrides(new Map());
|
|
112
132
|
};
|
|
113
133
|
|
|
@@ -42,6 +42,12 @@ interface SelectSharedProps {
|
|
|
42
42
|
* the visible text out of the accessible name (WCAG 2.5.3).
|
|
43
43
|
*/
|
|
44
44
|
readonly 'aria-label'?: string;
|
|
45
|
+
/**
|
|
46
|
+
* IDREFs of elements that describe the trigger. An enclosing `Field` owns entries
|
|
47
|
+
* in the same list (its description and error), so the two are concatenated,
|
|
48
|
+
* caller's first, exactly as the text inputs do.
|
|
49
|
+
*/
|
|
50
|
+
readonly 'aria-describedby'?: string;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** The FLAT listbox: one ungrouped run of options. */
|
|
@@ -93,8 +99,12 @@ export function Select(props: SelectArgs) {
|
|
|
93
99
|
disabled,
|
|
94
100
|
name,
|
|
95
101
|
'aria-label': ariaLabel,
|
|
102
|
+
'aria-describedby': ownDescribedBy,
|
|
96
103
|
} = props;
|
|
97
104
|
const field = useFieldControl();
|
|
105
|
+
const describedBy = [ownDescribedBy, field['aria-describedby']].filter(
|
|
106
|
+
(id): id is string => id !== undefined,
|
|
107
|
+
);
|
|
98
108
|
// Untyped JavaScript is the only caller that reaches this. Rendering one list
|
|
99
109
|
// and dropping the other would lose every entry in the discarded one silently,
|
|
100
110
|
// and an empty listbox reads as "there is nothing to choose".
|
|
@@ -118,7 +128,7 @@ export function Select(props: SelectArgs) {
|
|
|
118
128
|
// for>`, and `aria-label` outranks a native label — emitting both would
|
|
119
129
|
// discard the visible text the field renders.
|
|
120
130
|
aria-label={field.id === undefined ? ariaLabel : undefined}
|
|
121
|
-
aria-describedby={
|
|
131
|
+
aria-describedby={describedBy.length > 0 ? describedBy.join(' ') : undefined}
|
|
122
132
|
aria-invalid={field['aria-invalid']}
|
|
123
133
|
>
|
|
124
134
|
<RadixSelect.Value placeholder={placeholder} />
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* url is a loud blocked notice, its text React-escaped, never a live attribute.
|
|
13
13
|
* There is no `dangerouslySetInnerHTML` here.
|
|
14
14
|
*/
|
|
15
|
+
import type { ServedConstants } from '@tai42/api-client';
|
|
15
16
|
import type { CSSProperties, ReactNode } from 'react';
|
|
16
17
|
import { useState } from 'react';
|
|
17
18
|
|
|
@@ -20,24 +21,29 @@ import { Badge } from './badge';
|
|
|
20
21
|
import { isSafeHttpUrl } from './primitives';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* The served-media
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* The served-media grammar the contract publishes, read from the injected client
|
|
25
|
+
* (`useApi().constants`): the served route prefix and the stored-media id pattern. Media is
|
|
26
|
+
* stored by reference and served from the API origin at `MEDIA_ROUTE_PREFIX + <id>`; a record
|
|
27
|
+
* carries it as a RELATIVE url of exactly that shape, resolved to the API base at render time
|
|
27
28
|
* ({@link resolveMediaSrc}), not assumed same-origin as the SPA page.
|
|
28
29
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
export type ServedMediaGrammar = {
|
|
31
|
+
readonly [K in keyof Pick<ServedConstants, 'MEDIA_ROUTE_PREFIX' | 'MEDIA_ID_PATTERN'>]: string;
|
|
32
|
+
};
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* Whether `url` is the platform's own served-media reference: a relative
|
|
34
|
-
* `MEDIA_ROUTE_PREFIX + <id>`
|
|
36
|
+
* `MEDIA_ROUTE_PREFIX + <id>` whose id matches `MEDIA_ID_PATTERN` whole. A served
|
|
35
37
|
* reference is same-origin to the API base ({@link resolveMediaSrc}) and leaks no page
|
|
36
38
|
* context on load, so a sink with no per-element referrer control (`<audio>`/`<video>`,
|
|
37
39
|
* where `referrerPolicy` is not a valid attribute) admits ONLY this form.
|
|
38
40
|
*/
|
|
39
|
-
export function isServedMediaUrl(url: string): boolean {
|
|
40
|
-
|
|
41
|
+
export function isServedMediaUrl(url: string, grammar: ServedMediaGrammar): boolean {
|
|
42
|
+
const prefix = grammar.MEDIA_ROUTE_PREFIX;
|
|
43
|
+
return (
|
|
44
|
+
url.startsWith(prefix) &&
|
|
45
|
+
new RegExp(`^${grammar.MEDIA_ID_PATTERN}$`).test(url.slice(prefix.length))
|
|
46
|
+
);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
|
@@ -55,9 +61,17 @@ export function isServedMediaUrl(url: string): boolean {
|
|
|
55
61
|
* item. The gate keys on the reference form; {@link resolveMediaSrc} joins an
|
|
56
62
|
* admitted reference to the API base for the actual load.
|
|
57
63
|
*/
|
|
58
|
-
export function isRenderableMediaSrc(
|
|
64
|
+
export function isRenderableMediaSrc(
|
|
65
|
+
url: string,
|
|
66
|
+
grammar: ServedMediaGrammar,
|
|
67
|
+
allowDataImage = false,
|
|
68
|
+
): boolean {
|
|
59
69
|
const isHttpsUrl = isSafeHttpUrl(url) && new URL(url).protocol === 'https:';
|
|
60
|
-
return
|
|
70
|
+
return (
|
|
71
|
+
isHttpsUrl ||
|
|
72
|
+
(allowDataImage && url.startsWith('data:image/')) ||
|
|
73
|
+
isServedMediaUrl(url, grammar)
|
|
74
|
+
);
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
/**
|
|
@@ -69,8 +83,8 @@ export function isRenderableMediaSrc(url: string, allowDataImage = false): boole
|
|
|
69
83
|
* relative, which is correct; a configured base's trailing slash is stripped so the
|
|
70
84
|
* join never double-slashes.
|
|
71
85
|
*/
|
|
72
|
-
export function resolveMediaSrc(url: string, baseUrl: string): string {
|
|
73
|
-
return isServedMediaUrl(url) ? `${baseUrl.replace(/\/+$/, '')}${url}` : url;
|
|
86
|
+
export function resolveMediaSrc(url: string, baseUrl: string, grammar: ServedMediaGrammar): string {
|
|
87
|
+
return isServedMediaUrl(url, grammar) ? `${baseUrl.replace(/\/+$/, '')}${url}` : url;
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
// -- styles ------------------------------------------------------------------
|
|
@@ -150,9 +164,9 @@ export function MediaImage({
|
|
|
150
164
|
// url rendered at this same position gets a fresh load attempt instead of inheriting
|
|
151
165
|
// a stale failure notice.
|
|
152
166
|
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
|
153
|
-
const { baseUrl } = useApi();
|
|
167
|
+
const { baseUrl, constants } = useApi();
|
|
154
168
|
|
|
155
|
-
if (!isRenderableMediaSrc(url, allowDataImage)) {
|
|
169
|
+
if (!isRenderableMediaSrc(url, constants, allowDataImage)) {
|
|
156
170
|
if (renderNotice !== undefined) return renderNotice('blocked', url);
|
|
157
171
|
return (
|
|
158
172
|
<div role="alert" data-testid="media-item-blocked" style={blockedStyle}>
|
|
@@ -175,7 +189,7 @@ export function MediaImage({
|
|
|
175
189
|
return (
|
|
176
190
|
<div style={itemStyle}>
|
|
177
191
|
<img
|
|
178
|
-
src={resolveMediaSrc(url, baseUrl)}
|
|
192
|
+
src={resolveMediaSrc(url, baseUrl, constants)}
|
|
179
193
|
alt={caption ?? altFallback}
|
|
180
194
|
referrerPolicy="no-referrer"
|
|
181
195
|
style={imageStyle}
|
|
@@ -187,3 +201,123 @@ export function MediaImage({
|
|
|
187
201
|
</div>
|
|
188
202
|
);
|
|
189
203
|
}
|
|
204
|
+
|
|
205
|
+
// -- document item -----------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
const chipStyle: CSSProperties = {
|
|
208
|
+
display: 'flex',
|
|
209
|
+
alignItems: 'center',
|
|
210
|
+
gap: 'var(--tai-space-2)',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const labelStyle: CSSProperties = {
|
|
214
|
+
wordBreak: 'break-all',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export interface MediaDocumentProps {
|
|
218
|
+
readonly url: string;
|
|
219
|
+
/** The download label: the item's filename or caption, or the caller's default. */
|
|
220
|
+
readonly label: string;
|
|
221
|
+
/** Render the notice shown when the url fails the src gate. */
|
|
222
|
+
readonly renderNotice: (url: string) => ReactNode;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One document: a labelled download affordance. The served route sends
|
|
227
|
+
* `Content-Disposition: attachment` for documents, so the anchor `download`-hints and
|
|
228
|
+
* opens in a new tab; `rel="noreferrer"` stops the Studio URL leaking to the media
|
|
229
|
+
* host. A url that fails {@link isRenderableMediaSrc} is the caller's notice, never a
|
|
230
|
+
* live href.
|
|
231
|
+
*/
|
|
232
|
+
export function MediaDocument({ url, label, renderNotice }: MediaDocumentProps): ReactNode {
|
|
233
|
+
const { baseUrl, constants } = useApi();
|
|
234
|
+
if (!isRenderableMediaSrc(url, constants)) return renderNotice(url);
|
|
235
|
+
return (
|
|
236
|
+
<div style={chipStyle} data-testid="media-document">
|
|
237
|
+
<Badge variant="neutral">Document</Badge>
|
|
238
|
+
<a
|
|
239
|
+
href={resolveMediaSrc(url, baseUrl, constants)}
|
|
240
|
+
target="_blank"
|
|
241
|
+
rel="noreferrer"
|
|
242
|
+
download
|
|
243
|
+
title={label}
|
|
244
|
+
style={labelStyle}
|
|
245
|
+
>
|
|
246
|
+
{label}
|
|
247
|
+
</a>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -- audio / video item ------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
const audioStyle: CSSProperties = {
|
|
255
|
+
width: '100%',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const videoStyle: CSSProperties = {
|
|
259
|
+
maxWidth: '100%',
|
|
260
|
+
maxHeight: '320px',
|
|
261
|
+
width: 'auto',
|
|
262
|
+
height: 'auto',
|
|
263
|
+
borderRadius: 'var(--tai-radius-md)',
|
|
264
|
+
border: '1px solid var(--tai-color-border)',
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
export interface MediaPlayerProps {
|
|
268
|
+
readonly kind: 'audio' | 'video';
|
|
269
|
+
readonly url: string;
|
|
270
|
+
/** The player's accessible label. */
|
|
271
|
+
readonly label: string;
|
|
272
|
+
/**
|
|
273
|
+
* Render the notice shown when no player is displayed: `state` is `'blocked'` when
|
|
274
|
+
* the url is not a served reference and `'failed'` when it fails to LOAD.
|
|
275
|
+
*/
|
|
276
|
+
readonly renderNotice: (state: 'blocked' | 'failed', url: string) => ReactNode;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A native `<audio>`/`<video controls>` player. `referrerPolicy` is not a valid
|
|
281
|
+
* attribute on `<audio>`/`<video>`, so a remote load has no per-element referrer
|
|
282
|
+
* control; the player therefore admits ONLY the platform's own served-media reference
|
|
283
|
+
* ({@link isServedMediaUrl}: same-origin, leaking no page URL to a media host), and any
|
|
284
|
+
* other url, a bare `https:` one included, is the caller's notice. The load-failure
|
|
285
|
+
* flag keys on the url that failed so a new url at the same position gets a fresh load.
|
|
286
|
+
*/
|
|
287
|
+
export function MediaPlayer({ kind, url, label, renderNotice }: MediaPlayerProps): ReactNode {
|
|
288
|
+
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
|
289
|
+
const { baseUrl, constants } = useApi();
|
|
290
|
+
|
|
291
|
+
if (!isServedMediaUrl(url, constants)) return renderNotice('blocked', url);
|
|
292
|
+
if (failedUrl === url) return renderNotice('failed', url);
|
|
293
|
+
|
|
294
|
+
const src = resolveMediaSrc(url, baseUrl, constants);
|
|
295
|
+
const onError = (): void => {
|
|
296
|
+
setFailedUrl(url);
|
|
297
|
+
};
|
|
298
|
+
return (
|
|
299
|
+
<div style={itemStyle}>
|
|
300
|
+
{kind === 'audio' ? (
|
|
301
|
+
// eslint-disable-next-line jsx-a11y/media-has-caption -- a media item carries no caption track
|
|
302
|
+
<audio
|
|
303
|
+
controls
|
|
304
|
+
src={src}
|
|
305
|
+
aria-label={label}
|
|
306
|
+
onError={onError}
|
|
307
|
+
style={audioStyle}
|
|
308
|
+
data-testid="media-audio"
|
|
309
|
+
/>
|
|
310
|
+
) : (
|
|
311
|
+
// eslint-disable-next-line jsx-a11y/media-has-caption -- a media item carries no caption track
|
|
312
|
+
<video
|
|
313
|
+
controls
|
|
314
|
+
src={src}
|
|
315
|
+
aria-label={label}
|
|
316
|
+
onError={onError}
|
|
317
|
+
style={videoStyle}
|
|
318
|
+
data-testid="media-video"
|
|
319
|
+
/>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `!ENV ${VAR[:default]}` manifest marker grammar the platform reads: a string leaf
|
|
3
|
+
* that begins with the `!ENV ` prefix (the tag and exactly one space) references an
|
|
4
|
+
* environment variable by name, never its value. A variable name carries no `{`, `}` or
|
|
5
|
+
* `:`; an optional `:default` follows it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The prefix that makes a string leaf an `!ENV` marker. */
|
|
9
|
+
export const ENV_MARKER_PREFIX = '!ENV ';
|
|
10
|
+
|
|
11
|
+
/** A leaf that is exactly one reference: group 1 the name, group 2 the optional default. */
|
|
12
|
+
const SINGLE_REF = /^!ENV \$\{([^}{:]+)(?::([^}]+))?\}$/;
|
|
13
|
+
|
|
14
|
+
/** Every reference inside a marker leaf's expression. */
|
|
15
|
+
const ANY_REF = /\$\{([^}{:]+)(?::[^}]+)?\}/g;
|
|
16
|
+
|
|
17
|
+
/** The reference of a single-reference marker leaf. */
|
|
18
|
+
export interface EnvRef {
|
|
19
|
+
readonly key: string;
|
|
20
|
+
/** The `:default` text (without the leading colon); absent for a bare reference. */
|
|
21
|
+
readonly default?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The reference of `value` when it is exactly `!ENV ${KEY}` or `!ENV ${KEY:default}`, else `null`. */
|
|
25
|
+
export function parseEnvMarker(value: unknown): EnvRef | null {
|
|
26
|
+
if (typeof value !== 'string') return null;
|
|
27
|
+
const match = SINGLE_REF.exec(value);
|
|
28
|
+
if (match === null) return null;
|
|
29
|
+
const key = match[1] ?? '';
|
|
30
|
+
const defaultText = match[2];
|
|
31
|
+
return defaultText === undefined ? { key } : { key, default: defaultText };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The marker referencing `key`, carrying `defaultValue` when given. Throws when
|
|
36
|
+
* {@link parseEnvMarker} would not read the marker back as `(key, defaultValue)`: a name is
|
|
37
|
+
* non-empty and carries no `{`, `}` or `:`; a default is non-empty and carries no `}`.
|
|
38
|
+
*/
|
|
39
|
+
export function formatEnvMarker(key: string, defaultValue?: string): string {
|
|
40
|
+
const marker =
|
|
41
|
+
defaultValue === undefined
|
|
42
|
+
? `${ENV_MARKER_PREFIX}\${${key}}`
|
|
43
|
+
: `${ENV_MARKER_PREFIX}\${${key}:${defaultValue}}`;
|
|
44
|
+
const parsed = parseEnvMarker(marker);
|
|
45
|
+
if (parsed?.key !== key || parsed.default !== defaultValue) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`var ${JSON.stringify(key)} with default ${JSON.stringify(defaultValue)} cannot be written as an !ENV marker: a name is non-empty without '{', '}' or ':', a default is non-empty without '}'`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return marker;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every variable name any marker leaf anywhere under `value` references. */
|
|
54
|
+
export function collectEnvRefs(value: unknown): Set<string> {
|
|
55
|
+
const refs = new Set<string>();
|
|
56
|
+
const walk = (node: unknown): void => {
|
|
57
|
+
if (typeof node === 'string') {
|
|
58
|
+
if (!node.startsWith(ENV_MARKER_PREFIX)) return;
|
|
59
|
+
for (const match of node.slice(ENV_MARKER_PREFIX.length).matchAll(ANY_REF)) {
|
|
60
|
+
if (match[1] !== undefined) refs.add(match[1]);
|
|
61
|
+
}
|
|
62
|
+
} else if (Array.isArray(node)) {
|
|
63
|
+
for (const item of node) walk(item);
|
|
64
|
+
} else if (typeof node === 'object' && node !== null) {
|
|
65
|
+
for (const nested of Object.values(node)) walk(nested);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
walk(value);
|
|
69
|
+
return refs;
|
|
70
|
+
}
|
|
@@ -75,7 +75,7 @@ function requiredFieldsValid(
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* The optional fields, each rejected only when PRESENT with the wrong type: an
|
|
78
|
-
* absent value
|
|
78
|
+
* absent value takes its schema default (`null`, or `false` for `sensitive`) on assembly. `media`
|
|
79
79
|
* items stay unknown by design — each is validated per item by the renderer.
|
|
80
80
|
*/
|
|
81
81
|
function optionalFieldsValid(obj: Record<string, unknown>): boolean {
|
|
@@ -100,12 +100,14 @@ function buildInteraction(obj: Record<string, unknown> & RequiredFrameFields): I
|
|
|
100
100
|
created_at: obj.created_at,
|
|
101
101
|
timeout_at: obj.timeout_at,
|
|
102
102
|
sensitive: typeof obj.sensitive === 'boolean' ? obj.sensitive : false,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
server_verified: typeof obj.server_verified === 'boolean' ? obj.server_verified : null,
|
|
104
|
+
channel: typeof obj.channel === 'string' ? obj.channel : null,
|
|
105
|
+
recipient: typeof obj.recipient === 'string' ? obj.recipient : null,
|
|
106
|
+
origin: typeof obj.origin === 'string' ? obj.origin : null,
|
|
107
|
+
audience: typeof obj.audience === 'string' ? obj.audience : null,
|
|
108
|
+
// The items stay unvalidated here: the renderer validates each one, so one malformed
|
|
109
|
+
// item is a per-item notice rather than a dropped frame.
|
|
110
|
+
media: Array.isArray(obj.media) ? (obj.media as Interaction['media']) : null,
|
|
109
111
|
};
|
|
110
112
|
}
|
|
111
113
|
|