Skip to content

Commit 5bbf2ef

Browse files
committed
Fix studio provider expose URL normalization
1 parent 11b026d commit 5bbf2ef

4 files changed

Lines changed: 126 additions & 44 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ jobs:
6969
- name: Check Prettier formatting
7070
run: npx prettier --check .
7171

72+
- name: Test studio provider bridge
73+
run: npm run test:studio-provider
74+
7275
- name: Typecheck client
7376
run: npm run --prefix client typecheck
7477

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"test:integration:fixture": "node scripts/integration/prebuild-fixture.mjs",
6666
"test:integration:js-api": "node scripts/integration/js-api.mjs",
6767
"test:integration:js-api:verbose": "SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/js-api.mjs",
68+
"test:studio-provider": "node --test scripts/studio-provider-bridge.test.mjs",
6869
"test:stress": "node scripts/stress/simdeck.mjs",
6970
"ci": "npm run lint && npm run build:all && npm run test && npm run package:vscode-extension",
7071
"dev": "npm run build:cli && node scripts/dev.mjs",

scripts/studio-provider-bridge.mjs

Lines changed: 79 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import crypto from "node:crypto";
44
import os from "node:os";
5+
import { pathToFileURL } from "node:url";
56

67
const cloudUrl = (
78
process.env.SIMDECK_CLOUD_URL || "https://simdeck.djdev.me"
@@ -22,58 +23,62 @@ const providerId =
2223
let stopped = false;
2324
let lastRegisterAt = 0;
2425
let registered = false;
25-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
26-
process.once(signal, () => {
27-
stopped = true;
28-
});
29-
}
3026

31-
try {
32-
if (!previewId || !providerToken) {
33-
const session = await createLocalProviderSession();
34-
previewId = session.sessionId;
35-
providerToken = session.providerToken;
36-
publicUrl = session.url;
27+
if (isMainModule()) {
28+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
29+
process.once(signal, () => {
30+
stopped = true;
31+
});
3732
}
3833

39-
if (!publicUrl) {
40-
publicUrl = `${cloudUrl}/simulator/${encodeURIComponent(previewId)}`;
41-
}
42-
if (!localToken) {
43-
localToken = providerToken;
44-
}
34+
try {
35+
if (!previewId || !providerToken) {
36+
const session = await createLocalProviderSession();
37+
previewId = session.sessionId;
38+
providerToken = session.providerToken;
39+
publicUrl = session.url;
40+
}
4541

46-
console.log(`[simdeck-provider-bridge] ${publicUrl}`);
42+
if (!publicUrl) {
43+
publicUrl = `${cloudUrl}/simulator/${encodeURIComponent(previewId)}`;
44+
}
45+
publicUrl = normalizeStudioPublicUrl(publicUrl);
46+
if (!localToken) {
47+
localToken = providerToken;
48+
}
4749

48-
await registerProvider();
50+
console.log(`[simdeck-provider-bridge] ${publicUrl}`);
4951

50-
while (!stopped) {
51-
try {
52-
if (Date.now() - lastRegisterAt > registerIntervalMs) {
53-
await registerProvider();
54-
}
55-
const next = await fetchJson(
56-
`${cloudUrl}/api/actions/providers/rpc/next`,
57-
{
58-
previewId,
59-
providerToken,
60-
},
61-
);
62-
if (!next || !next.request) {
63-
await sleep(250);
64-
continue;
52+
await registerProvider();
53+
54+
while (!stopped) {
55+
try {
56+
if (Date.now() - lastRegisterAt > registerIntervalMs) {
57+
await registerProvider();
58+
}
59+
const next = await fetchJson(
60+
`${cloudUrl}/api/actions/providers/rpc/next`,
61+
{
62+
previewId,
63+
providerToken,
64+
},
65+
);
66+
if (!next || !next.request) {
67+
await sleep(250);
68+
continue;
69+
}
70+
await handleRequest(next.request);
71+
} catch (error) {
72+
console.error(
73+
`[simdeck-provider-bridge] ${error instanceof Error ? error.message : String(error)}`,
74+
);
75+
await sleep(1000);
6576
}
66-
await handleRequest(next.request);
67-
} catch (error) {
68-
console.error(
69-
`[simdeck-provider-bridge] ${error instanceof Error ? error.message : String(error)}`,
70-
);
71-
await sleep(1000);
7277
}
73-
}
74-
} finally {
75-
if (registered) {
76-
await markProviderExpired();
78+
} finally {
79+
if (registered) {
80+
await markProviderExpired();
81+
}
7782
}
7883
}
7984

@@ -233,6 +238,36 @@ function sleep(ms) {
233238
return new Promise((resolve) => setTimeout(resolve, ms));
234239
}
235240

241+
function normalizeStudioPublicUrl(value) {
242+
return normalizeStudioPublicUrlWithCloud(value, cloudUrl);
243+
}
244+
245+
export function normalizeStudioPublicUrlWithCloud(value, baseCloudUrl) {
246+
const trimmed = String(value || "").trim();
247+
if (!trimmed) {
248+
return "";
249+
}
250+
251+
const normalizedCloudUrl = baseCloudUrl.replace(/\/$/, "");
252+
const cloudOrigin = new URL(normalizedCloudUrl).origin;
253+
const collapsed = trimmed
254+
.replace(repeatedPrefixPattern(normalizedCloudUrl), normalizedCloudUrl)
255+
.replace(repeatedPrefixPattern(cloudOrigin), cloudOrigin);
256+
return new URL(collapsed, `${normalizedCloudUrl}/`).toString();
257+
}
258+
259+
function repeatedPrefixPattern(prefix) {
260+
return new RegExp(`^(?:${escapeRegExp(prefix)}){2,}`);
261+
}
262+
263+
function escapeRegExp(value) {
264+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
265+
}
266+
267+
function isMainModule() {
268+
return import.meta.url === pathToFileURL(process.argv[1] || "").href;
269+
}
270+
236271
function stableLocalProviderId() {
237272
const fingerprint = [
238273
os.hostname(),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { normalizeStudioPublicUrlWithCloud } from "./studio-provider-bridge.mjs";
5+
6+
const cloudUrl = "https://simdeck.djdev.me";
7+
8+
test("normalizes relative studio expose paths against the cloud URL", () => {
9+
assert.equal(
10+
normalizeStudioPublicUrlWithCloud("/simulator/preview-123", cloudUrl),
11+
"https://simdeck.djdev.me/simulator/preview-123",
12+
);
13+
});
14+
15+
test("collapses duplicated full cloud URL prefixes", () => {
16+
assert.equal(
17+
normalizeStudioPublicUrlWithCloud(
18+
"https://simdeck.djdev.mehttps://simdeck.djdev.me/simulator/preview-123",
19+
cloudUrl,
20+
),
21+
"https://simdeck.djdev.me/simulator/preview-123",
22+
);
23+
});
24+
25+
test("collapses duplicated cloud origins when base URL has a path", () => {
26+
assert.equal(
27+
normalizeStudioPublicUrlWithCloud(
28+
"https://simdeck.djdev.mehttps://simdeck.djdev.me/simulator/preview-123",
29+
"https://simdeck.djdev.me/actions",
30+
),
31+
"https://simdeck.djdev.me/simulator/preview-123",
32+
);
33+
});
34+
35+
test("preserves valid external tunnel URLs", () => {
36+
assert.equal(
37+
normalizeStudioPublicUrlWithCloud(
38+
"https://preview.example.test/simulator/preview-123",
39+
cloudUrl,
40+
),
41+
"https://preview.example.test/simulator/preview-123",
42+
);
43+
});

0 commit comments

Comments
 (0)