From dc3642153da0bcaf596dd13a11445e73339dbd18 Mon Sep 17 00:00:00 2001 From: Wanjohi <71614375+wanjohiryan@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:23:59 +0300 Subject: [PATCH] feat: Add sdk openapi generators --- bun.lock | 29 +- packages/core/src/api/api.sql.ts | 10 +- packages/core/src/api/personal.ts | 2 +- packages/core/src/drizzle/index.ts | 10 +- packages/core/src/utils/lazy.ts | 11 + packages/function/package.json | 16 +- packages/function/src/api/index.ts | 217 +++++----- packages/sdk/package.json | 9 +- packages/sdk/{src => }/script/build.ts | 22 +- packages/sdk/src/api/index.ts | 0 packages/sdk/src/gen/client.gen.ts | 27 ++ packages/sdk/src/gen/client/client.gen.ts | 268 ++++++++++++ packages/sdk/src/gen/client/index.ts | 26 ++ packages/sdk/src/gen/client/types.gen.ts | 268 ++++++++++++ packages/sdk/src/gen/client/utils.gen.ts | 336 +++++++++++++++ packages/sdk/src/gen/core/auth.gen.ts | 42 ++ .../sdk/src/gen/core/bodySerializer.gen.ts | 92 ++++ packages/sdk/src/gen/core/params.gen.ts | 153 +++++++ .../sdk/src/gen/core/pathSerializer.gen.ts | 181 ++++++++ .../src/gen/core/queryKeySerializer.gen.ts | 136 ++++++ .../sdk/src/gen/core/serverSentEvents.gen.ts | 264 ++++++++++++ packages/sdk/src/gen/core/types.gen.ts | 118 ++++++ packages/sdk/src/gen/core/utils.gen.ts | 143 +++++++ packages/sdk/src/gen/index.d.ts | 1 + packages/sdk/src/gen/index.js | 3 + packages/sdk/src/gen/index.js.map | 1 + packages/sdk/src/gen/index.ts | 3 + packages/sdk/src/gen/sdk.gen.d.ts | 39 ++ packages/sdk/src/gen/sdk.gen.js | 42 ++ packages/sdk/src/gen/sdk.gen.js.map | 1 + packages/sdk/src/gen/sdk.gen.ts | 89 ++++ packages/sdk/src/gen/types.gen.ts | 394 ++++++++++++++++++ packages/sdk/src/index.ts | 7 +- packages/studio/{ => src}/index.ts | 0 34 files changed, 2827 insertions(+), 133 deletions(-) create mode 100644 packages/core/src/utils/lazy.ts rename packages/sdk/{src => }/script/build.ts (71%) mode change 100644 => 100755 delete mode 100644 packages/sdk/src/api/index.ts create mode 100644 packages/sdk/src/gen/client.gen.ts create mode 100644 packages/sdk/src/gen/client/client.gen.ts create mode 100644 packages/sdk/src/gen/client/index.ts create mode 100644 packages/sdk/src/gen/client/types.gen.ts create mode 100644 packages/sdk/src/gen/client/utils.gen.ts create mode 100644 packages/sdk/src/gen/core/auth.gen.ts create mode 100644 packages/sdk/src/gen/core/bodySerializer.gen.ts create mode 100644 packages/sdk/src/gen/core/params.gen.ts create mode 100644 packages/sdk/src/gen/core/pathSerializer.gen.ts create mode 100644 packages/sdk/src/gen/core/queryKeySerializer.gen.ts create mode 100644 packages/sdk/src/gen/core/serverSentEvents.gen.ts create mode 100644 packages/sdk/src/gen/core/types.gen.ts create mode 100644 packages/sdk/src/gen/core/utils.gen.ts create mode 100644 packages/sdk/src/gen/index.d.ts create mode 100644 packages/sdk/src/gen/index.js create mode 100644 packages/sdk/src/gen/index.js.map create mode 100644 packages/sdk/src/gen/index.ts create mode 100644 packages/sdk/src/gen/sdk.gen.d.ts create mode 100644 packages/sdk/src/gen/sdk.gen.js create mode 100644 packages/sdk/src/gen/sdk.gen.js.map create mode 100644 packages/sdk/src/gen/sdk.gen.ts create mode 100644 packages/sdk/src/gen/types.gen.ts rename packages/studio/{ => src}/index.ts (100%) diff --git a/bun.lock b/bun.lock index dec6542e..73e8594e 100644 --- a/bun.lock +++ b/bun.lock @@ -112,13 +112,15 @@ "name": "@nestri/function", "version": "0.0.1", "dependencies": { + "@hey-api/openapi-ts": "^0.87.0", "@openauthjs/openauth": "catalog:", "hono": "catalog:", - "hono-openapi": "^1.1.0", + "hono-openapi": "1.1.1", "zod": "catalog:", }, "devDependencies": { "@cloudflare/workers-types": "catalog:", + "@hono/standard-validator": "^0.1.5", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", }, @@ -179,6 +181,7 @@ }, "peerDependencies": { "@nestri/core": "workspace:^", + "@nestri/function": "workspace:^", "typescript": "^5", }, }, @@ -614,16 +617,18 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.7.13", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw=="], - "@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.1", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-iLG9uRJdmQf83sCZ8WsDR6RXQep0X+D1t1mxuzhrSS9zVL4NvnjTQD6PNnQNPymJyss/mdPf7f7kbmcCK7DVmw=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.1", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A=="], - "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.86.2", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.1", "@hey-api/json-schema-ref-parser": "1.2.1", "ansi-colors": "4.1.3", "c12": "3.3.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "open": "10.1.2", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-xVVORQpoa4eqKd/3WXcMd4aCgTIotSh9S9CpfqNTuI4lu2RUlb5F7OvlPBkksf4z02ji62bJ/1JkWtp7FW2xKg=="], + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.87.0", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.1", "ansi-colors": "4.1.3", "c12": "3.3.1", "color-support": "1.1.3", "commander": "14.0.1", "open": "10.2.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-BZmiwvkXsMRlv6PTFuEoIsvIuT3nv5t5VN91pJapdXwnOVolA4u8BiwmLPR0cXtVeNdpUndgnD7DWMPjpVJ0fw=="], "@hono/arktype-validator": ["@hono/arktype-validator@2.0.0", "", { "peerDependencies": { "arktype": "^2.0.0-dev.14", "hono": "*" } }, "sha512-ICNZrK6Qcw6gyPfW53ONXI4JomRcks0fQhqzn9EWsfr6nlL6BNXQ96vIgVDU8qimcbJ2m3GJFAqgzOvWbZk3jw=="], "@hono/effect-validator": ["@hono/effect-validator@1.2.0", "", { "peerDependencies": { "effect": ">=3.10.0", "hono": ">=4.4.13" } }, "sha512-PJTTVsF3bN/ld7w3g3rcWRzhbgn2wG4CcAtZpgGAz90DF3TLD5ByVkkRI3SK0HnbtPQKT2Ndng+bJvLwV1lZZQ=="], + "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], + "@hono/typebox-validator": ["@hono/typebox-validator@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": ">=0.31.15 <1", "hono": ">=3.9.0" } }, "sha512-x+Q7RWiw6rJmJs7MDgtvRbS7ViXXEzqimC0duY4or7y1f89f5Z3KCQSdUSVka+Uv0HZ/O0ZNt+ixNDxfJLi4hA=="], "@hono/valibot-validator": ["@hono/valibot-validator@0.5.2", "", { "peerDependencies": { "hono": ">=3.9.0", "valibot": "^1.0.0 || ^1.0.0-beta.4 || ^1.0.0-rc" } }, "sha512-WbTr8PCFNAME0ale62rmmq+E8bGp5a7VPiVNmNT+Ue12GJE3Ax+CWBGtHhR0TzAEiloPvJND3IbjHIgtNtsqhw=="], @@ -1514,7 +1519,7 @@ "command-line-usage": ["command-line-usage@7.0.3", "", { "dependencies": { "array-back": "^6.2.2", "chalk-template": "^0.4.0", "table-layout": "^4.1.0", "typical": "^7.1.1" } }, "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q=="], - "commander": ["commander@13.0.0", "", {}, "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ=="], + "commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], "commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="], @@ -3720,10 +3725,10 @@ "@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - "@hey-api/openapi-ts/open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], - "@hey-api/openapi-ts/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@hono/standard-validator/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@httptoolkit/websocket-stream/duplexify": ["duplexify@3.7.1", "", { "dependencies": { "end-of-stream": "^1.0.0", "inherits": "^2.0.1", "readable-stream": "^2.0.0", "stream-shift": "^1.0.0" } }, "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g=="], "@httptoolkit/websocket-stream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -3774,10 +3779,12 @@ "@nestri/core/zod-openapi": ["zod-openapi@5.4.3", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-6kJ/gJdvHZtuxjYHoMtkl2PixCwRuZ/s79dVkEr7arHvZGXfx7Cvh53X3HfJ5h9FzGelXOXlnyjwfX0sKEPByw=="], - "@nestri/function/hono-openapi": ["hono-openapi@1.1.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-eA5hN8D2O30EkPPUxWFilcZcThAe81TShbH38Y183ZZp8WkgMh4BrPEDeZ/EFN2tyDi3cmTgKTa3+oStyJX0UA=="], + "@nestri/function/hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="], "@nestri/function/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "@nestri/sdk/@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.86.2", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.1", "@hey-api/json-schema-ref-parser": "1.2.1", "ansi-colors": "4.1.3", "c12": "3.3.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "open": "10.1.2", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-xVVORQpoa4eqKd/3WXcMd4aCgTIotSh9S9CpfqNTuI4lu2RUlb5F7OvlPBkksf4z02ji62bJ/1JkWtp7FW2xKg=="], + "@nestri/studio/@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], "@nestri/www/@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="], @@ -4848,6 +4855,14 @@ "@nestri/core/drizzle-kit/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + "@nestri/sdk/@hey-api/openapi-ts/@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.1", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-iLG9uRJdmQf83sCZ8WsDR6RXQep0X+D1t1mxuzhrSS9zVL4NvnjTQD6PNnQNPymJyss/mdPf7f7kbmcCK7DVmw=="], + + "@nestri/sdk/@hey-api/openapi-ts/commander": ["commander@13.0.0", "", {}, "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ=="], + + "@nestri/sdk/@hey-api/openapi-ts/open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], + + "@nestri/sdk/@hey-api/openapi-ts/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@nestri/studio/@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], "@nestri/www/@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], diff --git a/packages/core/src/api/api.sql.ts b/packages/core/src/api/api.sql.ts index 36d71131..93595a26 100644 --- a/packages/core/src/api/api.sql.ts +++ b/packages/core/src/api/api.sql.ts @@ -1,4 +1,4 @@ -import { teamTable } from "../team/team.sql.js"; +import { userTable } from "../user/user.sql.js"; import { pgTable, varchar } from "drizzle-orm/pg-core"; import { id, timestamps, ulid } from "../drizzle/types.js"; @@ -8,8 +8,8 @@ export const apiAppTable = pgTable("api_app", { name: varchar("name", { length: 255 }).notNull(), secret: varchar("secret", { length: 255 }).notNull(), redirectURI: varchar("redirect", { length: 255 }).notNull(), - teamID: ulid("team_id") - .references(() => teamTable.id, { + ownerID: ulid("owner_id") + .references(() => userTable.id, { onDelete: "cascade", }) .notNull(), @@ -19,8 +19,8 @@ export const apiPersonalTokenTable = pgTable("api_personal_token", { ...id, ...timestamps, token: varchar("token", { length: 255 }).notNull(), - teamID: ulid("team_id") - .references(() => teamTable.id, { + ownerID: ulid("owner_id") + .references(() => userTable.id, { onDelete: "cascade", }) .notNull(), diff --git a/packages/core/src/api/personal.ts b/packages/core/src/api/personal.ts index cd986fa3..b979eefc 100644 --- a/packages/core/src/api/personal.ts +++ b/packages/core/src/api/personal.ts @@ -36,7 +36,7 @@ export namespace Personal { return tx .select({ id: apiPersonalTokenTable.id, - teamID: apiPersonalTokenTable.teamID, + ownerID: apiPersonalTokenTable.ownerID, }) .from(apiPersonalTokenTable) .where(eq(apiPersonalTokenTable.token, token)) diff --git a/packages/core/src/drizzle/index.ts b/packages/core/src/drizzle/index.ts index 4894e167..ee59367b 100644 --- a/packages/core/src/drizzle/index.ts +++ b/packages/core/src/drizzle/index.ts @@ -6,11 +6,13 @@ import { PostgresJsQueryResultHKT, drizzle } from "drizzle-orm/postgres-js"; export namespace Database { const { DATABASE_URL } = process.env; - if (!DATABASE_URL) { - throw new Error("DATABASE_URL is not set"); - } + // if (!DATABASE_URL) { + // throw new Error("DATABASE_URL is not set"); + // } + const dbUrl = + DATABASE_URL || "postgres://user:password@localhost:5432/nestri"; - const sql = postgres(DATABASE_URL, { + const sql = postgres(dbUrl, { max: 20, idle_timeout: 60, }); diff --git a/packages/core/src/utils/lazy.ts b/packages/core/src/utils/lazy.ts new file mode 100644 index 00000000..9ed0f27e --- /dev/null +++ b/packages/core/src/utils/lazy.ts @@ -0,0 +1,11 @@ +export function lazy(fn: () => T) { + let value: T | undefined; + let loaded = false; + + return (): T => { + if (loaded) return value as T; + loaded = true; + value = fn(); + return value as T; + }; +} diff --git a/packages/function/package.json b/packages/function/package.json index 36524481..ea84febc 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,21 +1,29 @@ { "name": "@nestri/function", "type": "module", - "version":"0.0.1", + "version": "0.0.1", "private": true, + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "build": "./script/build.ts" + }, "devDependencies": { "@cloudflare/workers-types": "catalog:", - "@types/bun": "catalog:", - "@tsconfig/node22": "catalog:" + "@hono/standard-validator": "^0.1.5", + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:" }, "peerDependencies": { "typescript": "^5", "@nestri/core": "workspace:^" }, "dependencies": { + "@hey-api/openapi-ts": "^0.87.0", "@openauthjs/openauth": "catalog:", "hono": "catalog:", - "hono-openapi": "^1.1.0", + "hono-openapi": "1.1.1", "zod": "catalog:" } } diff --git a/packages/function/src/api/index.ts b/packages/function/src/api/index.ts index 4c60549f..4420f938 100644 --- a/packages/function/src/api/index.ts +++ b/packages/function/src/api/index.ts @@ -3,110 +3,135 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { auth } from "./utils/auth"; import { Log } from "@nestri/core/utils/log"; +import { lazy } from "@nestri/core/utils/lazy"; import { SessionApi } from "./routes/session.js"; import { HTTPException } from "hono/http-exception"; import { VisibleError, ErrorCodes } from "@nestri/core/error"; -import { openAPIRouteHandler, validator } from "hono-openapi"; import packageJson from "../../package.json" assert { type: "json" }; +import { openAPIRouteHandler, validator, generateSpecs } from "hono-openapi"; -const log = Log.create({ service: "api" }); +export namespace Api { + const log = Log.create({ service: "api" }); -export const app = new Hono(); -app - .use(async (c, next) => { - c.header("Cache-Control", "no-store"); - return next(); - }) - .use(async (c, next) => { - log.info("request", { - method: c.req.method, - path: c.req.path, - }); - const start = Date.now(); - await next(); - log.info("response", { - duration: Date.now() - start, - }); - }) - .use( - validator("header", z.object({ "x-nestri-steam": z.string().optional() })), - ) - .use(validator("query", z.object({ steamID: z.string().optional() }))) - .use(auth) - .use(cors()); + export const app = new Hono(); + export const App = lazy(() => + app + .use(async (c, next) => { + c.header("Cache-Control", "no-store"); + return next(); + }) + .use(async (c, next) => { + log.info("request", { + method: c.req.method, + path: c.req.path, + }); + const start = Date.now(); + await next(); + log.info("response", { + duration: Date.now() - start, + }); + }) + .use( + validator( + "header", + z.object({ "x-nestri-steam": z.string().optional() }), + ), + ) + .use(validator("query", z.object({ steamID: z.string().optional() }))) + .use(auth) + .use(cors()) + .get("/", async (c) => { + return c.text("ok"); + }) + .route("/session", SessionApi.route) + .onError((error, c) => { + // Handle our custom VisibleError + if (error instanceof VisibleError) { + return c.json(error.toResponse(), error.statusCode()); + } -export const routes = app - .get("/", async (c) => { - return c.text("ok"); - }) - .route("/session", SessionApi.route) - .onError((error, c) => { - // Handle our custom VisibleError - if (error instanceof VisibleError) { - return c.json(error.toResponse(), error.statusCode()); - } + // Handle HTTP exceptions + if (error instanceof HTTPException) { + console.error("http error:", error); + return c.json( + { + type: "validation", + code: ErrorCodes.Validation.INVALID_PARAMETER, + message: "Invalid request", + }, + 400, + ); + } - // Handle HTTP exceptions - if (error instanceof HTTPException) { - console.error("http error:", error); - return c.json( - { - type: "validation", - code: ErrorCodes.Validation.INVALID_PARAMETER, - message: "Invalid request", - }, - 400, - ); - } - - // Handle any other errors as internal server errors - log.error("Unhandled API error:", { error }); - return c.json( - { - type: "internal", - code: ErrorCodes.Server.INTERNAL_ERROR, - message: "Internal server error", - }, - 500, - ); - }); - -app.get( - "/doc", - openAPIRouteHandler(routes, { - documentation: { - info: { - title: "Nestri API", - termsOfService: "https://nestri.io/terms", - description: - "The Nestri API gives you the power deploy and stream games/apps in the cloud. Run on our GPUs or bring your own.", - version: packageJson.version ?? "1.0.0", - }, - components: { - securitySchemes: { - Bearer: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", + // Handle any other errors as internal server errors + log.error("Unhandled API error:", { error }); + return c.json( + { + type: "internal", + code: ErrorCodes.Server.INTERNAL_ERROR, + message: "Internal server error", }, - SteamID: { - type: "apiKey", - description: "The steamID to use for this query", - in: "header", - name: "x-nestri-steam", + 500, + ); + }) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "Nestri API", + termsOfService: "https://nestri.io/terms", + description: + "The Nestri API gives you the power deploy and stream games/apps in the cloud. Run on our GPUs or bring your own.", + version: packageJson.version ?? "1.0.0", + }, + components: { + securitySchemes: { + Bearer: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }, + SteamID: { + type: "apiKey", + description: "The steamID to use for this query", + in: "header", + name: "x-nestri-steam", + }, + }, + }, + openapi: "3.1.1", + security: [{ Bearer: [], SteamID: [] }], + servers: [ + { description: "Production", url: "https://api.nestri.io" }, + { description: "Sandbox", url: "https://api.dev.nestri.io" }, + ], }, - }, - }, - security: [{ Bearer: [], SteamID: [] }], - servers: [ - { description: "Production", url: "https://api.nestri.io" }, - { description: "Sandbox", url: "https://api.dev.nestri.io" }, - ], - }, - }), -); + }), + ), + ); -export default { - port: 3001, - fetch: app.fetch, -}; + export const generate = async () => { + const result = await generateSpecs(App(), { + documentation: { + info: { + title: "nestri", + version: packageJson.version ?? "1.0.0", + description: "Nestri api", + }, + openapi: "3.1.1", + }, + }); + return result; + }; + + export function serve(opts: { port: number; hostname: string }) { + const server = Bun.serve({ + port: opts.port, + hostname: opts.hostname, + idleTimeout: 0, + fetch: App().fetch, + }); + return server; + } +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 75306eff..b892618b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -3,6 +3,9 @@ "module": "src/index.ts", "type": "module", "private": true, + "scripts": { + "build": "./script/build.ts" + }, "devDependencies": { "@hey-api/openapi-ts": "^0.86.2", "@types/bun": "catalog:", @@ -13,6 +16,10 @@ }, "peerDependencies": { "typescript": "^5", - "@nestri/core": "workspace:^" + "@nestri/core": "workspace:^", + "@nestri/function": "workspace:^" + }, + "publishConfig": { + "directory": "dist" } } diff --git a/packages/sdk/src/script/build.ts b/packages/sdk/script/build.ts old mode 100644 new mode 100755 similarity index 71% rename from packages/sdk/src/script/build.ts rename to packages/sdk/script/build.ts index ea4b66aa..a0fa99ae --- a/packages/sdk/src/script/build.ts +++ b/packages/sdk/script/build.ts @@ -1,20 +1,18 @@ -#!/usr/bin/env bun -//TODO: Fix this to import JSON - +#! /usr/bin/env bun const dir = new URL("..", import.meta.url).pathname; process.chdir(dir); -import { $ } from "bun"; import path from "path"; - +import { $ } from "bun"; +import { Api } from "@nestri/function/api/index"; import { createClient } from "@hey-api/openapi-ts"; -await $`bun dev generate > ${dir}/openapi.json`.cwd( - path.resolve(dir, "../../opencode"), -); +await $`rm -rf src/gen`; + +const schema = await Api.generate(); await createClient({ - input: "./openapi.json", + input: schema, output: { path: "./src/gen", tsConfigPath: path.join(dir, "tsconfig.json"), @@ -26,9 +24,9 @@ await createClient({ }, { name: "@hey-api/sdk", - instance: "OpencodeClient", - exportFromIndex: false, - auth: false, + instance: "NestriApi", + // exportFromIndex: false, + auth: true, }, { name: "@hey-api/client-fetch", diff --git a/packages/sdk/src/api/index.ts b/packages/sdk/src/api/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/sdk/src/gen/client.gen.ts b/packages/sdk/src/gen/client.gen.ts new file mode 100644 index 00000000..91143217 --- /dev/null +++ b/packages/sdk/src/gen/client.gen.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { + type ClientOptions, + type Config, + createClient, + createConfig, +} from "./client/index.js"; +import type { ClientOptions as ClientOptions2 } from "./types.gen.js"; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export const client = createClient( + createConfig({ + baseUrl: "http://localhost:4096", + }), +); diff --git a/packages/sdk/src/gen/client/client.gen.ts b/packages/sdk/src/gen/client/client.gen.ts new file mode 100644 index 00000000..2f2023d9 --- /dev/null +++ b/packages/sdk/src/gen/client/client.gen.ts @@ -0,0 +1,268 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from "../core/serverSentEvents.gen.js"; +import type { HttpMethod } from "../core/types.gen.js"; +import { getValidRequestBody } from "../core/utils.gen.js"; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from "./types.gen.js"; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from "./utils.gen.js"; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === "") { + opts.headers.delete("Content-Type"); + } + + const url = buildUrl(opts); + + return { opts, url }; + }; + + const request: Client["request"] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: "follow", + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === "auto" + ? getParseAs(response.headers.get("Content-Type")) + : opts.parseAs) ?? "json"; + + if ( + response.status === 204 || + response.headers.get("Content-Length") === "0" + ) { + let emptyData: any; + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "text": + emptyData = await response[parseAs](); + break; + case "formData": + emptyData = new FormData(); + break; + case "stream": + emptyData = response.body; + break; + case "json": + default: + emptyData = {}; + break; + } + return opts.responseStyle === "data" + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "formData": + case "json": + case "text": + data = await response[parseAs](); + break; + case "stream": + return opts.responseStyle === "data" + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === "json") { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === "data" + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = + (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn("CONNECT"), + delete: makeMethodFn("DELETE"), + get: makeMethodFn("GET"), + getConfig, + head: makeMethodFn("HEAD"), + interceptors, + options: makeMethodFn("OPTIONS"), + patch: makeMethodFn("PATCH"), + post: makeMethodFn("POST"), + put: makeMethodFn("PUT"), + request, + setConfig, + sse: { + connect: makeSseFn("CONNECT"), + delete: makeSseFn("DELETE"), + get: makeSseFn("GET"), + head: makeSseFn("HEAD"), + options: makeSseFn("OPTIONS"), + patch: makeSseFn("PATCH"), + post: makeSseFn("POST"), + put: makeSseFn("PUT"), + trace: makeSseFn("TRACE"), + }, + trace: makeMethodFn("TRACE"), + } as Client; +}; diff --git a/packages/sdk/src/gen/client/index.ts b/packages/sdk/src/gen/client/index.ts new file mode 100644 index 00000000..75d26de3 --- /dev/null +++ b/packages/sdk/src/gen/client/index.ts @@ -0,0 +1,26 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from "../core/auth.gen.js"; +export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "../core/bodySerializer.gen.js"; +export { buildClientParams } from "../core/params.gen.js"; +export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen.js"; +export { createClient } from "./client.gen.js"; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from "./types.gen.js"; +export { createConfig, mergeHeaders } from "./utils.gen.js"; diff --git a/packages/sdk/src/gen/client/types.gen.ts b/packages/sdk/src/gen/client/types.gen.ts new file mode 100644 index 00000000..484ee98c --- /dev/null +++ b/packages/sdk/src/gen/client/types.gen.ts @@ -0,0 +1,268 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from "../core/auth.gen.js"; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from "../core/serverSentEvents.gen.js"; +import type { + Client as CoreClient, + Config as CoreConfig, +} from "../core/types.gen.js"; +import type { Middleware } from "./utils.gen.js"; + +export type ResponseStyle = "data" | "fields"; + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T["baseUrl"]; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | "arrayBuffer" + | "auto" + | "blob" + | "formData" + | "json" + | "stream" + | "text"; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T["throwOnError"]; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | "onSseError" + | "onSseEvent" + | "sseDefaultRetryDelay" + | "sseMaxRetryAttempts" + | "sseMaxRetryDelay" + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends "data" + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends "data" + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method"> & + Pick< + Required>, + "method" + >, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: Pick & Options, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = "fields", +> = OmitKeys< + RequestOptions, + "body" | "path" | "query" | "url" +> & + Omit; + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + "body" | "headers" | "url" + > & + TData + : OmitKeys< + RequestOptions, + "body" | "url" + > & + TData & + Pick, "headers"> + : TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + "headers" | "url" + > & + TData & + Pick, "body"> + : OmitKeys, "url"> & + TData; diff --git a/packages/sdk/src/gen/client/utils.gen.ts b/packages/sdk/src/gen/client/utils.gen.ts new file mode 100644 index 00000000..6d71c2a3 --- /dev/null +++ b/packages/sdk/src/gen/client/utils.gen.ts @@ -0,0 +1,336 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from "../core/auth.gen.js"; +import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"; +import { jsonBodySerializer } from "../core/bodySerializer.gen.js"; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "../core/pathSerializer.gen.js"; +import { getUrl } from "../core/utils.gen.js"; +import type { + Client, + ClientOptions, + Config, + RequestOptions, +} from "./types.gen.js"; + +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved, + explode: true, + name, + style: "form", + value, + ...array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === "object") { + const serializedObject = serializeObjectParam({ + allowReserved, + explode: true, + name, + style: "deepObject", + value: value as Record, + ...object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join("&"); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return "stream"; + } + + const cleanContent = contentType.split(";")[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith("application/json") || + cleanContent.endsWith("+json") + ) { + return "json"; + } + + if (cleanContent === "multipart/form-data") { + return "formData"; + } + + if ( + ["application/", "audio/", "image/", "video/"].some((type) => + cleanContent.startsWith(type), + ) + ) { + return "blob"; + } + + if (cleanContent.startsWith("text/")) { + return "text"; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get("Cookie")?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, "security"> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? "Authorization"; + + switch (auth.in) { + case "query": + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case "cookie": + options.headers.append("Cookie", `${name}=${token}`); + break; + case "header": + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client["buildUrl"] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === "function" + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith("/")) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array["headers"] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === "object" ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === "number") { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: "form", + }, + object: { + explode: true, + style: "deepObject", + }, +}); + +const defaultHeaders = { + "Content-Type": "application/json", +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: "auto", + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/packages/sdk/src/gen/core/auth.gen.ts b/packages/sdk/src/gen/core/auth.gen.ts new file mode 100644 index 00000000..3d741151 --- /dev/null +++ b/packages/sdk/src/gen/core/auth.gen.ts @@ -0,0 +1,42 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie"; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: "basic" | "bearer"; + type: "apiKey" | "http"; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = + typeof callback === "function" ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}`; + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/packages/sdk/src/gen/core/bodySerializer.gen.ts b/packages/sdk/src/gen/core/bodySerializer.gen.ts new file mode 100644 index 00000000..a769e7dd --- /dev/null +++ b/packages/sdk/src/gen/core/bodySerializer.gen.ts @@ -0,0 +1,92 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from "./pathSerializer.gen.js"; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === "string") { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/packages/sdk/src/gen/core/params.gen.ts b/packages/sdk/src/gen/core/params.gen.ts new file mode 100644 index 00000000..6527557e --- /dev/null +++ b/packages/sdk/src/gen/core/params.gen.ts @@ -0,0 +1,153 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = "body" | "headers" | "path" | "query"; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + { + in: Slot; + map?: string; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === "object" && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + (params[field.in] as Record)[name] = arg; + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else { + for (const [slot, allowed] of Object.entries( + config.allowExtra ?? {}, + )) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/packages/sdk/src/gen/core/pathSerializer.gen.ts b/packages/sdk/src/gen/core/pathSerializer.gen.ts new file mode 100644 index 00000000..034aa684 --- /dev/null +++ b/packages/sdk/src/gen/core/pathSerializer.gen.ts @@ -0,0 +1,181 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = "label" | "matrix" | "simple"; +export type ObjectStyle = "form" | "deepObject"; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return ","; + case "pipeDelimited": + return "|"; + case "spaceDelimited": + return "%20"; + default: + return ","; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + case "simple": + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ""; + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== "deepObject" && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(","); + switch (style) { + case "form": + return `${name}=${joinedValues}`; + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; diff --git a/packages/sdk/src/gen/core/queryKeySerializer.gen.ts b/packages/sdk/src/gen/core/queryKeySerializer.gen.ts new file mode 100644 index 00000000..0713164c --- /dev/null +++ b/packages/sdk/src/gen/core/queryKeySerializer.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if ( + value === undefined || + typeof value === "function" || + typeof value === "symbol" + ) { + return undefined; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { + if (value === null) { + return null; + } + + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if ( + value === undefined || + typeof value === "function" || + typeof value === "symbol" + ) { + return undefined; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if ( + typeof URLSearchParams !== "undefined" && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/packages/sdk/src/gen/core/serverSentEvents.gen.ts b/packages/sdk/src/gen/core/serverSentEvents.gen.ts new file mode 100644 index 00000000..801e7911 --- /dev/null +++ b/packages/sdk/src/gen/core/serverSentEvents.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from "./types.gen.js"; + +export type ServerSentEventsOptions = Omit< + RequestInit, + "method" +> & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit["body"]; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set("Last-Event-ID", lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: "follow", + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error("No body in SSE response"); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ""; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener("abort", abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() ?? ""; + + for (const chunk of chunks) { + const lines = chunk.split("\n"); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.replace(/^data:\s*/, "")); + } else if (line.startsWith("event:")) { + eventName = line.replace(/^event:\s*/, ""); + } else if (line.startsWith("id:")) { + lastEventId = line.replace(/^id:\s*/, ""); + } else if (line.startsWith("retry:")) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ""), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join("\n"); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener("abort", abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/packages/sdk/src/gen/core/types.gen.ts b/packages/sdk/src/gen/core/types.gen.ts new file mode 100644 index 00000000..0956605a --- /dev/null +++ b/packages/sdk/src/gen/core/types.gen.ts @@ -0,0 +1,118 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from "./auth.gen.js"; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from "./bodySerializer.gen.js"; + +export type HttpMethod = + | "connect" + | "delete" + | "get" + | "head" + | "options" + | "patch" + | "post" + | "put" + | "trace"; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/packages/sdk/src/gen/core/utils.gen.ts b/packages/sdk/src/gen/core/utils.gen.ts new file mode 100644 index 00000000..ff02d8da --- /dev/null +++ b/packages/sdk/src/gen/core/utils.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js"; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "./pathSerializer.gen.js"; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = "simple"; + + if (name.endsWith("*")) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith(".")) { + name = name.substring(1); + style = "label"; + } else if (name.startsWith(";")) { + name = name.substring(1); + style = "matrix"; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === "label" ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}`; + let url = (baseUrl ?? "") + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ""; + if (search.startsWith("?")) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ("serializedBody" in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ""; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== "" ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/packages/sdk/src/gen/index.d.ts b/packages/sdk/src/gen/index.d.ts new file mode 100644 index 00000000..546ee188 --- /dev/null +++ b/packages/sdk/src/gen/index.d.ts @@ -0,0 +1 @@ +export * from "./sdk.gen.js"; diff --git a/packages/sdk/src/gen/index.js b/packages/sdk/src/gen/index.js new file mode 100644 index 00000000..03442c37 --- /dev/null +++ b/packages/sdk/src/gen/index.js @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from "./sdk.gen.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/sdk/src/gen/index.js.map b/packages/sdk/src/gen/index.js.map new file mode 100644 index 00000000..838f43d3 --- /dev/null +++ b/packages/sdk/src/gen/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,qDAAqD;AAErD,cAAc,cAAc,CAAC"} \ No newline at end of file diff --git a/packages/sdk/src/gen/index.ts b/packages/sdk/src/gen/index.ts new file mode 100644 index 00000000..37c1c340 --- /dev/null +++ b/packages/sdk/src/gen/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export * from "./sdk.gen.js"; diff --git a/packages/sdk/src/gen/sdk.gen.d.ts b/packages/sdk/src/gen/sdk.gen.d.ts new file mode 100644 index 00000000..d44e408c --- /dev/null +++ b/packages/sdk/src/gen/sdk.gen.d.ts @@ -0,0 +1,39 @@ +import type { Client, Options as Options2, TDataShape } from "./client/index.js"; +import type { SessionCreateData, SessionCreateErrors, SessionCreateResponses, SessionListData, SessionListErrors, SessionListResponses } from "./types.gen.js"; +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; +declare class _HeyApiClient { + protected _client: Client; + constructor(args?: { + client?: Client; + }); +} +export declare class Session extends _HeyApiClient { + /** + * List all sessions + * + * List all the current user's sessions + */ + list(options?: Options): import("./client/types.gen.js").RequestResult; + /** + * Create a new session + * + * Create a new session for the current user + */ + create(options?: Options): import("./client/types.gen.js").RequestResult; +} +export declare class NestriApi extends _HeyApiClient { + session: Session; +} +export {}; diff --git a/packages/sdk/src/gen/sdk.gen.js b/packages/sdk/src/gen/sdk.gen.js new file mode 100644 index 00000000..b564b118 --- /dev/null +++ b/packages/sdk/src/gen/sdk.gen.js @@ -0,0 +1,42 @@ +// This file is auto-generated by @hey-api/openapi-ts +import { client } from "./client.gen.js"; +class _HeyApiClient { + _client = client; + constructor(args) { + if (args?.client) { + this._client = args.client; + } + } +} +export class Session extends _HeyApiClient { + /** + * List all sessions + * + * List all the current user's sessions + */ + list(options) { + return (options?.client ?? this._client).get({ + url: "/session", + ...options, + }); + } + /** + * Create a new session + * + * Create a new session for the current user + */ + create(options) { + return (options?.client ?? this._client).post({ + url: "/session", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } +} +export class NestriApi extends _HeyApiClient { + session = new Session({ client: this._client }); +} +//# sourceMappingURL=sdk.gen.js.map \ No newline at end of file diff --git a/packages/sdk/src/gen/sdk.gen.js.map b/packages/sdk/src/gen/sdk.gen.js.map new file mode 100644 index 00000000..fe4838c2 --- /dev/null +++ b/packages/sdk/src/gen/sdk.gen.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sdk.gen.js","sourceRoot":"","sources":["sdk.gen.ts"],"names":[],"mappings":"AAAA,qDAAqD;AAErD,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAgCzC,MAAM,aAAa;IACP,OAAO,GAAW,MAAM,CAAC;IAEnC,YAAY,IAA0B;QACpC,IAAI,IAAI,EAAE,MAAM,EAAE,CAAC;YACjB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC;QAC7B,CAAC;IACH,CAAC;CACF;AAED,MAAM,OAAO,OAAQ,SAAQ,aAAa;IACxC;;;;OAIG;IACI,IAAI,CACT,OAAgD;QAEhD,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,CAI1C;YACA,GAAG,EAAE,UAAU;YACf,GAAG,OAAO;SACX,CAAC,CAAC;IACL,CAAC;IAED;;;;OAIG;IACI,MAAM,CACX,OAAkD;QAElD,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,CAI3C;YACA,GAAG,EAAE,UAAU;YACf,GAAG,OAAO;YACV,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,GAAG,OAAO,EAAE,OAAO;aACpB;SACF,CAAC,CAAC;IACL,CAAC;CACF;AAED,MAAM,OAAO,SAAU,SAAQ,aAAa;IAC1C,OAAO,GAAG,IAAI,OAAO,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;CACjD"} \ No newline at end of file diff --git a/packages/sdk/src/gen/sdk.gen.ts b/packages/sdk/src/gen/sdk.gen.ts new file mode 100644 index 00000000..0efd0562 --- /dev/null +++ b/packages/sdk/src/gen/sdk.gen.ts @@ -0,0 +1,89 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { client } from "./client.gen.js"; +import type { + Client, + Options as Options2, + TDataShape, +} from "./client/index.js"; +import type { + SessionCreateData, + SessionCreateErrors, + SessionCreateResponses, + SessionListData, + SessionListErrors, + SessionListResponses, +} from "./types.gen.js"; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +class _HeyApiClient { + protected _client: Client = client; + + constructor(args?: { client?: Client }) { + if (args?.client) { + this._client = args.client; + } + } +} + +export class Session extends _HeyApiClient { + /** + * List all sessions + * + * List all the current user's sessions + */ + public list( + options?: Options, + ) { + return (options?.client ?? this._client).get< + SessionListResponses, + SessionListErrors, + ThrowOnError + >({ + url: "/session", + ...options, + }); + } + + /** + * Create a new session + * + * Create a new session for the current user + */ + public create( + options?: Options, + ) { + return (options?.client ?? this._client).post< + SessionCreateResponses, + SessionCreateErrors, + ThrowOnError + >({ + url: "/session", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } +} + +export class NestriApi extends _HeyApiClient { + session = new Session({ client: this._client }); +} diff --git a/packages/sdk/src/gen/types.gen.ts b/packages/sdk/src/gen/types.gen.ts new file mode 100644 index 00000000..1adaa489 --- /dev/null +++ b/packages/sdk/src/gen/types.gen.ts @@ -0,0 +1,394 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; + +/** + * Represents a session configuration with streaming and execution options + */ +export type Session = { + /** + * The unique identifier of the session + */ + id: string; + /** + * Description of what the session does + */ + description?: string; + /** + * Retry configuration for failed session attempts + */ + retry?: { + maxAttempts?: number; + factor?: number; + minTimeoutInMs?: number; + maxTimeoutInMs?: number; + randomize?: boolean; + outOfMemory?: { + machine?: + | "ns3-micro" + | "ns3-mini" + | "ns3-small" + | "ns3-medium" + | "ns3-large"; + }; + }; + /** + * Queue configuration for concurrent session execution + */ + queue?: { + name?: string; + concurrencyLimit?: number; + }; + /** + * Machine preset for the session + */ + machine?: "ns3-micro" | "ns3-mini" | "ns3-small" | "ns3-medium" | "ns3-large"; + /** + * Maximum duration in seconds the session can run + */ + maxDuration?: number; + /** + * Nestri relay URL for streaming and signaling + */ + relayUrl?: string; + /** + * Display or stream resolution in 'WxH' format + */ + resolution?: "1280x720" | "1080x1920" | "1440x2560" | "2160x3840"; + /** + * Display or stream framerate + */ + framerate?: number; + /** + * Preferred GPU vendor + */ + gpuVendor?: string; + /** + * Preferred GPU name + */ + gpuName?: string; + /** + * Specific GPU index to use + */ + gpuIndex?: number; + /** + * Force specific GPU card by /dev/dri path + */ + gpuCardPath?: string; + /** + * Preferred video codec for streaming + */ + videoCodec?: "h264" | "h265" | "vp8" | "vp9" | "av1"; + /** + * Override video encoder implementation + */ + videoEncoder?: string; + /** + * Video rate control method + */ + videoRateControl?: "cbr" | "vbr" | "cq" | "lossless"; + /** + * Constant Quantization Parameter for quality control + */ + videoCqp?: number; + /** + * Target video bitrate in kbps + */ + videoBitrate?: number; + /** + * Maximum video bitrate in kbps + */ + videoBitrateMax?: number; + /** + * Encoder type: hardware or software + */ + videoEncoderType?: "hardware" | "software"; + /** + * Video bit depth (8 or 10) + */ + videoBitDepth?: 8 | 10; + /** + * Audio capture method + */ + audioCaptureMethod?: "pipewire" | "pulse" | "alsa"; + /** + * Preferred audio codec for streaming + */ + audioCodec?: "opus" | "aac" | "mp3" | "pcm"; + /** + * Override audio encoder binary + */ + audioEncoder?: string; + /** + * Audio rate control method + */ + audioRateControl?: "cbr" | "vbr"; + /** + * Target audio bitrate in kbps + */ + audioBitrate?: number; + /** + * Maximum audio bitrate in kbps + */ + audioBitrateMax?: number; + /** + * Whether to use DMA-BUF for zero-copy video pipelines + */ + zeroCopy?: boolean; + /** + * Enable verbose output for debugging + */ + verbose?: boolean; +}; + +/** + * Validation error + */ +export type ErrorResponse = { + /** + * The error type category + */ + type: + | "validation" + | "authentication" + | "forbidden" + | "not_found" + | "already_exists" + | "rate_limit" + | "internal"; + /** + * Machine-readable error code identifier + */ + code: string; + /** + * Human-readable error message + */ + message: string; + /** + * The parameter that caused the error (if applicable) + */ + param?: string; + /** + * Additional error context information + */ + details?: unknown; +}; + +export type SessionListData = { + body?: never; + headers?: { + "x-nestri-steam"?: string; + }; + path?: never; + query?: { + steamID?: string; + }; + url: "/session"; +}; + +export type SessionListErrors = { + /** + * Unauthorized - Authentication is required and has failed or has not been provided. + */ + 401: ErrorResponse; + /** + * Forbidden - You do not have permission to access this resource. + */ + 403: ErrorResponse; + /** + * Too Many Requests - You have made too many requests in a short period of time. + */ + 429: ErrorResponse; +}; + +export type SessionListError = SessionListErrors[keyof SessionListErrors]; + +export type SessionListResponses = { + /** + * List of sessions + */ + 200: Array; +}; + +export type SessionListResponse = + SessionListResponses[keyof SessionListResponses]; + +export type SessionCreateData = { + body?: { + /** + * Description of what the session does + */ + description?: string; + /** + * Retry configuration for failed session attempts + */ + retry?: { + maxAttempts?: number; + factor?: number; + minTimeoutInMs?: number; + maxTimeoutInMs?: number; + randomize?: boolean; + outOfMemory?: { + machine?: + | "ns3-micro" + | "ns3-mini" + | "ns3-small" + | "ns3-medium" + | "ns3-large"; + }; + }; + /** + * Queue configuration for concurrent session execution + */ + queue?: { + name?: string; + concurrencyLimit?: number; + }; + /** + * Machine preset for the session + */ + machine?: + | "ns3-micro" + | "ns3-mini" + | "ns3-small" + | "ns3-medium" + | "ns3-large"; + /** + * Maximum duration in seconds the session can run + */ + maxDuration?: number; + /** + * Nestri relay URL for streaming and signaling + */ + relayUrl?: string; + /** + * Display or stream resolution in 'WxH' format + */ + resolution?: "1280x720" | "1080x1920" | "1440x2560" | "2160x3840"; + /** + * Display or stream framerate + */ + framerate?: number; + /** + * Preferred GPU vendor + */ + gpuVendor?: string; + /** + * Preferred GPU name + */ + gpuName?: string; + /** + * Specific GPU index to use + */ + gpuIndex?: number; + /** + * Force specific GPU card by /dev/dri path + */ + gpuCardPath?: string; + /** + * Preferred video codec for streaming + */ + videoCodec?: "h264" | "h265" | "vp8" | "vp9" | "av1"; + /** + * Override video encoder implementation + */ + videoEncoder?: string; + /** + * Video rate control method + */ + videoRateControl?: "cbr" | "vbr" | "cq" | "lossless"; + /** + * Constant Quantization Parameter for quality control + */ + videoCqp?: number; + /** + * Target video bitrate in kbps + */ + videoBitrate?: number; + /** + * Maximum video bitrate in kbps + */ + videoBitrateMax?: number; + /** + * Encoder type: hardware or software + */ + videoEncoderType?: "hardware" | "software"; + /** + * Video bit depth (8 or 10) + */ + videoBitDepth?: 8 | 10; + /** + * Audio capture method + */ + audioCaptureMethod?: "pipewire" | "pulse" | "alsa"; + /** + * Preferred audio codec for streaming + */ + audioCodec?: "opus" | "aac" | "mp3" | "pcm"; + /** + * Override audio encoder binary + */ + audioEncoder?: string; + /** + * Audio rate control method + */ + audioRateControl?: "cbr" | "vbr"; + /** + * Target audio bitrate in kbps + */ + audioBitrate?: number; + /** + * Maximum audio bitrate in kbps + */ + audioBitrateMax?: number; + /** + * Whether to use DMA-BUF for zero-copy video pipelines + */ + zeroCopy?: boolean; + /** + * Enable verbose output for debugging + */ + verbose?: boolean; + }; + headers?: { + "x-nestri-steam"?: string; + }; + path?: never; + query?: { + steamID?: string; + }; + url: "/session"; +}; + +export type SessionCreateErrors = { + /** + * Bad Request - The request could not be understood or was missing required parameters. + */ + 400: ErrorResponse; + /** + * Unauthorized - Authentication is required and has failed or has not been provided. + */ + 401: ErrorResponse; + /** + * Forbidden - You do not have permission to access this resource. + */ + 403: ErrorResponse; + /** + * Too Many Requests - You have made too many requests in a short period of time. + */ + 429: ErrorResponse; +}; + +export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors]; + +export type SessionCreateResponses = { + /** + * The created session + */ + 201: Session; +}; + +export type SessionCreateResponse = + SessionCreateResponses[keyof SessionCreateResponses]; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 15f3c4cd..4ce5226c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,4 +1,3 @@ -import { Identifier } from "@nestri/core/id"; import { Session } from "@nestri/core/session/index"; // Simple session creation function using our clean Session types @@ -12,7 +11,6 @@ export function createSession(config: Session.Config): Session.Session { ); // TODO: Implement actual session start logic return { - id: `run_${Date.now()}` as `run_${string}`, sessionIdentifier: config.description, publicAccessToken: "mock_token", }; @@ -28,7 +26,6 @@ export function createSession(config: Session.Config): Session.Session { setTimeout(() => { resolve({ ok: true, - id: `run_${Date.now()}` as any, sessionIdentifier: config.description, }); }, 1000); @@ -39,6 +36,10 @@ export function createSession(config: Session.Config): Session.Session { // Store the run function for later execution (session as any)._runFunction = config.run; + // Mark this object as a Nestri session + // @ts-ignore + session[Symbol.for("nestri/session")] = true; + return session; } diff --git a/packages/studio/index.ts b/packages/studio/src/index.ts similarity index 100% rename from packages/studio/index.ts rename to packages/studio/src/index.ts