@reactvision/react-viro 2.56.0 → 2.57.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/android/react_viro/react_viro-release.aar +0 -0
- package/android/viro_renderer/viro_renderer-release.aar +0 -0
- package/components/AR/ViroARPlane.tsx +1 -4
- package/components/AR/ViroARPlaneSelector.tsx +10 -5
- package/components/AR/ViroARScene.tsx +19 -4
- package/components/Studio/StudioARScene.tsx +243 -48
- package/components/Studio/StudioSceneNavigator.tsx +15 -0
- package/components/Studio/VRTStudioModule.ts +10 -0
- package/components/Studio/domain/StudioSounds.tsx +46 -0
- package/components/Studio/domain/apiRequestHelpers.ts +612 -0
- package/components/Studio/domain/collisionBindingsRuntime.ts +17 -2
- package/components/Studio/domain/defaultApiRequestExecutor.ts +61 -0
- package/components/Studio/domain/expressionEvaluator.ts +603 -0
- package/components/Studio/domain/sceneNavigationHandler.ts +770 -20
- package/components/Studio/domain/soundManager.ts +141 -0
- package/components/Studio/domain/utils.ts +50 -0
- package/components/Studio/domain/variableStore.ts +74 -0
- package/components/Studio/domain/viroNodeFactory.tsx +175 -44
- package/components/Studio/domain/visibilityStore.ts +66 -0
- package/components/Studio/index.ts +1 -0
- package/components/Studio/types.ts +178 -1
- package/components/Utilities/VRModuleOpenXR.ts +35 -0
- package/components/Utilities/ViroVersion.ts +1 -1
- package/components/ViroAnimatedImage.tsx +1 -0
- package/components/ViroFlexView.tsx +1 -0
- package/components/ViroImage.tsx +1 -0
- package/components/ViroObjectDetector.tsx +230 -0
- package/components/ViroParticleEmitter.tsx +2 -0
- package/components/ViroPolygon.tsx +2 -1
- package/components/ViroQuad.tsx +2 -1
- package/components/ViroSurface.tsx +1 -0
- package/components/ViroVideo.tsx +1 -0
- package/components/ViroXRSceneNavigator.tsx +8 -0
- package/dist/components/AR/ViroARPlane.d.ts +1 -1
- package/dist/components/AR/ViroARPlane.js +1 -5
- package/dist/components/AR/ViroARPlaneSelector.d.ts +1 -1
- package/dist/components/AR/ViroARPlaneSelector.js +10 -5
- package/dist/components/AR/ViroARScene.d.ts +13 -1
- package/dist/components/AR/ViroARScene.js +10 -6
- package/dist/components/Studio/StudioARScene.d.ts +3 -0
- package/dist/components/Studio/StudioARScene.js +142 -15
- package/dist/components/Studio/StudioSceneNavigator.js +14 -0
- package/dist/components/Studio/VRTStudioModule.d.ts +6 -0
- package/dist/components/Studio/VRTStudioModule.js +10 -0
- package/dist/components/Studio/domain/StudioSounds.d.ts +11 -0
- package/dist/components/Studio/domain/StudioSounds.js +69 -0
- package/dist/components/Studio/domain/apiRequestHelpers.d.ts +180 -0
- package/dist/components/Studio/domain/apiRequestHelpers.js +496 -0
- package/dist/components/Studio/domain/collisionBindingsRuntime.d.ts +3 -1
- package/dist/components/Studio/domain/collisionBindingsRuntime.js +4 -3
- package/dist/components/Studio/domain/defaultApiRequestExecutor.d.ts +14 -0
- package/dist/components/Studio/domain/defaultApiRequestExecutor.js +58 -0
- package/dist/components/Studio/domain/expressionEvaluator.d.ts +108 -0
- package/dist/components/Studio/domain/expressionEvaluator.js +520 -0
- package/dist/components/Studio/domain/sceneNavigationHandler.d.ts +35 -8
- package/dist/components/Studio/domain/sceneNavigationHandler.js +564 -22
- package/dist/components/Studio/domain/soundManager.d.ts +52 -0
- package/dist/components/Studio/domain/soundManager.js +118 -0
- package/dist/components/Studio/domain/utils.d.ts +16 -0
- package/dist/components/Studio/domain/utils.js +48 -0
- package/dist/components/Studio/domain/variableStore.d.ts +21 -0
- package/dist/components/Studio/domain/variableStore.js +65 -0
- package/dist/components/Studio/domain/viroNodeFactory.d.ts +4 -9
- package/dist/components/Studio/domain/viroNodeFactory.js +84 -25
- package/dist/components/Studio/domain/visibilityStore.d.ts +33 -0
- package/dist/components/Studio/domain/visibilityStore.js +66 -0
- package/dist/components/Studio/index.d.ts +1 -1
- package/dist/components/Studio/types.d.ts +150 -1
- package/dist/components/Utilities/VRModuleOpenXR.d.ts +19 -0
- package/dist/components/Utilities/VRModuleOpenXR.js +16 -0
- package/dist/components/Utilities/ViroVersion.d.ts +1 -1
- package/dist/components/Utilities/ViroVersion.js +1 -1
- package/dist/components/ViroAnimatedImage.js +1 -0
- package/dist/components/ViroFlexView.js +1 -0
- package/dist/components/ViroImage.js +1 -0
- package/dist/components/ViroObjectDetector.d.ts +146 -0
- package/dist/components/ViroObjectDetector.js +93 -0
- package/dist/components/ViroParticleEmitter.js +2 -0
- package/dist/components/ViroPolygon.js +1 -0
- package/dist/components/ViroQuad.js +1 -0
- package/dist/components/ViroSurface.js +1 -0
- package/dist/components/ViroVideo.js +1 -0
- package/dist/components/ViroXRSceneNavigator.d.ts +8 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +5 -2
- package/dist/plugins/withViro.js +2 -15
- package/dist/plugins/withViroAndroid.js +18 -5
- package/dist/plugins/withViroIos.js +0 -6
- package/index.ts +7 -3
- package/ios/ViroReact.podspec +25 -6
- package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreCoreMLSemanticsResources.bundle/Info.plist +0 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreResources.bundle/Info.plist +0 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARFrameiOS.h +9 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARScene.h +14 -1
- package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARSceneDelegateiOS.h +8 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROGLTFLoader.h +5 -1
- package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROMonocularDepthEstimator.h +10 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROObjectDetector.h +134 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/Info.plist +0 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/Shaders.dat +1 -1
- package/ios/dist/ViroRenderer/ViroKit.framework/ViroKit +0 -0
- package/ios/dist/ViroRendererVisionOS/ViroKit.podspec +27 -0
- package/ios/dist/include/VRTARScene.h +1 -0
- package/ios/dist/lib/libViroReact.a +0 -0
- package/package.json +17 -20
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Request helpers: template validation + interpolation (URL / headers /
|
|
3
|
+
* JSON body), allowlist host matching, dot-path response extraction, and
|
|
4
|
+
* response->variable binding application. Pure logic, no platform APIs (no
|
|
5
|
+
* URL constructor — Hermes-safe), shared by the Studio editor, the
|
|
6
|
+
* scene-api-request egress proxy, and the scene runtime.
|
|
7
|
+
*
|
|
8
|
+
* Dual-maintained: this file exists in the Viro repo
|
|
9
|
+
* (components/Studio/domain/) and the ReactVisionStudio monorepo
|
|
10
|
+
* (packages/common/scene-assets/). Keep the two copies identical.
|
|
11
|
+
*
|
|
12
|
+
* Interpolation rules (fail closed — unknown or malformed placeholders are
|
|
13
|
+
* errors, never substituted with empty strings):
|
|
14
|
+
* - Placeholders are written {{name}} where name matches the
|
|
15
|
+
* scene_variables name constraint.
|
|
16
|
+
* - URL: the origin (scheme://host[:port]) must be static; placeholders are
|
|
17
|
+
* only legal after it and substitute percent-encoded.
|
|
18
|
+
* - Headers: values substitute as strings; control characters are rejected.
|
|
19
|
+
* - Body: the template must parse as JSON. A string value that is EXACTLY
|
|
20
|
+
* one placeholder substitutes as the typed value (number stays number);
|
|
21
|
+
* embedded placeholders substitute stringified; placeholders outside
|
|
22
|
+
* string values are invalid JSON and rejected. Re-serialisation makes
|
|
23
|
+
* structural injection impossible.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type ApiVariableType = "BOOLEAN" | "NUMBER" | "STRING";
|
|
27
|
+
export type ApiPrimitiveValue = boolean | number | string;
|
|
28
|
+
|
|
29
|
+
export type ApiRequestBindingSource = "BODY" | "STATUS" | "OK" | "ERROR_MESSAGE";
|
|
30
|
+
|
|
31
|
+
export type ApiHeaderTemplate = { key: string; value_template: string };
|
|
32
|
+
|
|
33
|
+
export const API_REQUEST_TIMEOUT_MIN_MS = 1000;
|
|
34
|
+
export const API_REQUEST_TIMEOUT_MAX_MS = 30000;
|
|
35
|
+
export const API_REQUEST_TIMEOUT_DEFAULT_MS = 10000;
|
|
36
|
+
export const API_REQUEST_MAX_URL_LENGTH = 2048;
|
|
37
|
+
export const API_REQUEST_MAX_BODY_LENGTH = 65536;
|
|
38
|
+
export const API_REQUEST_MAX_SELECTOR_LENGTH = 256;
|
|
39
|
+
export const API_REQUEST_MAX_VARIABLES = 32;
|
|
40
|
+
export const API_REQUEST_MAX_VARIABLE_VALUE_LENGTH = 4096;
|
|
41
|
+
|
|
42
|
+
export const API_REQUEST_METHODS = [
|
|
43
|
+
"GET",
|
|
44
|
+
"POST",
|
|
45
|
+
"PUT",
|
|
46
|
+
"PATCH",
|
|
47
|
+
"DELETE",
|
|
48
|
+
] as const;
|
|
49
|
+
export type ApiRequestMethod = (typeof API_REQUEST_METHODS)[number];
|
|
50
|
+
|
|
51
|
+
const PLACEHOLDER_NAME = "[A-Za-z_][A-Za-z0-9_]{0,63}";
|
|
52
|
+
const PLACEHOLDER_GLOBAL = new RegExp(`\\{\\{(${PLACEHOLDER_NAME})\\}\\}`, "g");
|
|
53
|
+
const WHOLE_PLACEHOLDER = new RegExp(`^\\{\\{(${PLACEHOLDER_NAME})\\}\\}$`);
|
|
54
|
+
|
|
55
|
+
export type TemplateValidation =
|
|
56
|
+
| { ok: true; names: string[] }
|
|
57
|
+
| { ok: false; error: string };
|
|
58
|
+
|
|
59
|
+
type Fail = { ok: false; error: string };
|
|
60
|
+
|
|
61
|
+
const fail = (error: string): Fail => ({ ok: false, error });
|
|
62
|
+
|
|
63
|
+
/** Names of every well-formed placeholder, in order of appearance. */
|
|
64
|
+
export const extractPlaceholders = (template: string): string[] => {
|
|
65
|
+
const names: string[] = [];
|
|
66
|
+
for (const match of template.matchAll(PLACEHOLDER_GLOBAL)) {
|
|
67
|
+
names.push(match[1]);
|
|
68
|
+
}
|
|
69
|
+
return names;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A template string is valid when every "{{" / "}}" belongs to a well-formed
|
|
74
|
+
* placeholder. Malformed names ({{9x}}, {{a b}}) and stray braces are errors.
|
|
75
|
+
*/
|
|
76
|
+
export const validateTemplateString = (template: string): TemplateValidation => {
|
|
77
|
+
const names = extractPlaceholders(template);
|
|
78
|
+
const residue = template.replace(PLACEHOLDER_GLOBAL, "");
|
|
79
|
+
if (residue.includes("{{") || residue.includes("}}")) {
|
|
80
|
+
return fail("Malformed placeholder; use {{variableName}}");
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, names };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type ValueLookup = (name: string) => ApiPrimitiveValue | undefined;
|
|
86
|
+
|
|
87
|
+
type SubstituteResult = { ok: true; value: string } | Fail;
|
|
88
|
+
|
|
89
|
+
const substitute = (
|
|
90
|
+
template: string,
|
|
91
|
+
get: ValueLookup,
|
|
92
|
+
encode?: (raw: string) => string,
|
|
93
|
+
): SubstituteResult => {
|
|
94
|
+
const validation = validateTemplateString(template);
|
|
95
|
+
if (!validation.ok) return validation;
|
|
96
|
+
|
|
97
|
+
let missing: string | null = null;
|
|
98
|
+
const value = template.replace(PLACEHOLDER_GLOBAL, (_, name: string) => {
|
|
99
|
+
const resolved = get(name);
|
|
100
|
+
if (resolved === undefined) {
|
|
101
|
+
missing = missing ?? name;
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
const raw = String(resolved);
|
|
105
|
+
return encode ? encode(raw) : raw;
|
|
106
|
+
});
|
|
107
|
+
if (missing) return fail(`Unknown variable {{${missing}}}`);
|
|
108
|
+
return { ok: true, value };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fail-soft interpolation for display strings (alert title / message). Unlike
|
|
113
|
+
* the request templates this never errors: a well-formed {{name}} whose value
|
|
114
|
+
* is missing is left verbatim, so a stale or mistyped reference stays visible
|
|
115
|
+
* rather than blanking the field or suppressing the whole alert. Malformed
|
|
116
|
+
* braces (not a valid placeholder) are left untouched.
|
|
117
|
+
*/
|
|
118
|
+
export const interpolateDisplayTemplate = (
|
|
119
|
+
template: string,
|
|
120
|
+
get: ValueLookup,
|
|
121
|
+
): string =>
|
|
122
|
+
template.replace(PLACEHOLDER_GLOBAL, (whole, name: string) => {
|
|
123
|
+
const resolved = get(name);
|
|
124
|
+
return resolved === undefined ? whole : String(resolved);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── URL templates ───────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
// Static origin grammar: https://host[:port] with >= 2 lowercase ASCII labels
|
|
130
|
+
// (same grammar as allowlist patterns; IDNs must be pre-punycoded).
|
|
131
|
+
const URL_ORIGIN_PATTERN =
|
|
132
|
+
/^https:\/\/(([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]*[a-z0-9])?)(:([0-9]{1,5}))?$/;
|
|
133
|
+
|
|
134
|
+
export type UrlTemplateValidation =
|
|
135
|
+
| { ok: true; host: string; port: string | null; names: string[] }
|
|
136
|
+
| Fail;
|
|
137
|
+
|
|
138
|
+
const splitOrigin = (template: string): { origin: string; rest: string } => {
|
|
139
|
+
const afterScheme = "https://".length;
|
|
140
|
+
const pathStart = template.slice(afterScheme).search(/[/?#]/);
|
|
141
|
+
if (pathStart === -1) return { origin: template, rest: "" };
|
|
142
|
+
return {
|
|
143
|
+
origin: template.slice(0, afterScheme + pathStart),
|
|
144
|
+
rest: template.slice(afterScheme + pathStart),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validates scheme + static origin (no placeholders, no userinfo, no IPv6
|
|
150
|
+
* literal) and the placeholder syntax of the whole template. Returns the
|
|
151
|
+
* pinned host (lowercase, no port) and explicit port if present.
|
|
152
|
+
*/
|
|
153
|
+
export const validateUrlTemplate = (template: string): UrlTemplateValidation => {
|
|
154
|
+
if (template.length > API_REQUEST_MAX_URL_LENGTH) {
|
|
155
|
+
return fail("URL is too long");
|
|
156
|
+
}
|
|
157
|
+
if (!/^https:\/\//i.test(template)) {
|
|
158
|
+
return fail("URL must start with https://");
|
|
159
|
+
}
|
|
160
|
+
const { origin } = splitOrigin(template);
|
|
161
|
+
if (origin.includes("{") || origin.includes("}")) {
|
|
162
|
+
return fail("Placeholders are not allowed in the URL host");
|
|
163
|
+
}
|
|
164
|
+
if (origin.includes("@")) {
|
|
165
|
+
return fail("Credentials are not allowed in the URL");
|
|
166
|
+
}
|
|
167
|
+
const originMatch = URL_ORIGIN_PATTERN.exec(origin.toLowerCase());
|
|
168
|
+
if (!originMatch) {
|
|
169
|
+
return fail("Invalid URL host");
|
|
170
|
+
}
|
|
171
|
+
const whole = validateTemplateString(template);
|
|
172
|
+
if (!whole.ok) return whole;
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
host: originMatch[1],
|
|
176
|
+
port: originMatch[6] ?? null,
|
|
177
|
+
names: whole.names,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export type UrlInterpolation = { ok: true; url: string } | Fail;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Substitutes placeholders percent-encoded into the path/query part. The
|
|
185
|
+
* origin is template-static by validation; the post-substitution recheck is
|
|
186
|
+
* belt-and-braces.
|
|
187
|
+
*/
|
|
188
|
+
export const interpolateUrlTemplate = (
|
|
189
|
+
template: string,
|
|
190
|
+
get: ValueLookup,
|
|
191
|
+
): UrlInterpolation => {
|
|
192
|
+
const validation = validateUrlTemplate(template);
|
|
193
|
+
if (!validation.ok) return validation;
|
|
194
|
+
|
|
195
|
+
const { origin, rest } = splitOrigin(template);
|
|
196
|
+
const substituted = substitute(rest, get, encodeURIComponent);
|
|
197
|
+
if (!substituted.ok) return substituted;
|
|
198
|
+
|
|
199
|
+
const url = origin + substituted.value;
|
|
200
|
+
const recheck = splitOrigin(url);
|
|
201
|
+
if (recheck.origin !== origin || recheck.origin.includes("@")) {
|
|
202
|
+
return fail("URL host changed after interpolation");
|
|
203
|
+
}
|
|
204
|
+
return { ok: true, url };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// ── Header templates ────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
export const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
|
|
210
|
+
|
|
211
|
+
// Never author-controllable: request framing, proxy semantics, and anything
|
|
212
|
+
// the platform itself sets. The connection auth header is appended per call.
|
|
213
|
+
const FORBIDDEN_HEADER_NAMES = new Set([
|
|
214
|
+
"host",
|
|
215
|
+
"content-length",
|
|
216
|
+
"transfer-encoding",
|
|
217
|
+
"connection",
|
|
218
|
+
"upgrade",
|
|
219
|
+
"te",
|
|
220
|
+
"trailer",
|
|
221
|
+
"expect",
|
|
222
|
+
"forwarded",
|
|
223
|
+
]);
|
|
224
|
+
const FORBIDDEN_HEADER_PREFIXES = ["proxy-", "sec-", "x-forwarded-"];
|
|
225
|
+
|
|
226
|
+
export const isForbiddenHeaderName = (name: string): boolean => {
|
|
227
|
+
const lower = name.toLowerCase();
|
|
228
|
+
if (FORBIDDEN_HEADER_NAMES.has(lower)) return true;
|
|
229
|
+
return FORBIDDEN_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export type HeadersValidation = { ok: true } | Fail;
|
|
233
|
+
|
|
234
|
+
export const validateHeaderTemplates = (
|
|
235
|
+
headers: ApiHeaderTemplate[],
|
|
236
|
+
options?: { reservedNames?: string[] },
|
|
237
|
+
): HeadersValidation => {
|
|
238
|
+
const reserved = new Set(
|
|
239
|
+
(options?.reservedNames ?? []).map((name) => name.toLowerCase()),
|
|
240
|
+
);
|
|
241
|
+
for (const header of headers) {
|
|
242
|
+
if (!HEADER_NAME_PATTERN.test(header.key)) {
|
|
243
|
+
return fail(`Invalid header name "${header.key}"`);
|
|
244
|
+
}
|
|
245
|
+
if (isForbiddenHeaderName(header.key)) {
|
|
246
|
+
return fail(`Header "${header.key}" is not allowed`);
|
|
247
|
+
}
|
|
248
|
+
if (reserved.has(header.key.toLowerCase())) {
|
|
249
|
+
return fail(`Header "${header.key}" is set by the connection`);
|
|
250
|
+
}
|
|
251
|
+
const value = validateTemplateString(header.value_template);
|
|
252
|
+
if (!value.ok) return value;
|
|
253
|
+
}
|
|
254
|
+
return { ok: true };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export type HeadersInterpolation =
|
|
258
|
+
| { ok: true; headers: { key: string; value: string }[] }
|
|
259
|
+
| Fail;
|
|
260
|
+
|
|
261
|
+
export const interpolateHeaders = (
|
|
262
|
+
headers: ApiHeaderTemplate[],
|
|
263
|
+
get: ValueLookup,
|
|
264
|
+
options?: { reservedNames?: string[] },
|
|
265
|
+
): HeadersInterpolation => {
|
|
266
|
+
const validation = validateHeaderTemplates(headers, options);
|
|
267
|
+
if (!validation.ok) return validation;
|
|
268
|
+
|
|
269
|
+
const result: { key: string; value: string }[] = [];
|
|
270
|
+
for (const header of headers) {
|
|
271
|
+
const substituted = substitute(header.value_template, get);
|
|
272
|
+
if (!substituted.ok) return substituted;
|
|
273
|
+
// eslint-disable-next-line no-control-regex
|
|
274
|
+
if (/[\x00-\x1F\x7F]/.test(substituted.value)) {
|
|
275
|
+
return fail(`Header "${header.key}" value contains control characters`);
|
|
276
|
+
}
|
|
277
|
+
result.push({ key: header.key, value: substituted.value });
|
|
278
|
+
}
|
|
279
|
+
return { ok: true, headers: result };
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// ── JSON body templates ─────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
export type BodyValidation = { ok: true; names: string[] } | Fail;
|
|
285
|
+
|
|
286
|
+
const collectBodyPlaceholders = (
|
|
287
|
+
node: unknown,
|
|
288
|
+
names: string[],
|
|
289
|
+
): Fail | null => {
|
|
290
|
+
if (typeof node === "string") {
|
|
291
|
+
const validation = validateTemplateString(node);
|
|
292
|
+
if (!validation.ok) return validation;
|
|
293
|
+
names.push(...validation.names);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (Array.isArray(node)) {
|
|
297
|
+
for (const item of node) {
|
|
298
|
+
const error = collectBodyPlaceholders(item, names);
|
|
299
|
+
if (error) return error;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
if (node !== null && typeof node === "object") {
|
|
304
|
+
for (const [key, value] of Object.entries(node)) {
|
|
305
|
+
if (key.includes("{{") || key.includes("}}")) {
|
|
306
|
+
return fail("Placeholders are not allowed in JSON keys");
|
|
307
|
+
}
|
|
308
|
+
const error = collectBodyPlaceholders(value, names);
|
|
309
|
+
if (error) return error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The body template must be JSON; placeholders may only appear inside string
|
|
317
|
+
* values (a bare {{n}} outside a string is invalid JSON by construction).
|
|
318
|
+
*/
|
|
319
|
+
export const validateBodyTemplate = (template: string): BodyValidation => {
|
|
320
|
+
if (template.length > API_REQUEST_MAX_BODY_LENGTH) {
|
|
321
|
+
return fail("Body is too long");
|
|
322
|
+
}
|
|
323
|
+
let parsed: unknown;
|
|
324
|
+
try {
|
|
325
|
+
parsed = JSON.parse(template);
|
|
326
|
+
} catch {
|
|
327
|
+
return fail("Body must be valid JSON (placeholders only inside strings)");
|
|
328
|
+
}
|
|
329
|
+
const names: string[] = [];
|
|
330
|
+
const error = collectBodyPlaceholders(parsed, names);
|
|
331
|
+
if (error) return error;
|
|
332
|
+
return { ok: true, names };
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export type BodyInterpolation = { ok: true; body: string } | Fail;
|
|
336
|
+
|
|
337
|
+
const interpolateBodyNode = (
|
|
338
|
+
node: unknown,
|
|
339
|
+
get: ValueLookup,
|
|
340
|
+
): { ok: true; value: unknown } | Fail => {
|
|
341
|
+
if (typeof node === "string") {
|
|
342
|
+
const whole = WHOLE_PLACEHOLDER.exec(node);
|
|
343
|
+
if (whole) {
|
|
344
|
+
const resolved = get(whole[1]);
|
|
345
|
+
if (resolved === undefined) return fail(`Unknown variable {{${whole[1]}}}`);
|
|
346
|
+
return { ok: true, value: resolved };
|
|
347
|
+
}
|
|
348
|
+
const substituted = substitute(node, get);
|
|
349
|
+
if (!substituted.ok) return substituted;
|
|
350
|
+
return { ok: true, value: substituted.value };
|
|
351
|
+
}
|
|
352
|
+
if (Array.isArray(node)) {
|
|
353
|
+
const items: unknown[] = [];
|
|
354
|
+
for (const item of node) {
|
|
355
|
+
const result = interpolateBodyNode(item, get);
|
|
356
|
+
if (!result.ok) return result;
|
|
357
|
+
items.push(result.value);
|
|
358
|
+
}
|
|
359
|
+
return { ok: true, value: items };
|
|
360
|
+
}
|
|
361
|
+
if (node !== null && typeof node === "object") {
|
|
362
|
+
const out: Record<string, unknown> = {};
|
|
363
|
+
for (const [key, value] of Object.entries(node)) {
|
|
364
|
+
const result = interpolateBodyNode(value, get);
|
|
365
|
+
if (!result.ok) return result;
|
|
366
|
+
out[key] = result.value;
|
|
367
|
+
}
|
|
368
|
+
return { ok: true, value: out };
|
|
369
|
+
}
|
|
370
|
+
return { ok: true, value: node };
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Parse -> walk -> substitute (typed for whole-placeholder strings) ->
|
|
375
|
+
* re-stringify. The serialiser guarantees escaping, so variable values can
|
|
376
|
+
* never change the JSON structure.
|
|
377
|
+
*/
|
|
378
|
+
export const interpolateJsonBody = (
|
|
379
|
+
template: string,
|
|
380
|
+
get: ValueLookup,
|
|
381
|
+
): BodyInterpolation => {
|
|
382
|
+
const validation = validateBodyTemplate(template);
|
|
383
|
+
if (!validation.ok) return validation;
|
|
384
|
+
const parsed: unknown = JSON.parse(template);
|
|
385
|
+
const result = interpolateBodyNode(parsed, get);
|
|
386
|
+
if (!result.ok) return result;
|
|
387
|
+
return { ok: true, body: JSON.stringify(result.value) };
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// ── Allowlist matching ──────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Pattern grammar: "host.tld" or "*.host.tld", optional ":port". A pattern
|
|
394
|
+
* without a port matches the default HTTPS port only (no explicit port in the
|
|
395
|
+
* URL); a pattern with a port matches exactly that port. The wildcard matches
|
|
396
|
+
* subdomains at a label boundary, never the apex itself.
|
|
397
|
+
*/
|
|
398
|
+
export const matchHostPattern = (
|
|
399
|
+
host: string,
|
|
400
|
+
port: string | null,
|
|
401
|
+
pattern: string,
|
|
402
|
+
): boolean => {
|
|
403
|
+
const colon = pattern.indexOf(":");
|
|
404
|
+
const patternHost = colon === -1 ? pattern : pattern.slice(0, colon);
|
|
405
|
+
const patternPort = colon === -1 ? null : pattern.slice(colon + 1);
|
|
406
|
+
|
|
407
|
+
if (patternPort === null ? port !== null : port !== patternPort) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const lowerHost = host.toLowerCase();
|
|
412
|
+
if (patternHost.startsWith("*.")) {
|
|
413
|
+
const suffix = patternHost.slice(2);
|
|
414
|
+
return lowerHost.endsWith(`.${suffix}`) && lowerHost.length > suffix.length + 1;
|
|
415
|
+
}
|
|
416
|
+
return lowerHost === patternHost;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
export const isHostAllowlisted = (
|
|
420
|
+
host: string,
|
|
421
|
+
port: string | null,
|
|
422
|
+
patterns: string[],
|
|
423
|
+
): boolean => patterns.some((pattern) => matchHostPattern(host, port, pattern));
|
|
424
|
+
|
|
425
|
+
// ── Response extraction (dot-path subset) ───────────────────────────────────
|
|
426
|
+
|
|
427
|
+
// Segment: optional key followed by any number of [n] indices. The selector
|
|
428
|
+
// field stays a plain string so richer JSONPath can be introduced later.
|
|
429
|
+
const SELECTOR_SEGMENT_PATTERN = /^([A-Za-z0-9_$-]+)?((?:\[\d+\])*)$/;
|
|
430
|
+
|
|
431
|
+
type SelectorToken = { kind: "key"; key: string } | { kind: "index"; index: number };
|
|
432
|
+
|
|
433
|
+
const parseSelector = (selector: string): SelectorToken[] | null => {
|
|
434
|
+
if (
|
|
435
|
+
selector.length === 0 ||
|
|
436
|
+
selector.length > API_REQUEST_MAX_SELECTOR_LENGTH
|
|
437
|
+
) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const tokens: SelectorToken[] = [];
|
|
441
|
+
for (const segment of selector.split(".")) {
|
|
442
|
+
const match = SELECTOR_SEGMENT_PATTERN.exec(segment);
|
|
443
|
+
if (!match || segment.length === 0) return null;
|
|
444
|
+
if (match[1]) tokens.push({ kind: "key", key: match[1] });
|
|
445
|
+
if (match[2]) {
|
|
446
|
+
for (const index of match[2].matchAll(/\[(\d+)\]/g)) {
|
|
447
|
+
tokens.push({ kind: "index", index: Number(index[1]) });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return tokens.length > 0 ? tokens : null;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
export const validateSelector = (selector: string): boolean =>
|
|
455
|
+
parseSelector(selector) !== null;
|
|
456
|
+
|
|
457
|
+
export type ExtractionResult = { found: true; value: unknown } | { found: false };
|
|
458
|
+
|
|
459
|
+
/** Walks `data.items[0].name`-style selectors. Missing path -> not found. */
|
|
460
|
+
export const extractByPath = (
|
|
461
|
+
value: unknown,
|
|
462
|
+
selector: string,
|
|
463
|
+
): ExtractionResult => {
|
|
464
|
+
const tokens = parseSelector(selector);
|
|
465
|
+
if (!tokens) return { found: false };
|
|
466
|
+
|
|
467
|
+
let current: unknown = value;
|
|
468
|
+
for (const token of tokens) {
|
|
469
|
+
if (token.kind === "key") {
|
|
470
|
+
if (
|
|
471
|
+
current === null ||
|
|
472
|
+
typeof current !== "object" ||
|
|
473
|
+
Array.isArray(current) ||
|
|
474
|
+
!(token.key in (current as Record<string, unknown>))
|
|
475
|
+
) {
|
|
476
|
+
return { found: false };
|
|
477
|
+
}
|
|
478
|
+
current = (current as Record<string, unknown>)[token.key];
|
|
479
|
+
} else {
|
|
480
|
+
if (!Array.isArray(current) || token.index >= current.length) {
|
|
481
|
+
return { found: false };
|
|
482
|
+
}
|
|
483
|
+
current = current[token.index];
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return { found: true, value: current };
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// ── Bindings ────────────────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
export type ApiRequestBindingInput = {
|
|
492
|
+
source: ApiRequestBindingSource;
|
|
493
|
+
selector: string | null;
|
|
494
|
+
variable_name: string;
|
|
495
|
+
variable_type: ApiVariableType;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
export type ApiRequestOutcomeInput = {
|
|
499
|
+
ok: boolean;
|
|
500
|
+
status: number | null;
|
|
501
|
+
body?: unknown;
|
|
502
|
+
error_message?: string | null;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
export type CoercionResult = { ok: true; value: ApiPrimitiveValue } | { ok: false };
|
|
506
|
+
|
|
507
|
+
/** Primitive-only, lossless-ish coercion into the variable's declared type. */
|
|
508
|
+
export const coerceBindingValue = (
|
|
509
|
+
value: unknown,
|
|
510
|
+
type: ApiVariableType,
|
|
511
|
+
): CoercionResult => {
|
|
512
|
+
if (type === "STRING") {
|
|
513
|
+
if (typeof value === "string") return { ok: true, value };
|
|
514
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
515
|
+
return { ok: true, value: String(value) };
|
|
516
|
+
}
|
|
517
|
+
if (typeof value === "boolean") return { ok: true, value: String(value) };
|
|
518
|
+
return { ok: false };
|
|
519
|
+
}
|
|
520
|
+
if (type === "NUMBER") {
|
|
521
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
522
|
+
return { ok: true, value };
|
|
523
|
+
}
|
|
524
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
525
|
+
const parsed = Number(value);
|
|
526
|
+
if (Number.isFinite(parsed)) return { ok: true, value: parsed };
|
|
527
|
+
}
|
|
528
|
+
return { ok: false };
|
|
529
|
+
}
|
|
530
|
+
if (typeof value === "boolean") return { ok: true, value };
|
|
531
|
+
if (value === "true") return { ok: true, value: true };
|
|
532
|
+
if (value === "false") return { ok: true, value: false };
|
|
533
|
+
return { ok: false };
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
export type ApiRequestBindingWrite = {
|
|
537
|
+
name: string;
|
|
538
|
+
type: ApiVariableType;
|
|
539
|
+
value: ApiPrimitiveValue;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
export type BindingApplication = {
|
|
543
|
+
writes: ApiRequestBindingWrite[];
|
|
544
|
+
warnings: string[];
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Which sources are meaningful per outcome path.
|
|
548
|
+
const SUCCESS_SOURCES: ApiRequestBindingSource[] = ["BODY", "STATUS", "OK"];
|
|
549
|
+
const FAILURE_SOURCES: ApiRequestBindingSource[] = [
|
|
550
|
+
"STATUS",
|
|
551
|
+
"OK",
|
|
552
|
+
"ERROR_MESSAGE",
|
|
553
|
+
];
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Pure: resolves each applicable binding against the outcome and returns the
|
|
557
|
+
* variable writes plus warnings for skipped bindings (missing path, type
|
|
558
|
+
* mismatch). The caller applies writes to its variable store.
|
|
559
|
+
*/
|
|
560
|
+
export const applyBindings = (
|
|
561
|
+
bindings: ApiRequestBindingInput[],
|
|
562
|
+
outcome: ApiRequestOutcomeInput,
|
|
563
|
+
): BindingApplication => {
|
|
564
|
+
const applicable = outcome.ok ? SUCCESS_SOURCES : FAILURE_SOURCES;
|
|
565
|
+
const writes: ApiRequestBindingWrite[] = [];
|
|
566
|
+
const warnings: string[] = [];
|
|
567
|
+
|
|
568
|
+
for (const binding of bindings) {
|
|
569
|
+
if (!applicable.includes(binding.source)) continue;
|
|
570
|
+
|
|
571
|
+
let raw: unknown;
|
|
572
|
+
if (binding.source === "BODY") {
|
|
573
|
+
if (!binding.selector) {
|
|
574
|
+
warnings.push(`Binding to ${binding.variable_name}: missing selector`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const extracted = extractByPath(outcome.body, binding.selector);
|
|
578
|
+
if (!extracted.found) {
|
|
579
|
+
warnings.push(
|
|
580
|
+
`Binding to ${binding.variable_name}: "${binding.selector}" not found in response`,
|
|
581
|
+
);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
raw = extracted.value;
|
|
585
|
+
} else if (binding.source === "STATUS") {
|
|
586
|
+
if (outcome.status === null) {
|
|
587
|
+
warnings.push(`Binding to ${binding.variable_name}: no status code`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
raw = outcome.status;
|
|
591
|
+
} else if (binding.source === "OK") {
|
|
592
|
+
raw = outcome.ok;
|
|
593
|
+
} else {
|
|
594
|
+
raw = outcome.error_message ?? "";
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const coerced = coerceBindingValue(raw, binding.variable_type);
|
|
598
|
+
if (!coerced.ok) {
|
|
599
|
+
warnings.push(
|
|
600
|
+
`Binding to ${binding.variable_name}: value is not a ${binding.variable_type}`,
|
|
601
|
+
);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
writes.push({
|
|
605
|
+
name: binding.variable_name,
|
|
606
|
+
type: binding.variable_type,
|
|
607
|
+
value: coerced.value,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return { writes, warnings };
|
|
612
|
+
};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { MutableRefObject } from "react";
|
|
2
2
|
import { StudioAnimation, StudioCollisionBinding } from "../types";
|
|
3
3
|
import { canonicalizeCollisionAssetIds, collisionPairKey } from "./collisionPairKey";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
executeFunctionWithRelations,
|
|
6
|
+
SequenceRuntimeContext,
|
|
7
|
+
} from "./sceneNavigationHandler";
|
|
5
8
|
|
|
6
9
|
const DEFAULT_COOLDOWN_MS = 750;
|
|
7
10
|
|
|
@@ -23,6 +26,7 @@ export function dispatchCollisionBindingActions(params: {
|
|
|
23
26
|
onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void;
|
|
24
27
|
cooldownMs?: number;
|
|
25
28
|
lastFiredRef: MutableRefObject<Map<string, number>>;
|
|
29
|
+
runtimeCtx?: SequenceRuntimeContext;
|
|
26
30
|
}): void {
|
|
27
31
|
const {
|
|
28
32
|
selfPlacementId,
|
|
@@ -34,6 +38,7 @@ export function dispatchCollisionBindingActions(params: {
|
|
|
34
38
|
onAnimationTrigger,
|
|
35
39
|
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
36
40
|
lastFiredRef,
|
|
41
|
+
runtimeCtx,
|
|
37
42
|
} = params;
|
|
38
43
|
|
|
39
44
|
if (!otherTag) return;
|
|
@@ -58,7 +63,15 @@ export function dispatchCollisionBindingActions(params: {
|
|
|
58
63
|
if (now - last < cooldownMs) continue;
|
|
59
64
|
map.set(ck, now);
|
|
60
65
|
|
|
61
|
-
executeFunctionWithRelations(
|
|
66
|
+
executeFunctionWithRelations(
|
|
67
|
+
fn,
|
|
68
|
+
sceneNavigator,
|
|
69
|
+
animations,
|
|
70
|
+
onAnimationTrigger,
|
|
71
|
+
0,
|
|
72
|
+
onSceneChange,
|
|
73
|
+
runtimeCtx
|
|
74
|
+
);
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
|
|
@@ -73,6 +86,7 @@ export function createPlacementCollisionHandler(
|
|
|
73
86
|
lastFiredRef: MutableRefObject<Map<string, number>>,
|
|
74
87
|
onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void,
|
|
75
88
|
onSceneChange?: (sceneId: string, sceneName: string) => void,
|
|
89
|
+
runtimeCtx?: SequenceRuntimeContext,
|
|
76
90
|
): (
|
|
77
91
|
viroTag: string,
|
|
78
92
|
collidedPoint: [number, number, number],
|
|
@@ -88,6 +102,7 @@ export function createPlacementCollisionHandler(
|
|
|
88
102
|
onSceneChange,
|
|
89
103
|
onAnimationTrigger,
|
|
90
104
|
lastFiredRef,
|
|
105
|
+
runtimeCtx,
|
|
91
106
|
});
|
|
92
107
|
};
|
|
93
108
|
}
|