simdeck 0.1.0 → 0.1.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.
@@ -0,0 +1,729 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import { pathToFileURL } from "node:url";
8
+ import { promisify } from "node:util";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const cloudUrl = (
13
+ process.env.SIMDECK_CLOUD_URL || "https://simdeck.djdev.me"
14
+ ).replace(/\/$/, "");
15
+ let previewId = process.env.PREVIEW_ID || "";
16
+ let providerToken = process.env.PROVIDER_TOKEN || "";
17
+ let publicUrl = process.env.SIMDECK_STUDIO_URL || "";
18
+ let localUrl = (
19
+ process.env.SIMDECK_LOCAL_URL || "http://127.0.0.1:4310"
20
+ ).replace(/\/$/, "");
21
+ let localToken = process.env.SIMDECK_LOCAL_TOKEN || providerToken;
22
+ const registerIntervalMs = Number(
23
+ process.env.SIMDECK_PROVIDER_REGISTER_INTERVAL_MS || 15000,
24
+ );
25
+ const maxConcurrentRequests = Math.max(
26
+ 1,
27
+ Number(process.env.SIMDECK_PROVIDER_MAX_CONCURRENT_REQUESTS || 8),
28
+ );
29
+ const proxyTimeoutMs = Math.max(
30
+ 1000,
31
+ Number(process.env.SIMDECK_PROVIDER_PROXY_TIMEOUT_MS || 25000),
32
+ );
33
+ const cloudRequestTimeoutMs = Math.max(
34
+ 5000,
35
+ Number(process.env.SIMDECK_PROVIDER_CLOUD_TIMEOUT_MS || 30000),
36
+ );
37
+ const simulatorListCacheTtlMs = Math.max(
38
+ 0,
39
+ Number(process.env.SIMDECK_PROVIDER_SIMULATORS_CACHE_MS || 5000),
40
+ );
41
+ const localUnavailableLogIntervalMs = Math.max(
42
+ 5000,
43
+ Number(
44
+ process.env.SIMDECK_PROVIDER_LOCAL_UNAVAILABLE_LOG_INTERVAL_MS || 30000,
45
+ ),
46
+ );
47
+ const localUnavailableRestartMs = Math.max(
48
+ 15000,
49
+ Number(process.env.SIMDECK_PROVIDER_LOCAL_UNAVAILABLE_RESTART_MS || 45000),
50
+ );
51
+ const providerId =
52
+ process.env.SIMDECK_STUDIO_PROVIDER_ID || stableLocalProviderId();
53
+ const parentPid = Number(process.env.SIMDECK_PROVIDER_PARENT_PID || 0);
54
+ let localDaemonPid = Number(process.env.SIMDECK_LOCAL_DAEMON_PID || 0);
55
+ let localDaemonLog = process.env.SIMDECK_LOCAL_DAEMON_LOG || "";
56
+ const localDaemonCommand = process.env.SIMDECK_LOCAL_DAEMON_COMMAND || "";
57
+ const localDaemonRestartArgs = parseJsonArrayEnv(
58
+ "SIMDECK_LOCAL_DAEMON_RESTART_ARGS_JSON",
59
+ );
60
+ const localDaemonStatusArgs = parseJsonArrayEnv(
61
+ "SIMDECK_LOCAL_DAEMON_STATUS_ARGS_JSON",
62
+ ) ?? ["daemon", "status"];
63
+
64
+ let stopped = false;
65
+ let lastRegisterAt = 0;
66
+ let localUnavailableSince = 0;
67
+ let lastLocalUnavailableLogAt = 0;
68
+ let lastLocalRestartAt = 0;
69
+ let localRestartInFlight = null;
70
+ let registered = false;
71
+ let providerMarkedTerminal = false;
72
+ const activeRequests = new Set();
73
+ const responseCache = new Map();
74
+ const inFlightCache = new Map();
75
+
76
+ if (isMainModule()) {
77
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
78
+ process.once(signal, () => {
79
+ stopped = true;
80
+ });
81
+ }
82
+ if (Number.isInteger(parentPid) && parentPid > 0) {
83
+ setInterval(() => {
84
+ try {
85
+ process.kill(parentPid, 0);
86
+ } catch {
87
+ stopped = true;
88
+ }
89
+ }, 1000).unref();
90
+ }
91
+
92
+ try {
93
+ if (!previewId || !providerToken) {
94
+ const session = await createLocalProviderSession();
95
+ previewId = session.sessionId;
96
+ providerToken = session.providerToken;
97
+ publicUrl = session.url;
98
+ }
99
+
100
+ if (!publicUrl) {
101
+ publicUrl = `${cloudUrl}/simulator/${encodeURIComponent(previewId)}`;
102
+ }
103
+ publicUrl = normalizeStudioPublicUrl(publicUrl);
104
+ if (!localToken) {
105
+ localToken = providerToken;
106
+ }
107
+
108
+ await registerProvider();
109
+ console.log(`[simdeck-provider-bridge] ${publicUrl}`);
110
+
111
+ while (!stopped) {
112
+ try {
113
+ if (activeRequests.size >= maxConcurrentRequests) {
114
+ await Promise.race([...activeRequests, sleep(50)]);
115
+ continue;
116
+ }
117
+ if (Date.now() - lastRegisterAt > registerIntervalMs) {
118
+ await registerProvider();
119
+ }
120
+ const next = await fetchJson(
121
+ `${cloudUrl}/api/actions/providers/rpc/next`,
122
+ {
123
+ previewId,
124
+ providerToken,
125
+ },
126
+ );
127
+ if (stopped) {
128
+ break;
129
+ }
130
+ if (!next || !next.request) {
131
+ await sleep(250);
132
+ continue;
133
+ }
134
+ runProviderRequest(next.request);
135
+ } catch (error) {
136
+ if (stopped) {
137
+ break;
138
+ }
139
+ console.error(
140
+ `[simdeck-provider-bridge] ${error instanceof Error ? error.message : String(error)}`,
141
+ );
142
+ await sleep(1000);
143
+ }
144
+ }
145
+ } finally {
146
+ if (activeRequests.size > 0) {
147
+ await Promise.allSettled(activeRequests);
148
+ }
149
+ if (registered && !providerMarkedTerminal) {
150
+ await markProviderExpired();
151
+ }
152
+ }
153
+ }
154
+
155
+ async function registerProvider() {
156
+ try {
157
+ let metadata = await localProviderMetadata();
158
+ updateLocalAvailability(metadata);
159
+ await maybeRestartLocalDaemon(metadata);
160
+ if (!metadata.ok && !localUnavailableSince) {
161
+ metadata = await localProviderMetadata();
162
+ updateLocalAvailability(metadata);
163
+ }
164
+ await fetchJson(`${cloudUrl}/api/actions/providers/register`, {
165
+ previewId,
166
+ providerToken,
167
+ baseUrl: publicUrl,
168
+ status: metadata.ok ? "ready" : "provider-online",
169
+ simulatorUdid: metadata.simulator?.udid,
170
+ simulatorName: metadata.simulator?.name,
171
+ runtimeName: metadata.simulator?.runtimeName,
172
+ videoCodec: metadata.health?.videoCodec,
173
+ realtimeStream: metadata.health?.realtimeStream,
174
+ streamQuality: metadata.health?.streamQuality,
175
+ });
176
+ registered = true;
177
+ lastRegisterAt = Date.now();
178
+ if (
179
+ !stopped &&
180
+ shouldStopForLocalMetadata(metadata, localDaemonProcessExited())
181
+ ) {
182
+ providerMarkedTerminal = true;
183
+ stopped = true;
184
+ await markProviderFailed(
185
+ metadata.failureReason ||
186
+ "Local SimDeck daemon supervisor process exited.",
187
+ );
188
+ }
189
+ } catch (error) {
190
+ console.error(
191
+ `[simdeck-provider-bridge] provider registration failed: ${error instanceof Error ? error.message : String(error)}`,
192
+ );
193
+ }
194
+ }
195
+
196
+ export function shouldStopForLocalMetadata(metadata, daemonProcessExited) {
197
+ return !metadata.ok && daemonProcessExited;
198
+ }
199
+
200
+ function localDaemonProcessExited() {
201
+ if (
202
+ Number.isInteger(localDaemonPid) &&
203
+ localDaemonPid > 0 &&
204
+ !processIsRunning(localDaemonPid)
205
+ ) {
206
+ console.error(
207
+ `[simdeck-provider-bridge] local SimDeck daemon process ${localDaemonPid} is no longer running.`,
208
+ );
209
+ printRecentDaemonLog();
210
+ return true;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ function updateLocalAvailability(metadata) {
216
+ if (stopped) {
217
+ return;
218
+ }
219
+ if (metadata.ok) {
220
+ localUnavailableSince = 0;
221
+ return;
222
+ }
223
+ localUnavailableSince ||= Date.now();
224
+ const elapsed = Date.now() - localUnavailableSince;
225
+ if (Date.now() - lastLocalUnavailableLogAt >= localUnavailableLogIntervalMs) {
226
+ lastLocalUnavailableLogAt = Date.now();
227
+ console.error(
228
+ `[simdeck-provider-bridge] local SimDeck HTTP unavailable for ${elapsed}ms while daemon supervisor is still running; keeping Studio bridge alive.`,
229
+ );
230
+ if (metadata.failureReason) {
231
+ console.error(`[simdeck-provider-bridge] ${metadata.failureReason}`);
232
+ }
233
+ printRecentDaemonLog();
234
+ }
235
+ }
236
+
237
+ function processIsRunning(pid) {
238
+ try {
239
+ process.kill(pid, 0);
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ function printRecentDaemonLog() {
247
+ const lines = recentDaemonLogLines();
248
+ if (!lines) {
249
+ return;
250
+ }
251
+ console.error("[simdeck-provider-bridge] recent daemon log:");
252
+ console.error(lines);
253
+ }
254
+
255
+ function recentDaemonLogLines() {
256
+ if (!localDaemonLog) {
257
+ return "";
258
+ }
259
+ try {
260
+ const data = fs.readFileSync(localDaemonLog, "utf8");
261
+ return data.split(/\r?\n/).filter(Boolean).slice(-20).join("\n");
262
+ } catch {
263
+ return "";
264
+ }
265
+ }
266
+
267
+ async function maybeRestartLocalDaemon(metadata) {
268
+ if (metadata.ok || stopped || !localUnavailableSince) {
269
+ return;
270
+ }
271
+ if (!localDaemonCommand || !localDaemonRestartArgs) {
272
+ return;
273
+ }
274
+ const elapsed = Date.now() - localUnavailableSince;
275
+ if (elapsed < localUnavailableRestartMs) {
276
+ return;
277
+ }
278
+ if (localRestartInFlight) {
279
+ await localRestartInFlight;
280
+ return;
281
+ }
282
+ if (Date.now() - lastLocalRestartAt < localUnavailableRestartMs) {
283
+ return;
284
+ }
285
+
286
+ lastLocalRestartAt = Date.now();
287
+ localRestartInFlight = restartLocalDaemon()
288
+ .catch((error) => {
289
+ console.error(
290
+ `[simdeck-provider-bridge] local SimDeck daemon restart failed: ${describeError(error)}`,
291
+ );
292
+ })
293
+ .finally(() => {
294
+ localRestartInFlight = null;
295
+ });
296
+ await localRestartInFlight;
297
+ }
298
+
299
+ async function restartLocalDaemon() {
300
+ console.error(
301
+ `[simdeck-provider-bridge] local SimDeck HTTP has been unavailable for ${Date.now() - localUnavailableSince}ms; restarting local daemon.`,
302
+ );
303
+ printRecentDaemonLog();
304
+ await execFileAsync(localDaemonCommand, localDaemonRestartArgs, {
305
+ timeout: 90_000,
306
+ windowsHide: true,
307
+ });
308
+ const { stdout } = await execFileAsync(
309
+ localDaemonCommand,
310
+ localDaemonStatusArgs,
311
+ {
312
+ timeout: 15_000,
313
+ windowsHide: true,
314
+ },
315
+ );
316
+ const status = JSON.parse(stdout);
317
+ const daemon = status.daemon ?? status;
318
+ if (daemon.httpUrl) {
319
+ localUrl = String(daemon.httpUrl).replace(/\/$/, "");
320
+ }
321
+ if (daemon.accessToken) {
322
+ localToken = String(daemon.accessToken);
323
+ }
324
+ if (daemon.pid) {
325
+ localDaemonPid = Number(daemon.pid);
326
+ }
327
+ if (daemon.logPath) {
328
+ localDaemonLog = String(daemon.logPath);
329
+ }
330
+ responseCache.clear();
331
+ inFlightCache.clear();
332
+ localUnavailableSince = 0;
333
+ lastLocalUnavailableLogAt = 0;
334
+ console.error(
335
+ `[simdeck-provider-bridge] local SimDeck daemon restarted at ${localUrl}.`,
336
+ );
337
+ }
338
+
339
+ async function createLocalProviderSession() {
340
+ const response = await fetchJson(`${cloudUrl}/api/local-provider-sessions`, {
341
+ providerId,
342
+ simulatorName: process.env.SIMDECK_STUDIO_SIMULATOR_NAME,
343
+ runtimeName: process.env.SIMDECK_STUDIO_RUNTIME_NAME,
344
+ });
345
+ if (!response?.sessionId || !response?.providerToken || !response?.url) {
346
+ throw new Error("Studio did not return a local provider session.");
347
+ }
348
+ return response;
349
+ }
350
+
351
+ async function markProviderExpired() {
352
+ await markProviderStatus("expired");
353
+ }
354
+
355
+ async function markProviderFailed(reason) {
356
+ if (reason) {
357
+ console.error(`[simdeck-provider-bridge] ${reason}`);
358
+ }
359
+ await markProviderStatus("failed");
360
+ }
361
+
362
+ async function markProviderStatus(status) {
363
+ try {
364
+ await fetchJson(`${cloudUrl}/api/actions/providers/register`, {
365
+ previewId,
366
+ providerToken,
367
+ baseUrl: publicUrl,
368
+ status,
369
+ });
370
+ } catch (error) {
371
+ console.error(
372
+ `[simdeck-provider-bridge] provider ${status} update failed: ${error instanceof Error ? error.message : String(error)}`,
373
+ );
374
+ }
375
+ }
376
+
377
+ async function localProviderMetadata() {
378
+ let health = null;
379
+ let healthError = null;
380
+ try {
381
+ health = await localJson("/api/health");
382
+ } catch (error) {
383
+ healthError = error;
384
+ health = null;
385
+ }
386
+
387
+ try {
388
+ const simulators = await localJson("/api/simulators");
389
+ const selected =
390
+ simulators.simulators?.find((simulator) => simulator.isBooted) ??
391
+ simulators.simulators?.[0] ??
392
+ null;
393
+ return { health, ok: true, simulator: selected };
394
+ } catch (error) {
395
+ if (health) {
396
+ return { health, ok: true, simulator: null };
397
+ }
398
+ return {
399
+ failureReason: localProviderFailureReason(healthError, error),
400
+ health: null,
401
+ ok: false,
402
+ simulator: null,
403
+ };
404
+ }
405
+ }
406
+
407
+ function localProviderFailureReason(healthError, simulatorError) {
408
+ const healthMessage = describeError(healthError);
409
+ const simulatorMessage = describeError(simulatorError);
410
+ return [healthMessage, simulatorMessage].filter(Boolean).join("; ");
411
+ }
412
+
413
+ function describeError(error) {
414
+ if (!error) {
415
+ return "";
416
+ }
417
+ if (!(error instanceof Error)) {
418
+ return String(error);
419
+ }
420
+ const cause = error.cause;
421
+ if (cause instanceof Error && cause.message) {
422
+ return `${error.message}: ${cause.message}`;
423
+ }
424
+ return error.message;
425
+ }
426
+
427
+ async function handleRequest(request) {
428
+ let responsePayload;
429
+ if (isWebSocketUpgradeRequest(request)) {
430
+ await complete({
431
+ requestId: request.id,
432
+ responseBodyBase64: Buffer.from(
433
+ "Studio provider RPC does not tunnel WebSocket upgrade requests.",
434
+ ).toString("base64"),
435
+ responseHeaders: { "content-type": "text/plain; charset=utf-8" },
436
+ responseStatus: 426,
437
+ });
438
+ return;
439
+ }
440
+ try {
441
+ responsePayload = await cachedProxyResponse(request);
442
+ } catch (error) {
443
+ console.error(
444
+ `[simdeck-provider-bridge] request ${request.id} ${request.method} ${request.path} failed: ${describeError(error)}`,
445
+ );
446
+ await handleLocalProxyFailure(error);
447
+ if (request.method !== "GET") {
448
+ await complete({
449
+ requestId: request.id,
450
+ error: describeError(error),
451
+ });
452
+ return;
453
+ }
454
+ try {
455
+ responsePayload = await proxyLocalRequest(request);
456
+ } catch (retryError) {
457
+ console.error(
458
+ `[simdeck-provider-bridge] request ${request.id} ${request.method} ${request.path} retry failed: ${describeError(retryError)}`,
459
+ );
460
+ await complete({
461
+ requestId: request.id,
462
+ error: describeError(retryError),
463
+ });
464
+ return;
465
+ }
466
+ }
467
+
468
+ try {
469
+ await complete({
470
+ requestId: request.id,
471
+ ...responsePayload,
472
+ });
473
+ } catch (error) {
474
+ console.error(
475
+ `[simdeck-provider-bridge] request ${request.id} ${request.method} ${request.path} completion failed: ${describeError(error)}`,
476
+ );
477
+ throw error;
478
+ }
479
+ }
480
+
481
+ function runProviderRequest(request) {
482
+ const task = handleRequest(request).catch((error) => {
483
+ console.error(
484
+ `[simdeck-provider-bridge] request ${request.id} failed: ${error instanceof Error ? error.message : String(error)}`,
485
+ );
486
+ });
487
+ activeRequests.add(task);
488
+ task.finally(() => {
489
+ activeRequests.delete(task);
490
+ });
491
+ }
492
+
493
+ async function cachedProxyResponse(request) {
494
+ const cacheKey = cacheKeyForRequest(request);
495
+ if (!cacheKey || simulatorListCacheTtlMs <= 0) {
496
+ return proxyLocalRequest(request);
497
+ }
498
+
499
+ const cached = responseCache.get(cacheKey);
500
+ if (cached && Date.now() - cached.updatedAt <= simulatorListCacheTtlMs) {
501
+ return cached.payload;
502
+ }
503
+
504
+ const pending = inFlightCache.get(cacheKey);
505
+ if (pending) {
506
+ return pending;
507
+ }
508
+
509
+ const pendingRequest = proxyLocalRequest(request)
510
+ .then((payload) => {
511
+ if (payload.responseStatus >= 200 && payload.responseStatus < 300) {
512
+ responseCache.set(cacheKey, { payload, updatedAt: Date.now() });
513
+ }
514
+ return payload;
515
+ })
516
+ .finally(() => {
517
+ inFlightCache.delete(cacheKey);
518
+ });
519
+ inFlightCache.set(cacheKey, pendingRequest);
520
+ return pendingRequest;
521
+ }
522
+
523
+ function cacheKeyForRequest(request) {
524
+ if (request.method !== "GET") {
525
+ return "";
526
+ }
527
+ const target = new URL(request.path, `${localUrl}/`);
528
+ target.searchParams.delete("simdeckToken");
529
+ if (target.pathname !== "/api/simulators") {
530
+ return "";
531
+ }
532
+ return `${target.pathname}?${target.searchParams.toString()}`;
533
+ }
534
+
535
+ export function isWebSocketUpgradeRequest(request) {
536
+ const headers = new Headers(request.headers || {});
537
+ return (
538
+ headers.get("upgrade")?.toLowerCase() === "websocket" ||
539
+ headers
540
+ .get("connection")
541
+ ?.toLowerCase()
542
+ .split(",")
543
+ .some((value) => value.trim() === "upgrade") === true
544
+ );
545
+ }
546
+
547
+ async function proxyLocalRequest(request) {
548
+ const target = new URL(request.path, `${localUrl}/`);
549
+ if (!target.searchParams.has("simdeckToken")) {
550
+ target.searchParams.set("simdeckToken", localToken);
551
+ }
552
+ const headers = new Headers(request.headers || {});
553
+ headers.set("x-simdeck-token", localToken);
554
+ headers.delete("host");
555
+ headers.delete("content-length");
556
+ let response;
557
+ try {
558
+ response = await fetch(target, {
559
+ body: request.bodyBase64
560
+ ? Buffer.from(request.bodyBase64, "base64")
561
+ : undefined,
562
+ headers,
563
+ method: request.method,
564
+ signal: AbortSignal.timeout(proxyTimeoutMs),
565
+ });
566
+ } catch (error) {
567
+ throw new Error(
568
+ `Local SimDeck request ${target.origin}${target.pathname} failed`,
569
+ {
570
+ cause: error,
571
+ },
572
+ );
573
+ }
574
+ const responseHeaders = {};
575
+ for (const [name, value] of response.headers.entries()) {
576
+ const lower = name.toLowerCase();
577
+ if (
578
+ lower === "connection" ||
579
+ lower === "content-encoding" ||
580
+ lower === "content-length" ||
581
+ lower === "transfer-encoding"
582
+ ) {
583
+ continue;
584
+ }
585
+ responseHeaders[name] = value;
586
+ }
587
+ const responseBodyBase64 = Buffer.from(await response.arrayBuffer()).toString(
588
+ "base64",
589
+ );
590
+ return {
591
+ responseStatus: response.status,
592
+ responseHeaders,
593
+ responseBodyBase64,
594
+ };
595
+ }
596
+
597
+ async function handleLocalProxyFailure(error) {
598
+ const message = describeError(error);
599
+ if (!message.includes("Local SimDeck request")) {
600
+ return;
601
+ }
602
+ updateLocalAvailability({
603
+ failureReason: message,
604
+ health: null,
605
+ ok: false,
606
+ simulator: null,
607
+ });
608
+ await maybeRestartLocalDaemon({
609
+ failureReason: message,
610
+ health: null,
611
+ ok: false,
612
+ simulator: null,
613
+ });
614
+ }
615
+
616
+ async function localJson(path) {
617
+ const target = new URL(path, `${localUrl}/`);
618
+ target.searchParams.set("simdeckToken", localToken);
619
+ let response;
620
+ try {
621
+ response = await fetch(target, {
622
+ headers: { "x-simdeck-token": localToken },
623
+ signal: AbortSignal.timeout(Math.min(proxyTimeoutMs, 5000)),
624
+ });
625
+ } catch (error) {
626
+ throw new Error(`Local SimDeck request ${target.origin}${path} failed`, {
627
+ cause: error,
628
+ });
629
+ }
630
+ if (!response.ok) {
631
+ throw new Error(
632
+ `${target.href} failed with ${response.status}: ${await response.text()}`,
633
+ );
634
+ }
635
+ return response.json();
636
+ }
637
+
638
+ async function complete(payload) {
639
+ await fetchJson(`${cloudUrl}/api/actions/providers/rpc/complete`, {
640
+ previewId,
641
+ providerToken,
642
+ ...payload,
643
+ });
644
+ }
645
+
646
+ async function fetchJson(url, body) {
647
+ let response;
648
+ try {
649
+ response = await fetch(url, {
650
+ body: JSON.stringify(body),
651
+ headers: { "content-type": "application/json" },
652
+ method: "POST",
653
+ signal: AbortSignal.timeout(cloudRequestTimeoutMs),
654
+ });
655
+ } catch (error) {
656
+ throw new Error(`Studio request ${url} failed`, { cause: error });
657
+ }
658
+ if (response.status === 204) {
659
+ return null;
660
+ }
661
+ if (!response.ok) {
662
+ throw new Error(
663
+ `${url} failed with ${response.status}: ${await response.text()}`,
664
+ );
665
+ }
666
+ return response.json();
667
+ }
668
+
669
+ function sleep(ms) {
670
+ return new Promise((resolve) => setTimeout(resolve, ms));
671
+ }
672
+
673
+ function normalizeStudioPublicUrl(value) {
674
+ return normalizeStudioPublicUrlWithCloud(value, cloudUrl);
675
+ }
676
+
677
+ export function normalizeStudioPublicUrlWithCloud(value, baseCloudUrl) {
678
+ const trimmed = String(value || "").trim();
679
+ if (!trimmed) {
680
+ return "";
681
+ }
682
+
683
+ const normalizedCloudUrl = baseCloudUrl.replace(/\/$/, "");
684
+ const cloudOrigin = new URL(normalizedCloudUrl).origin;
685
+ const collapsed = trimmed
686
+ .replace(repeatedPrefixPattern(normalizedCloudUrl), normalizedCloudUrl)
687
+ .replace(repeatedPrefixPattern(cloudOrigin), cloudOrigin);
688
+ return new URL(collapsed, `${normalizedCloudUrl}/`).toString();
689
+ }
690
+
691
+ function repeatedPrefixPattern(prefix) {
692
+ return new RegExp(`^(?:${escapeRegExp(prefix)}){2,}`);
693
+ }
694
+
695
+ function escapeRegExp(value) {
696
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
697
+ }
698
+
699
+ function parseJsonArrayEnv(name) {
700
+ const raw = process.env[name];
701
+ if (!raw) {
702
+ return null;
703
+ }
704
+ try {
705
+ const value = JSON.parse(raw);
706
+ if (
707
+ Array.isArray(value) &&
708
+ value.every((item) => typeof item === "string")
709
+ ) {
710
+ return value;
711
+ }
712
+ } catch {
713
+ return null;
714
+ }
715
+ return null;
716
+ }
717
+
718
+ function isMainModule() {
719
+ return import.meta.url === pathToFileURL(process.argv[1] || "").href;
720
+ }
721
+
722
+ function stableLocalProviderId() {
723
+ const fingerprint = [
724
+ os.hostname(),
725
+ localUrl,
726
+ process.env.SIMDECK_STUDIO_SIMULATOR_UDID || "",
727
+ ].join("\n");
728
+ return `local-${crypto.createHash("sha256").update(fingerprint).digest("hex").slice(0, 24)}`;
729
+ }