Skip to content

Commit 670a168

Browse files
committed
feat(api): connect Skiller Web to docker-git runtime
1 parent 1dc2272 commit 670a168

5 files changed

Lines changed: 316 additions & 18 deletions

File tree

packages/api/src/http.ts

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,18 @@ import {
159159
startTerminalSession
160160
} from "./services/terminal-sessions.js"
161161
import {
162+
connectSkillerWeb,
162163
openSkiller,
163164
openSkillerForTerminalSession,
164165
parseSkillerRoute,
165166
proxySkillerTrpc,
166167
readSkillerProjectContext,
167168
serveSkillerApp
168169
} from "./services/skiller.js"
169-
import { resolveDockerGitSkillerBackendUrl } from "./services/skiller-core.js"
170+
import {
171+
isSkillerWebCorsOriginAllowed,
172+
resolveDockerGitSkillerBackendUrl
173+
} from "./services/skiller-core.js"
170174
import {
171175
commitStateFromRequest,
172176
initStateFromRequest,
@@ -230,6 +234,11 @@ const AuthTerminalSessionParamsSchema = Schema.Struct({
230234
sessionId: Schema.String
231235
})
232236

237+
const SkillerConnectRequestSchema = Schema.Struct({
238+
projectKey: Schema.String,
239+
sessionId: Schema.optional(Schema.String)
240+
})
241+
233242
type ApiError =
234243
| ApiAuthRequiredError
235244
| ApiBadRequestError
@@ -448,6 +457,7 @@ const readCodexAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(CodexAu
448457
const readGrokAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GrokAuthLogoutRequestSchema)
449458
const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLogoutRequestSchema)
450459
const readProjectAuthRequest = () => HttpServerRequest.schemaBodyJson(ProjectAuthRequestSchema)
460+
const readSkillerConnectRequest = () => HttpServerRequest.schemaBodyJson(SkillerConnectRequestSchema)
451461
const readProjectPromptUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectPromptUpdateRequestSchema)
452462
const readProjectSkillUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectSkillUpdateRequestSchema)
453463
const readActiveProjectTerminalSessionRequest = () =>
@@ -601,6 +611,66 @@ const resolveRequestOrigin = (request: HttpServerRequest.HttpServerRequest): str
601611
const resolveSkillerBackendUrl = (request: HttpServerRequest.HttpServerRequest): string =>
602612
resolveDockerGitSkillerBackendUrl(process.env, resolveRequestOrigin(request))
603613

