mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-06 08:21:50 +00:00
refactor: use Effect config for HttpApi authorization (#25035)
This commit is contained in:
parent
38adc13295
commit
cee9610d26
6 changed files with 178 additions and 92 deletions
67
packages/opencode/src/effect/config-service.ts
Normal file
67
packages/opencode/src/effect/config-service.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Config, Context, Effect, Layer } from "effect"
|
||||
|
||||
type ConfigMap = Record<string, Config.Config<unknown>>
|
||||
|
||||
/**
|
||||
* The service shape inferred from an object of Effect `Config` definitions.
|
||||
*/
|
||||
export type Shape<Fields extends ConfigMap> = {
|
||||
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
|
||||
}
|
||||
|
||||
/**
|
||||
* A Context service class with generated layers for config-backed services.
|
||||
*/
|
||||
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
|
||||
/** Provide already-parsed config, useful in tests. */
|
||||
readonly layer: (input: Service) => Layer.Layer<Self>
|
||||
/** Parse config once from the active Effect ConfigProvider and provide the service. */
|
||||
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Context service whose implementation is derived from Effect `Config`.
|
||||
*
|
||||
* This keeps Effect `Config` as the source of truth for env names, defaults, and
|
||||
* validation while generating a typed service plus convenient production/test
|
||||
* layers.
|
||||
*
|
||||
* ```ts
|
||||
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
|
||||
* "@opencode/ServerAuthConfig",
|
||||
* {
|
||||
* password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
|
||||
* username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
|
||||
* },
|
||||
* ) {}
|
||||
*
|
||||
* const live = ServerAuthConfig.defaultLayer
|
||||
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
|
||||
* ```
|
||||
*/
|
||||
export const Service =
|
||||
<Self>() =>
|
||||
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
|
||||
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
|
||||
static layer(input: Shape<Fields>) {
|
||||
return Layer.succeed(this, this.of(input))
|
||||
}
|
||||
|
||||
static get defaultLayer() {
|
||||
return Layer.effect(
|
||||
this,
|
||||
Config.all(fields)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
|
||||
Effect.map((config) => this.of(config as Shape<Fields>)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
|
||||
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
|
||||
}
|
||||
|
||||
export * as ConfigService from "./config-service"
|
||||
|
|
@ -1,17 +1,11 @@
|
|||
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
|
||||
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
||||
"Unauthorized",
|
||||
{ message: Schema.String },
|
||||
{ httpApiStatus: 401 },
|
||||
) {}
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
|
||||
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
|
||||
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
"@opencode/ExperimentalHttpApiAuthorization",
|
||||
{
|
||||
error: Unauthorized,
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
security: {
|
||||
basic: HttpApiSecurity.basic,
|
||||
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
|
||||
|
|
@ -19,29 +13,38 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
|||
},
|
||||
) {}
|
||||
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
|
||||
"@opencode/ExperimentalHttpApiServerAuthConfig",
|
||||
{
|
||||
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
|
||||
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
|
||||
},
|
||||
) {}
|
||||
|
||||
function validateCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
|
||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
|
||||
if (Option.isNone(config.password) || config.password.value === "") return yield* effect
|
||||
|
||||
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
|
||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||
if (credential.username !== config.username) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
}
|
||||
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||
if (Redacted.value(credential.password) !== config.password.value) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
}
|
||||
return yield* effect
|
||||
})
|
||||
}
|
||||
|
||||
function decodeCredential(input: string) {
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
|
||||
return Encoding.decodeBase64String(input)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
|
|
@ -59,13 +62,16 @@ function decodeCredential(input: string) {
|
|||
)
|
||||
}
|
||||
|
||||
export const authorizationLayer = Layer.succeed(
|
||||
export const authorizationLayer = Layer.effect(
|
||||
Authorization,
|
||||
Authorization.of({
|
||||
basic: (effect, { credential }) => validateCredential(effect, credential),
|
||||
authToken: (effect, { credential }) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
|
||||
}),
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuthConfig
|
||||
return Authorization.of({
|
||||
basic: (effect, { credential }) => validateCredential(effect, credential, config),
|
||||
authToken: (effect, { credential }) =>
|
||||
decodeCredential(Redacted.value(credential)).pipe(
|
||||
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { lazy } from "@/util/lazy"
|
|||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { authorizationLayer } from "./middleware/authorization"
|
||||
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
|
||||
import { eventRoute } from "./event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
|
|
@ -56,7 +56,7 @@ import { disposeMiddleware } from "./lifecycle"
|
|||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import * as ServerBackend from "@/server/backend"
|
||||
|
||||
export const context = Context.empty() as Context.Context<unknown>
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
const runtime = HttpRouter.middleware()(
|
||||
Effect.succeed((effect) =>
|
||||
|
|
@ -97,7 +97,7 @@ const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
|
|||
)
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
Layer.provide([
|
||||
authorizationLayer,
|
||||
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import {
|
||||
Authorization,
|
||||
ServerAuthConfig,
|
||||
authorizationLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const Api = HttpApi.make("test-authorization").add(
|
||||
|
|
@ -24,48 +27,19 @@ const apiLayer = HttpRouter.serve(
|
|||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
Flag.OPENCODE_SERVER_PASSWORD = undefined
|
||||
Flag.OPENCODE_SERVER_USERNAME = undefined
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
|
||||
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
|
||||
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
|
||||
|
||||
const it = testEffect(apiLayer.pipe(Layer.provideMerge(testStateLayer)))
|
||||
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
||||
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
||||
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
|
||||
|
||||
const basic = (username: string, password: string) =>
|
||||
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
|
||||
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
|
||||
|
||||
const useAuth = (input: { password: string; username?: string }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input.username
|
||||
return original
|
||||
}),
|
||||
(original) =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
}),
|
||||
)
|
||||
|
||||
const getProbe = (headers?: Record<string, string>) =>
|
||||
HttpClientRequest.get("/probe").pipe(
|
||||
headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
|
||||
|
|
@ -82,10 +56,8 @@ describe("HttpApi authorization middleware", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.live("requires configured password for basic auth", () =>
|
||||
itSecret.live("requires configured password for basic auth", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* useAuth({ password: "secret" })
|
||||
|
||||
const [missing, badPassword, good] = yield* Effect.all(
|
||||
[
|
||||
getProbe(),
|
||||
|
|
@ -101,10 +73,8 @@ describe("HttpApi authorization middleware", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.live("respects configured basic auth username", () =>
|
||||
itKitSecret.live("respects configured basic auth username", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* useAuth({ username: "kit", password: "secret" })
|
||||
|
||||
const [defaultUser, configuredUser] = yield* Effect.all(
|
||||
[getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
|
||||
{ concurrency: "unbounded" },
|
||||
|
|
@ -115,20 +85,16 @@ describe("HttpApi authorization middleware", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.live("accepts auth token query credentials", () =>
|
||||
itSecret.live("accepts auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* useAuth({ password: "secret" })
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects malformed auth token query credentials", () =>
|
||||
itSecret.live("rejects malformed auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* useAuth({ password: "secret" })
|
||||
|
||||
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigProvider, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
|
@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) {
|
|||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
return Server.Default().app
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
|
||||
|
|
@ -94,9 +116,9 @@ type RequestBody = {
|
|||
required?: boolean
|
||||
}
|
||||
|
||||
function parameterKey(param: unknown) {
|
||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
|
||||
if (typeof param.in !== "string" || typeof param.name !== "string") return
|
||||
function parameterKey(param: unknown): string | undefined {
|
||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
|
||||
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
|
||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
|
||||
}
|
||||
|
||||
|
|
@ -105,27 +127,29 @@ function parameterSchema(input: {
|
|||
path: string
|
||||
method: (typeof methods)[number]
|
||||
name: string
|
||||
}) {
|
||||
}): unknown {
|
||||
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
|
||||
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
|
||||
)
|
||||
if (!param || typeof param !== "object" || !("schema" in param)) return
|
||||
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
|
||||
return param.schema
|
||||
}
|
||||
|
||||
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
|
||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
|
||||
const requestBody = body as RequestBody
|
||||
return JSON.stringify({
|
||||
required: requestBody.required === true,
|
||||
content: Object.entries(requestBody.content ?? {})
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
|
||||
.sort(),
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
})
|
||||
}
|
||||
|
||||
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
|
||||
if (!schema) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
|
||||
const resolved = (
|
||||
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
|
||||
) as OpenApiSchema | undefined
|
||||
|
|
@ -142,6 +166,7 @@ function responseContentTypes(input: {
|
|||
}) {
|
||||
const responses = input.spec.paths[input.path]?.[input.method]?.responses
|
||||
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
|
||||
const response = (responses as Record<string, unknown>)[input.status]
|
||||
if (!response || typeof response !== "object" || !("content" in response)) return []
|
||||
const content = (response as { content?: unknown }).content
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
|
@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string })
|
|||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
|
||||
if (backend === "legacy") return Server.Legacy().app
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function client(
|
||||
|
|
@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
|
|||
}
|
||||
|
||||
function record(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
|
||||
}
|
||||
|
||||
function array(value: unknown) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue