@reactvision/react-viro 2.57.0 → 2.57.3

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.
Files changed (101) hide show
  1. package/README.md +8 -0
  2. package/android/react_viro/react_viro-release.aar +0 -0
  3. package/android/viro_renderer/viro_renderer-release.aar +0 -0
  4. package/components/AR/ViroARPlane.tsx +1 -4
  5. package/components/AR/ViroARPlaneSelector.tsx +10 -5
  6. package/components/AR/ViroARScene.tsx +2 -4
  7. package/components/AR/ViroARSceneNavigator.tsx +11 -3
  8. package/components/Studio/StudioARScene.tsx +243 -48
  9. package/components/Studio/StudioSceneNavigator.tsx +15 -0
  10. package/components/Studio/VRTStudioModule.ts +10 -0
  11. package/components/Studio/domain/StudioSounds.tsx +46 -0
  12. package/components/Studio/domain/apiRequestHelpers.ts +612 -0
  13. package/components/Studio/domain/collisionBindingsRuntime.ts +17 -2
  14. package/components/Studio/domain/defaultApiRequestExecutor.ts +61 -0
  15. package/components/Studio/domain/expressionEvaluator.ts +603 -0
  16. package/components/Studio/domain/sceneNavigationHandler.ts +770 -20
  17. package/components/Studio/domain/soundManager.ts +141 -0
  18. package/components/Studio/domain/utils.ts +50 -0
  19. package/components/Studio/domain/variableStore.ts +74 -0
  20. package/components/Studio/domain/viroNodeFactory.tsx +175 -44
  21. package/components/Studio/domain/visibilityStore.ts +66 -0
  22. package/components/Studio/index.ts +1 -0
  23. package/components/Studio/types.ts +178 -1
  24. package/components/Utilities/VRModuleOpenXR.ts +35 -0
  25. package/components/Utilities/ViroVersion.ts +1 -1
  26. package/components/ViroAnimatedImage.tsx +1 -0
  27. package/components/ViroFlexView.tsx +1 -0
  28. package/components/ViroImage.tsx +1 -0
  29. package/components/ViroObjectDetector.tsx +13 -5
  30. package/components/ViroParticleEmitter.tsx +2 -0
  31. package/components/ViroPolygon.tsx +2 -1
  32. package/components/ViroQuad.tsx +2 -1
  33. package/components/ViroSurface.tsx +1 -0
  34. package/components/ViroVideo.tsx +1 -0
  35. package/components/ViroXRSceneNavigator.tsx +8 -0
  36. package/dist/components/AR/ViroARPlane.d.ts +1 -1
  37. package/dist/components/AR/ViroARPlane.js +1 -5
  38. package/dist/components/AR/ViroARPlaneSelector.d.ts +1 -1
  39. package/dist/components/AR/ViroARPlaneSelector.js +10 -5
  40. package/dist/components/AR/ViroARScene.d.ts +1 -1
  41. package/dist/components/AR/ViroARScene.js +2 -5
  42. package/dist/components/AR/ViroARSceneNavigator.d.ts +11 -3
  43. package/dist/components/Studio/StudioARScene.d.ts +3 -0
  44. package/dist/components/Studio/StudioARScene.js +142 -15
  45. package/dist/components/Studio/StudioSceneNavigator.js +14 -0
  46. package/dist/components/Studio/VRTStudioModule.d.ts +6 -0
  47. package/dist/components/Studio/VRTStudioModule.js +10 -0
  48. package/dist/components/Studio/domain/StudioSounds.d.ts +11 -0
  49. package/dist/components/Studio/domain/StudioSounds.js +69 -0
  50. package/dist/components/Studio/domain/apiRequestHelpers.d.ts +180 -0
  51. package/dist/components/Studio/domain/apiRequestHelpers.js +496 -0
  52. package/dist/components/Studio/domain/collisionBindingsRuntime.d.ts +3 -1
  53. package/dist/components/Studio/domain/collisionBindingsRuntime.js +4 -3
  54. package/dist/components/Studio/domain/defaultApiRequestExecutor.d.ts +14 -0
  55. package/dist/components/Studio/domain/defaultApiRequestExecutor.js +58 -0
  56. package/dist/components/Studio/domain/expressionEvaluator.d.ts +108 -0
  57. package/dist/components/Studio/domain/expressionEvaluator.js +520 -0
  58. package/dist/components/Studio/domain/sceneNavigationHandler.d.ts +35 -8
  59. package/dist/components/Studio/domain/sceneNavigationHandler.js +564 -22
  60. package/dist/components/Studio/domain/soundManager.d.ts +52 -0
  61. package/dist/components/Studio/domain/soundManager.js +118 -0
  62. package/dist/components/Studio/domain/utils.d.ts +16 -0
  63. package/dist/components/Studio/domain/utils.js +48 -0
  64. package/dist/components/Studio/domain/variableStore.d.ts +21 -0
  65. package/dist/components/Studio/domain/variableStore.js +65 -0
  66. package/dist/components/Studio/domain/viroNodeFactory.d.ts +4 -9
  67. package/dist/components/Studio/domain/viroNodeFactory.js +84 -25
  68. package/dist/components/Studio/domain/visibilityStore.d.ts +33 -0
  69. package/dist/components/Studio/domain/visibilityStore.js +66 -0
  70. package/dist/components/Studio/index.d.ts +1 -1
  71. package/dist/components/Studio/types.d.ts +150 -1
  72. package/dist/components/Utilities/VRModuleOpenXR.d.ts +19 -0
  73. package/dist/components/Utilities/VRModuleOpenXR.js +16 -0
  74. package/dist/components/Utilities/ViroVersion.d.ts +1 -1
  75. package/dist/components/Utilities/ViroVersion.js +1 -1
  76. package/dist/components/ViroAnimatedImage.js +1 -0
  77. package/dist/components/ViroFlexView.js +1 -0
  78. package/dist/components/ViroImage.js +1 -0
  79. package/dist/components/ViroObjectDetector.d.ts +13 -5
  80. package/dist/components/ViroObjectDetector.js +13 -5
  81. package/dist/components/ViroParticleEmitter.js +2 -0
  82. package/dist/components/ViroPolygon.js +1 -0
  83. package/dist/components/ViroQuad.js +1 -0
  84. package/dist/components/ViroSurface.js +1 -0
  85. package/dist/components/ViroVideo.js +1 -0
  86. package/dist/components/ViroXRSceneNavigator.d.ts +8 -0
  87. package/dist/index.d.ts +4 -4
  88. package/dist/index.js +2 -1
  89. package/dist/plugins/withViroAndroid.js +18 -0
  90. package/index.ts +4 -3
  91. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreCoreMLSemanticsResources.bundle/Info.plist +0 -0
  92. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreResources.bundle/Info.plist +0 -0
  93. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARFrameiOS.h +8 -0
  94. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARSessioniOS.h +14 -3
  95. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROFrontCameraProvider.h +34 -0
  96. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROViewAR.h +3 -1
  97. package/ios/dist/ViroRenderer/ViroKit.framework/Info.plist +0 -0
  98. package/ios/dist/ViroRenderer/ViroKit.framework/Shaders.dat +1 -1
  99. package/ios/dist/ViroRenderer/ViroKit.framework/ViroKit +0 -0
  100. package/ios/dist/lib/libViroReact.a +0 -0
  101. package/package.json +11 -11
@@ -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 { executeFunctionWithRelations } from "./sceneNavigationHandler";
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(fn, sceneNavigator, animations, onAnimationTrigger, 0, onSceneChange);
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
  }