614+
const skillerCorsHeaders = (
615+
request: HttpServerRequest.HttpServerRequest
616+
): Record<string, string> => {
617+
const origin = readHeader(request, "origin")
618+
if (origin === undefined || !isSkillerWebCorsOriginAllowed(origin, process.env)) {
619+
return {}
620+
}
621+
return {
622+
"access-control-allow-credentials": "true",
623+
"access-control-allow-headers": readHeader(request, "access-control-request-headers") ??
624+
"content-type,trpc-accept,x-trpc-source",
625+
"access-control-allow-methods": "GET,POST,OPTIONS",
626+
"access-control-allow-origin": origin,
627+
"access-control-max-age": "600",
628+
"access-control-expose-headers": "content-type",
629+
vary: "origin"
630+
}
631+
}
632+
633+
const withSkillerCors = (
634+
request: HttpServerRequest.HttpServerRequest,
635+
response: HttpServerResponse.HttpServerResponse
636+
): HttpServerResponse.HttpServerResponse => {
637+
const headers = skillerCorsHeaders(request)
638+
return Object.keys(headers).length === 0 ? response : HttpServerResponse.setHeaders(response, headers)
639+
}
640+
641+
const skillerJsonResponse = (
642+
request: HttpServerRequest.HttpServerRequest,
643+
data: unknown,
644+
status: number
645+
) =>
646+
jsonResponse(data, status).pipe(
647+
Effect.map((response) => withSkillerCors(request, response))
648+
)
649+
650+
const skillerErrorResponse = (
651+
request: HttpServerRequest.HttpServerRequest,
652+
error: unknown
653+
) =>
654+
errorResponse(error).pipe(
655+
Effect.map((response) => withSkillerCors(request, response))
656+
)
657+
658+
const isSkillerCorsPath = (pathname: string): boolean => {
659+
const normalized = pathname.startsWith("/api/") ? pathname.slice("/api".length) : pathname
660+
return normalized === "/skiller/connect" || parseSkillerRoute(pathname) !== null
661+
}
662+
663+
const skillerCorsPreflightResponse = (
664+
request: HttpServerRequest.HttpServerRequest
665+
) => {
666+
const origin = readHeader(request, "origin")
667+
const allowed = origin === undefined || isSkillerWebCorsOriginAllowed(origin, process.env)
668+
return Effect.succeed(HttpServerResponse.empty({
669+
headers: allowed ? skillerCorsHeaders(request) : noStoreHeaders,
670+
status: allowed ? 204 : 403
671+
}))
672+
}
673+
604674
const resolveFederationContext = (
605675
request: HttpServerRequest.HttpServerRequest,
606676
requestedDomain?: string | undefined
@@ -776,11 +846,25 @@ const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
776846
const projectProxyResponse = Effect.gen(function*(_) {
777847
const request = yield* _(HttpServerRequest.HttpServerRequest)
778848
const pathname = new URL(request.url, "http://localhost").pathname
849+
if (request.method === "OPTIONS" && isSkillerCorsPath(pathname)) {
850+
return yield* _(skillerCorsPreflightResponse(request))
851+
}
779852
const skillerRoute = parseSkillerRoute(pathname)
780853
if (skillerRoute !== null) {
781-
return skillerRoute._tag === "App"
782-
? yield* _(serveSkillerApp(skillerRoute))
783-
: yield* _(proxySkillerTrpc(request, skillerRoute))
854+
if (skillerRoute._tag === "App") {
855+
return yield* _(
856+
serveSkillerApp(skillerRoute).pipe(
857+
Effect.map((response) => withSkillerCors(request, response)),
858+
Effect.catchAll((error) => skillerErrorResponse(request, error))
859+
)
860+
)
861+
}
862+
return yield* _(
863+
proxySkillerTrpc(request, skillerRoute).pipe(
864+
Effect.map((response) => withSkillerCors(request, response)),
865+
Effect.catchAll((error) => skillerErrorResponse(request, error))
866+
)
867+
)
784868
}
785869
const browserTarget = parseProjectBrowserProxyPath(pathname)
786870
if (browserTarget !== null) {
@@ -805,6 +889,38 @@ const projectProxyResponse = Effect.gen(function*(_) {
805889
return yield* _(proxyProjectPortForward(request, target))
806890
})
807891

892+
const normalizedOptionalString = (value: string | undefined): string | undefined => {
893+
const trimmed = value?.trim()
894+
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed
895+
}
896+
897+
const skillerConnectInfoResponse = (
898+
request: HttpServerRequest.HttpServerRequest
899+
) =>
900+
listProjects().pipe(
901+
Effect.flatMap((projects) => skillerJsonResponse(request, { ok: true, projects }, 200)),
902+
Effect.catchAll((error) => skillerErrorResponse(request, error))
903+
)
904+
905+
const skillerConnectResponse = (
906+
request: HttpServerRequest.HttpServerRequest
907+
) =>
908+
Effect.gen(function*(_) {
909+
const body = yield* _(readSkillerConnectRequest())
910+
const projectKey = body.projectKey.trim()
911+
if (projectKey.length === 0) {
912+
return yield* _(Effect.fail(new ApiBadRequestError({ message: "projectKey is required." })))
913+
}
914+
const connection = yield* _(connectSkillerWeb(
915+
projectKey,
916+
normalizedOptionalString(body.sessionId),
917+
resolveSkillerBackendUrl(request)
918+
))
919+
return yield* _(skillerJsonResponse(request, { ok: true, ...connection }, 202))
920+
}).pipe(
921+
Effect.catchAll((error) => skillerErrorResponse(request, error))
922+
)
923+
808924
export const makeRouter = () => {
809925
const withCoreRoutes = HttpRouter.empty.pipe(
810926
HttpRouter.get(
@@ -815,6 +931,34 @@ export const makeRouter = () => {
815931
return yield* _(jsonResponse({ ok: true, revision: controllerRevision, cwd, projectsRoot }, 200))
816932
}).pipe(Effect.catchAll(errorResponse))
817933
),
934+
HttpRouter.get(
935+
"/skiller/connect",
936+
Effect.gen(function*(_) {
937+
const request = yield* _(HttpServerRequest.HttpServerRequest)
938+
return yield* _(skillerConnectInfoResponse(request))
939+
})
940+
),
941+
HttpRouter.get(
942+
"/api/skiller/connect",
943+
Effect.gen(function*(_) {
944+
const request = yield* _(HttpServerRequest.HttpServerRequest)
945+
return yield* _(skillerConnectInfoResponse(request))
946+
})
947+
),
948+
HttpRouter.post(
949+
"/skiller/connect",
950+
Effect.gen(function*(_) {
951+
const request = yield* _(HttpServerRequest.HttpServerRequest)
952+
return yield* _(skillerConnectResponse(request))
953+
})
954+
),
955+
HttpRouter.post(
956+
"/api/skiller/connect",
957+
Effect.gen(function*(_) {
958+
const request = yield* _(HttpServerRequest.HttpServerRequest)
959+
return yield* _(skillerConnectResponse(request))
960+
})
961+
),
818962
HttpRouter.post(
819963
"/skiller/open",
820964
Effect.gen(function*(_) {

packages/api/src/services/skiller-core.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ export type ExternalSkillerLaunchUrlInput = {
5050
const trimTrailingSlashes = (value: string): string =>
5151
value.replace(/\/+$/u, "")
5252

53+
const configuredOrigin = (raw: string): string | null => {
54+
const value = raw.trim()
55+
if (!URL.canParse(value)) {
56+
return null
57+
}
58+
const parsed = new URL(value)
59+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
60+
return null
61+
}
62+
return parsed.origin
63+
}
64+
65+
const uniqueOrigins = (origins: ReadonlyArray<string>): ReadonlyArray<string> =>
66+
[...new Set(origins)]
67+
5368
export const resolveConfiguredSkillerWebUrl = (
5469
env: Record<string, string | undefined>
5570
): ConfiguredSkillerWebUrl => {
@@ -82,6 +97,31 @@ export const resolveDockerGitSkillerBackendUrl = (
8297
return configured ?? requestOrigin
8398
}
8499

100+
export const configuredSkillerWebCorsOrigins = (
101+
env: Record<string, string | undefined>
102+
): ReadonlyArray<string> => {
103+
const configuredWeb = resolveConfiguredSkillerWebUrl(env)
104+
const fromWeb = configuredWeb._tag === "Enabled"
105+
? [configuredOrigin(configuredWeb.baseUrl)].filter((origin): origin is string => origin !== null)
106+
: []
107+
const fromAllowed = (env["DOCKER_GIT_SKILLER_ALLOWED_ORIGINS"] ?? "")
108+
.split(",")
109+
.map(configuredOrigin)
110+
.filter((origin): origin is string => origin !== null)
111+
return uniqueOrigins([
112+
...fromWeb,
113+
...fromAllowed,
114+
"http://localhost:5180",
115+
"http://127.0.0.1:5180"
116+
])
117+
}
118+
119+
export const isSkillerWebCorsOriginAllowed = (
120+
origin: string | undefined,
121+
env: Record<string, string | undefined>
122+
): boolean =>
123+
origin !== undefined && configuredSkillerWebCorsOrigins(env).includes(origin)
124+
85125
export const externalSkillerLaunchUrl = (input: ExternalSkillerLaunchUrlInput): string => {
86126
const url = new URL(`${trimTrailingSlashes(input.skillerWebUrl)}/launch`)
87127
url.searchParams.set("backendUrl", input.backendUrl)

0 commit comments

Comments
 (0)