mirror of
https://github.com/nestriness/nestri.git
synced 2026-08-01 17:34:15 +03:00
feat: Add a lot of stuff
This commit is contained in:
7
alchemy.run.ts
Normal file
7
alchemy.run.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import alchemy from "alchemy";
|
||||
|
||||
const app = await alchemy("nestri");
|
||||
|
||||
// await import("./infra/api");
|
||||
|
||||
await app.finalize();
|
||||
@@ -1,51 +1,51 @@
|
||||
{
|
||||
"name": "@nestri/core",
|
||||
"version": "0.0.0",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"db:dev": "drizzle-kit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db": "sst shell drizzle-kit",
|
||||
"db:exec": "sst shell ../scripts/src/psql.sh",
|
||||
"db:reset": "sst shell ../scripts/src/db-reset.sh"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.21.2",
|
||||
"ulid": "^2.3.0",
|
||||
"zod": "^3.24.1",
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-iot-data-plane": "^3.758.0",
|
||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||
"@neondatabase/serverless": "^1.0.1",
|
||||
"@openauthjs/openauth": "*",
|
||||
"@openauthjs/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*",
|
||||
"ws": "^8.18.3",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
"name": "@nestri/cloud-core",
|
||||
"version": "0.0.0",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"db:dev": "drizzle-kit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db": "sst shell drizzle-kit",
|
||||
"db:exec": "sst shell ../scripts/src/psql.sh",
|
||||
"db:reset": "sst shell ../scripts/src/db-reset.sh"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.21.2",
|
||||
"ulid": "^2.3.0",
|
||||
"zod": "^3.24.1",
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-iot-data-plane": "^3.758.0",
|
||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||
"@neondatabase/serverless": "^1.0.1",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@openauthjs/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*",
|
||||
"ws": "^8.18.3",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
97
cloud/packages/core/src/team/index.ts
Normal file
97
cloud/packages/core/src/team/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { z } from "zod/v4";
|
||||
import { fn } from "../utils/fn";
|
||||
import { Examples } from "../examples.js";
|
||||
import { createID } from "../utils/id.js";
|
||||
import { Polar } from "../polar/index.js";
|
||||
import { Database } from "../drizzle/index.js";
|
||||
import { teamTable } from "../team/team.sql.js";
|
||||
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||
import { VisibleError, ErrorCodes } from "../error.js";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: "The unique identifier of the team",
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
email: z.email().meta({
|
||||
description: "The contact email address for the team",
|
||||
example: Examples.Team.email,
|
||||
}),
|
||||
polarCustomerID: z.string().nullable().meta({
|
||||
description: "Associated Polar.sh customer identifier",
|
||||
example: Examples.Team.polarCustomerID,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "Team",
|
||||
description: "Represents a team entity",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export class TeamExistsError extends VisibleError {
|
||||
constructor(email: string) {
|
||||
super(
|
||||
"already_exists",
|
||||
ErrorCodes.Validation.ALREADY_EXISTS,
|
||||
`A team with this email ${email} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info.omit({ polarCustomerID: true }).partial({ id: true }),
|
||||
async (input) => {
|
||||
const userID = createID("team");
|
||||
const customerID = await Polar.fromUserEmail(input.email).then(
|
||||
(polarUser) => polarUser?.id || null,
|
||||
);
|
||||
|
||||
const id = input.id ?? userID;
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
const result = await tx
|
||||
.insert(teamTable)
|
||||
.values({
|
||||
id,
|
||||
email: input.email,
|
||||
polarCustomerID: customerID,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [teamTable.email],
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
throw new TeamExistsError(input.email);
|
||||
}
|
||||
});
|
||||
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
export const fromEmail = fn(Info.shape.email, async (email) =>
|
||||
Database.transaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(and(eq(teamTable.email, email), isNull(teamTable.timeDeleted)))
|
||||
.orderBy(asc(teamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0)),
|
||||
),
|
||||
);
|
||||
|
||||
export function serialize(
|
||||
input: typeof teamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
polarCustomerID: input.polarCustomerID,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
cloud/packages/core/src/team/team.sql.ts
Normal file
9
cloud/packages/core/src/team/team.sql.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ulid, timestamps } from "@/drizzle/types.js";
|
||||
import { pgTable, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const teamTable = pgTable("team", {
|
||||
...timestamps,
|
||||
id: ulid("id").primaryKey().notNull(),
|
||||
email: varchar("email", { length: 255 }).unique().notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }),
|
||||
});
|
||||
@@ -1,27 +1,30 @@
|
||||
import { ZodSchema, z } from "zod";
|
||||
import { ZodType, z } from "zod/v4";
|
||||
|
||||
export function fn<
|
||||
Arg1 extends ZodSchema,
|
||||
Callback extends (arg1: z.output<Arg1>) => any,
|
||||
Arg1 extends ZodType,
|
||||
Callback extends (arg1: z.output<Arg1>) => any,
|
||||
>(arg1: Arg1, cb: Callback) {
|
||||
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
return cb.apply(cb, [parsed as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
return cb.apply(cb, [parsed as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function doubleFn<
|
||||
Arg1 extends ZodSchema,
|
||||
Arg2 extends ZodSchema,
|
||||
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
|
||||
Arg1 extends ZodType,
|
||||
Arg2 extends ZodType,
|
||||
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
|
||||
>(arg1: Arg1, arg2: Arg2, cb: Callback) {
|
||||
const result = function (input: z.input<typeof arg1>, input2: z.input<typeof arg2>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
const parsed2 = arg2.parse(input2);
|
||||
return cb.apply(cb, [parsed as any, parsed2 as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
const result = function (
|
||||
input: z.input<typeof arg1>,
|
||||
input2: z.input<typeof arg2>,
|
||||
): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
const parsed2 = arg2.parse(input2);
|
||||
return cb.apply(cb, [parsed as any, parsed2 as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@nestri/functions",
|
||||
"name": "@nestri/cloud-functions",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -22,6 +22,7 @@
|
||||
"@aws-sdk/client-s3": "^3.806.0",
|
||||
"@aws-sdk/client-sqs": "^3.806.0",
|
||||
"@nestri/core": "workspace:",
|
||||
"@nestri/cloud-core": "workspace:",
|
||||
"actor-core": "^0.8.0",
|
||||
"hono": "^4.7.8",
|
||||
"hono-openapi": "^0.4.8",
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Resource } from "sst";
|
||||
import { logger } from "hono/logger";
|
||||
import { subjects } from "../subjects";
|
||||
import { handleDiscord } from "./utils";
|
||||
import { Team } from "@nestri/cloud-core/team/index";
|
||||
import { DiscordAdapter } from "./adapters";
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { User } from "@nestri/cloud-core/user/index";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import type { KVNamespace } from "@cloudflare/workers-types";
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare";
|
||||
@@ -63,6 +64,7 @@ export default {
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
await Team.fromEmail(email);
|
||||
const matching = await User.fromEmail(user.primary.email);
|
||||
|
||||
//Sign Up
|
||||
|
||||
22
package.json
22
package.json
@@ -1,21 +1,20 @@
|
||||
{
|
||||
"name": "nestri",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20240821.1",
|
||||
"@pulumi/pulumi": "^3.134.0",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/aws-lambda": "8.10.147",
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "bun@1.2.4",
|
||||
"packageManager": "bun@1.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"core-js-pure",
|
||||
@@ -31,10 +30,15 @@
|
||||
],
|
||||
"catalog": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"steam-session": "1.9.3"
|
||||
"@cloudflare/workers-types": "4.20240821.1",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/node": "22.13.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"steam-session": "1.9.3",
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"sst": "^3.17.13"
|
||||
"alchemy": "^0.75.1"
|
||||
}
|
||||
}
|
||||
|
||||
34
packages/.gitignore
vendored
Normal file
34
packages/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/README.md
Normal file
15
packages/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# packages
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.23. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
29
packages/bun.lock
Normal file
29
packages/bun.lock
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "packages",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
34
packages/core/.gitignore
vendored
Normal file
34
packages/core/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/core/README.md
Normal file
15
packages/core/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# core
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.23. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
29
packages/core/package.json
Normal file
29
packages/core/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@nestri/core",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"sideEffects": false,
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "^0.31.5"
|
||||
},
|
||||
"files": [
|
||||
"./tsconfig.base.json"
|
||||
],
|
||||
"exports": {
|
||||
"./*": "./src/*.ts",
|
||||
"./tsconfig.base": "./tsconfig.base.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"ioredis": "^5.8.2",
|
||||
"postgres": "^3.4.7",
|
||||
"ulid": "^3.0.1",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "^5.4.3"
|
||||
}
|
||||
}
|
||||
89
packages/core/src/actor.ts
Normal file
89
packages/core/src/actor.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Log } from "./utils/log.js";
|
||||
import { Context } from "./context.js";
|
||||
import { ErrorCodes, VisibleError } from "./error.js";
|
||||
|
||||
export namespace Actor {
|
||||
export interface Member {
|
||||
type: "member";
|
||||
properties: {
|
||||
memberID: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
type: "team";
|
||||
properties: {
|
||||
teamID: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface System {
|
||||
type: "system";
|
||||
properties: {
|
||||
teamID: string;
|
||||
memberID: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
type: "token";
|
||||
properties: {
|
||||
teamID: string;
|
||||
tokenID: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Public {
|
||||
type: "public";
|
||||
properties: {};
|
||||
}
|
||||
|
||||
export type Info = Team | Member | Public | Token | System;
|
||||
|
||||
export const ctx = Context.create<Info>();
|
||||
|
||||
export function teamID() {
|
||||
const actor = ctx.use();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function use() {
|
||||
try {
|
||||
return ctx.use();
|
||||
} catch {
|
||||
return { type: "public", properties: {} } as Public;
|
||||
}
|
||||
}
|
||||
|
||||
export function assert<T extends Info["type"]>(type: T) {
|
||||
const actor = use();
|
||||
if (actor.type !== type)
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Actor is not "${type}"`,
|
||||
);
|
||||
return actor as Extract<Info, { type: T }>;
|
||||
}
|
||||
|
||||
export function provide<
|
||||
T extends Info["type"],
|
||||
Next extends (...args: any) => any,
|
||||
>(type: T, properties: Extract<Info, { type: T }>["properties"], fn: Next) {
|
||||
return ctx.provide({ type, properties } as any, () =>
|
||||
Log.provide(
|
||||
{
|
||||
actor: type,
|
||||
...properties,
|
||||
},
|
||||
fn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
packages/core/src/api/api.sql.ts
Normal file
27
packages/core/src/api/api.sql.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { teamTable } from "../team/team.sql.js";
|
||||
import { pgTable, varchar } from "drizzle-orm/pg-core";
|
||||
import { id, timestamps, ulid } from "../drizzle/types.js";
|
||||
|
||||
export const apiAppTable = pgTable("api_app", {
|
||||
...id,
|
||||
...timestamps,
|
||||
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, {
|
||||
onDelete: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const apiPersonalTokenTable = pgTable("api_personal_token", {
|
||||
...id,
|
||||
...timestamps,
|
||||
token: varchar("token", { length: 255 }).notNull(),
|
||||
teamID: ulid("team_id")
|
||||
.references(() => teamTable.id, {
|
||||
onDelete: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
});
|
||||
31
packages/core/src/api/app.ts
Normal file
31
packages/core/src/api/app.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { Examples } from "../examples.js";
|
||||
|
||||
export namespace App {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: "The unique identifier of the App",
|
||||
example: Examples.App.id,
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: "The name of the App",
|
||||
example: Examples.App.name,
|
||||
}),
|
||||
redirectURI: z.url().meta({
|
||||
description: "Redirect endpoint URL of the App",
|
||||
example: Examples.App.redirectURI,
|
||||
}),
|
||||
secret: z.string().meta({
|
||||
description: "Auth2.0 client secret key of the App (obfuscated)",
|
||||
example: Examples.App.secret,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "App",
|
||||
description: "Represents an App for OAuth2.0 authentication",
|
||||
example: Examples.App,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
}
|
||||
7
packages/core/src/api/index.ts
Normal file
7
packages/core/src/api/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { App as ApiApp } from "./app.js";
|
||||
import { Personal as ApiPersonal } from "./personal.js";
|
||||
|
||||
export namespace Api {
|
||||
export const App = ApiApp;
|
||||
export const Personal = ApiPersonal;
|
||||
}
|
||||
45
packages/core/src/api/personal.ts
Normal file
45
packages/core/src/api/personal.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod/v4";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { fn } from "../utils/fn.js";
|
||||
import { Examples } from "../examples.js";
|
||||
import { Database } from "../drizzle/index.js";
|
||||
import { apiPersonalTokenTable } from "./api.sql.js";
|
||||
|
||||
export namespace Personal {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: "The unique identifier of the personal access token",
|
||||
example: Examples.Token.id,
|
||||
}),
|
||||
created: z.string().meta({
|
||||
description: "The creation timestamp of the personal access token",
|
||||
example: Examples.Token.created,
|
||||
}),
|
||||
token: z.string().meta({
|
||||
description:
|
||||
"A personal access token used to access the Nestri API. This is used to do things on behalf of the user.",
|
||||
example: Examples.Token.token,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "PersonalToken",
|
||||
description: "Represents a personal access token for API authentication",
|
||||
example: Examples.Token,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const fromToken = fn(Info.shape.token, async (token: string) =>
|
||||
Database.transaction(async (tx) => {
|
||||
return tx
|
||||
.select({
|
||||
id: apiPersonalTokenTable.id,
|
||||
teamID: apiPersonalTokenTable.teamID,
|
||||
})
|
||||
.from(apiPersonalTokenTable)
|
||||
.where(eq(apiPersonalTokenTable.token, token))
|
||||
.then((rows) => rows.at(0));
|
||||
}),
|
||||
);
|
||||
}
|
||||
21
packages/core/src/context.ts
Normal file
21
packages/core/src/context.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export namespace Context {
|
||||
export class NotFound extends Error {}
|
||||
|
||||
export function create<T>() {
|
||||
const storage = new AsyncLocalStorage<T>();
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore();
|
||||
if (!result) {
|
||||
throw new Error("No context available");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run<R>(value, fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
90
packages/core/src/drizzle/index.ts
Normal file
90
packages/core/src/drizzle/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import postgres from "postgres";
|
||||
import { Context } from "../context.js";
|
||||
import { ExtractTablesWithRelations } from "drizzle-orm";
|
||||
import { PgTransaction, PgTransactionConfig } from "drizzle-orm/pg-core";
|
||||
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");
|
||||
}
|
||||
|
||||
const sql = postgres(DATABASE_URL, {
|
||||
max: 20,
|
||||
idle_timeout: 60,
|
||||
});
|
||||
|
||||
const client = drizzle(sql);
|
||||
|
||||
export type Transaction = PgTransaction<
|
||||
PostgresJsQueryResultHKT,
|
||||
Record<string, never>,
|
||||
ExtractTablesWithRelations<Record<string, never>>
|
||||
>;
|
||||
|
||||
export type TxOrDb = Transaction | typeof client;
|
||||
|
||||
const TransactionContext = Context.create<{
|
||||
tx: TxOrDb;
|
||||
effects: (() => void | Promise<void>)[];
|
||||
}>();
|
||||
|
||||
export async function use<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return tx.transaction(callback);
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await TransactionContext.provide(
|
||||
{
|
||||
effects,
|
||||
tx: client,
|
||||
},
|
||||
() => callback(client),
|
||||
);
|
||||
await Promise.all(effects.map((x) => x()));
|
||||
return result;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fn<Input, T>(
|
||||
callback: (input: Input, trx: TxOrDb) => Promise<T>,
|
||||
) {
|
||||
return (input: Input) => use(async (tx) => callback(input, tx));
|
||||
}
|
||||
|
||||
export async function effect(effect: () => any | Promise<any>) {
|
||||
try {
|
||||
const { effects } = TransactionContext.use();
|
||||
effects.push(effect);
|
||||
} catch {
|
||||
await effect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function transaction<T>(
|
||||
callback: (tx: TxOrDb) => Promise<T>,
|
||||
config?: PgTransactionConfig,
|
||||
) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return callback(tx);
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await client.transaction(async (tx) => {
|
||||
return TransactionContext.provide({ tx, effects }, () =>
|
||||
callback(tx),
|
||||
);
|
||||
}, config);
|
||||
await Promise.all(effects.map((x) => x()));
|
||||
return result;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/core/src/drizzle/types.ts
Normal file
39
packages/core/src/drizzle/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 5 });
|
||||
|
||||
export const id = {
|
||||
get id() {
|
||||
return ulid("id").primaryKey().notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const teamID = {
|
||||
get id() {
|
||||
return ulid("id").notNull();
|
||||
},
|
||||
get teamID() {
|
||||
return ulid("team_id").notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const memberID = {
|
||||
get id() {
|
||||
return ulid("id").notNull();
|
||||
},
|
||||
get userID() {
|
||||
return ulid("member_id").notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const utc = (name: string) =>
|
||||
rawTs(name, {
|
||||
withTimezone: true,
|
||||
// mode: "date"
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated").notNull().defaultNow(),
|
||||
timeDeleted: utc("time_deleted"),
|
||||
};
|
||||
145
packages/core/src/error.ts
Normal file
145
packages/core/src/error.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Standard error response schema used for OpenAPI documentation
|
||||
*/
|
||||
export const ErrorResponse = z
|
||||
.object({
|
||||
type: z
|
||||
.enum([
|
||||
"validation",
|
||||
"authentication",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"already_exists",
|
||||
"rate_limit",
|
||||
"internal",
|
||||
])
|
||||
.meta({
|
||||
description: "The error type category",
|
||||
examples: ["validation", "authentication"],
|
||||
}),
|
||||
code: z.string().meta({
|
||||
description: "Machine-readable error code identifier",
|
||||
examples: ["invalid_parameter", "missing_required_field", "unauthorized"],
|
||||
}),
|
||||
message: z.string().meta({
|
||||
description: "Human-readable error message",
|
||||
examples: ["The request was invalid", "Authentication required"],
|
||||
}),
|
||||
param: z
|
||||
.string()
|
||||
.optional()
|
||||
.meta({
|
||||
description: "The parameter that caused the error (if applicable)",
|
||||
examples: ["email", "user_id", "team_id"],
|
||||
}),
|
||||
details: z.any().optional().meta({
|
||||
description: "Additional error context information",
|
||||
}),
|
||||
})
|
||||
.meta({ ref: "ErrorResponse" });
|
||||
|
||||
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
|
||||
|
||||
/**
|
||||
* Standardized error codes for the API
|
||||
*/
|
||||
export const ErrorCodes = {
|
||||
// Validation errors (400)
|
||||
Validation: {
|
||||
MISopenapiSING_REQUIRED_FIELD: "missing_required_field",
|
||||
ALREADY_EXISTS: "resource_already_exists",
|
||||
TEAM_ALREADY_EXISTS: "team_already_exists",
|
||||
INVALID_PARAMETER: "invalid_parameter",
|
||||
INVALID_FORMAT: "invalid_format",
|
||||
INVALID_STATE: "invalid_state",
|
||||
IN_USE: "resource_in_use",
|
||||
},
|
||||
|
||||
// Authentication errors (401)
|
||||
Authentication: {
|
||||
UNAUTHORIZED: "unauthorized",
|
||||
INVALID_TOKEN: "invalid_token",
|
||||
EXPIRED_TOKEN: "expired_token",
|
||||
INVALID_CREDENTIALS: "invalid_credentials",
|
||||
},
|
||||
|
||||
// Permission errors (403)
|
||||
Permission: {
|
||||
FORBIDDEN: "forbidden",
|
||||
INSUFFICIENT_PERMISSIONS: "insufficient_permissions",
|
||||
ACCOUNT_RESTRICTED: "account_restricted",
|
||||
},
|
||||
|
||||
// Resource not found errors (404)
|
||||
NotFound: {
|
||||
RESOURCE_NOT_FOUND: "resource_not_found",
|
||||
},
|
||||
|
||||
// Rate limit errors (429)
|
||||
RateLimit: {
|
||||
TOO_MANY_REQUESTS: "too_many_requests",
|
||||
QUOTA_EXCEEDED: "quota_exceeded",
|
||||
},
|
||||
|
||||
// Server errors (500)
|
||||
Server: {
|
||||
INTERNAL_ERROR: "internal_error",
|
||||
SERVICE_UNAVAILABLE: "service_unavailable",
|
||||
DEPENDENCY_FAILURE: "dependency_failure",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard error that will be exposed to clients through API responses
|
||||
*/
|
||||
export class VisibleError extends Error {
|
||||
constructor(
|
||||
public type: ErrorResponseType["type"],
|
||||
public code: string,
|
||||
public message: string,
|
||||
public param?: string,
|
||||
public details?: any,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this error to an HTTP status code
|
||||
*/
|
||||
public statusCode(): number {
|
||||
switch (this.type) {
|
||||
case "validation":
|
||||
return 400;
|
||||
case "authentication":
|
||||
return 401;
|
||||
case "forbidden":
|
||||
return 403;
|
||||
case "not_found":
|
||||
return 404;
|
||||
case "already_exists":
|
||||
return 409;
|
||||
case "rate_limit":
|
||||
return 429;
|
||||
case "internal":
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this error to a standard response object
|
||||
*/
|
||||
public toResponse(): ErrorResponseType {
|
||||
const response: ErrorResponseType = {
|
||||
type: this.type,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
};
|
||||
|
||||
if (this.param) response.param = this.param;
|
||||
if (this.details) response.details = this.details;
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
138
packages/core/src/event/event.ts
Normal file
138
packages/core/src/event/event.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Prettify } from "../utils/types.js";
|
||||
|
||||
export namespace event {
|
||||
export type Definition = {
|
||||
type: string;
|
||||
$input: any;
|
||||
$output: any;
|
||||
$metadata: any;
|
||||
$payload: any;
|
||||
create: (...args: any[]) => Promise<any>;
|
||||
};
|
||||
|
||||
export function builder<
|
||||
Metadata extends
|
||||
| ((type: string, properties: any) => any)
|
||||
| Parameters<Validator>[0],
|
||||
Validator extends (schema: any) => (input: any) => any,
|
||||
>(input: { validator: Validator; metadata?: Metadata }) {
|
||||
const validator = input.validator;
|
||||
const fn = function event<
|
||||
Type extends string,
|
||||
Schema extends Parameters<Validator>[0],
|
||||
>(type: Type, schema: Schema) {
|
||||
type MetadataOutput = Metadata extends (
|
||||
type: string,
|
||||
properties: any,
|
||||
) => any
|
||||
? ReturnType<Metadata>
|
||||
: // @ts-expect-error
|
||||
inferParser<Metadata>["out"];
|
||||
type Payload = Prettify<{
|
||||
type: Type;
|
||||
properties: Parsed["out"];
|
||||
metadata: MetadataOutput;
|
||||
}>;
|
||||
type Parsed = inferParser<Schema>;
|
||||
type Create = Metadata extends (type: string, properties: any) => any
|
||||
? (properties: Parsed["in"]) => Promise<Payload>
|
||||
: (
|
||||
properties: Parsed["in"],
|
||||
// @ts-expect-error
|
||||
metadata: inferParser<Metadata>["in"],
|
||||
) => Promise<Payload>;
|
||||
const validate = validator(schema);
|
||||
async function create(properties: any, metadata?: any) {
|
||||
metadata = input.metadata
|
||||
? typeof input.metadata === "function"
|
||||
? input.metadata(type, properties)
|
||||
: input.metadata(metadata)
|
||||
: {};
|
||||
properties = validate(properties);
|
||||
return {
|
||||
type,
|
||||
properties,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
return {
|
||||
create: create as Create,
|
||||
type,
|
||||
$input: {} as Parsed["in"],
|
||||
$output: {} as Parsed["out"],
|
||||
$payload: {} as Payload,
|
||||
$metadata: {} as MetadataOutput,
|
||||
} satisfies Definition;
|
||||
};
|
||||
fn.coerce = <Events extends Definition>(
|
||||
_events: Events | Events[],
|
||||
raw: any,
|
||||
): {
|
||||
[K in Events["type"]]: Extract<Events, { type: K }>["$payload"];
|
||||
}[Events["type"]] => {
|
||||
return raw;
|
||||
};
|
||||
return fn;
|
||||
}
|
||||
|
||||
// Taken from tRPC
|
||||
type ParserZodEsque<TInput, TParsedInput> = {
|
||||
_input: TInput;
|
||||
_output: TParsedInput;
|
||||
};
|
||||
|
||||
type ParserValibotEsque<TInput, TParsedInput> = {
|
||||
_types?: {
|
||||
input: TInput;
|
||||
output: TParsedInput;
|
||||
};
|
||||
};
|
||||
|
||||
type ParserMyZodEsque<TInput> = {
|
||||
parse: (input: any) => TInput;
|
||||
};
|
||||
|
||||
type ParserSuperstructEsque<TInput> = {
|
||||
create: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
type ParserCustomValidatorEsque<TInput> = (
|
||||
input: unknown,
|
||||
) => Promise<TInput> | TInput;
|
||||
|
||||
type ParserYupEsque<TInput> = {
|
||||
validateSync: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
type ParserScaleEsque<TInput> = {
|
||||
assert(value: unknown): asserts value is TInput;
|
||||
};
|
||||
|
||||
export type ParserWithoutInput<TInput> =
|
||||
| ParserCustomValidatorEsque<TInput>
|
||||
| ParserMyZodEsque<TInput>
|
||||
| ParserScaleEsque<TInput>
|
||||
| ParserSuperstructEsque<TInput>
|
||||
| ParserYupEsque<TInput>;
|
||||
|
||||
export type ParserWithInputOutput<TInput, TParsedInput> =
|
||||
| ParserZodEsque<TInput, TParsedInput>
|
||||
| ParserValibotEsque<TInput, TParsedInput>;
|
||||
|
||||
export type Parser =
|
||||
| ParserWithInputOutput<any, any>
|
||||
| ParserWithoutInput<any>;
|
||||
|
||||
export type inferParser<TParser extends Parser> =
|
||||
TParser extends ParserWithInputOutput<infer $TIn, infer $TOut>
|
||||
? {
|
||||
in: $TIn;
|
||||
out: $TOut;
|
||||
}
|
||||
: TParser extends ParserWithoutInput<infer $InOut>
|
||||
? {
|
||||
in: $InOut;
|
||||
out: $InOut;
|
||||
}
|
||||
: never;
|
||||
}
|
||||
12
packages/core/src/event/index.ts
Normal file
12
packages/core/src/event/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { event } from "./event.js";
|
||||
import { Actor } from "../actor.js";
|
||||
import { ZodValidator } from "./validator.js";
|
||||
|
||||
export const createEvent = event.builder({
|
||||
validator: ZodValidator,
|
||||
metadata() {
|
||||
return {
|
||||
actor: Actor.use(),
|
||||
};
|
||||
},
|
||||
});
|
||||
9
packages/core/src/event/validator.ts
Normal file
9
packages/core/src/event/validator.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type ZodType, z } from "zod/v4";
|
||||
|
||||
export function ZodValidator<Schema extends ZodType>(
|
||||
schema: Schema,
|
||||
): (input: z.input<Schema>) => z.output<Schema> {
|
||||
return (input) => {
|
||||
return schema.parse(input);
|
||||
};
|
||||
}
|
||||
32
packages/core/src/examples.ts
Normal file
32
packages/core/src/examples.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { prefixes } from "./utils/id.js";
|
||||
|
||||
export namespace Examples {
|
||||
export const Id = (prefix: keyof typeof prefixes) =>
|
||||
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),
|
||||
email: "email@example.com",
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
};
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
name: "John Doe",
|
||||
role: "adult" as const, // Role on the team, adult or child
|
||||
teamID: Team.id, // FK to Teams
|
||||
};
|
||||
|
||||
export const Token = {
|
||||
id: Id("apiPersonal"),
|
||||
token: "nst_test_******XXXX",
|
||||
created: new Date("2024-06-29 19:36:19.000"),
|
||||
};
|
||||
|
||||
export const App = {
|
||||
id: Id("apiApp"),
|
||||
secret: "sec_******XXXX",
|
||||
name: "Example App",
|
||||
redirectURI: "https://web.example.com/v1/callback",
|
||||
};
|
||||
}
|
||||
32
packages/core/src/member/index.ts
Normal file
32
packages/core/src/member/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod/v4";
|
||||
import { Examples } from "../examples.js";
|
||||
import { MemberRole } from "./member.sql.js";
|
||||
|
||||
export namespace Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: "The unique identifier of the member",
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: "The name of the member",
|
||||
example: Examples.Member.name,
|
||||
}),
|
||||
teamID: z.string().meta({
|
||||
description: "The unique identifier of the team this member belongs to",
|
||||
example: Examples.Member.teamID,
|
||||
}),
|
||||
role: z.enum(MemberRole.enumValues).meta({
|
||||
description: "The role of the member within the team",
|
||||
example: Examples.Member.role,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "Member",
|
||||
description: "Represents a member of a team",
|
||||
example: Examples.Member,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
}
|
||||
15
packages/core/src/member/member.sql.ts
Normal file
15
packages/core/src/member/member.sql.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { teamTable } from "../team/team.sql.js";
|
||||
import { pgEnum, pgTable } from "drizzle-orm/pg-core";
|
||||
import { id, ulid, timestamps } from "../drizzle/types.js";
|
||||
|
||||
export const MemberRole = pgEnum("role", ["adult", "child"]);
|
||||
|
||||
export const memberTable = pgTable("members", {
|
||||
...id,
|
||||
...timestamps,
|
||||
name: ulid("name").notNull(),
|
||||
teamID: ulid("team_id").references(() => teamTable.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
role: MemberRole("role").notNull(),
|
||||
});
|
||||
20
packages/core/src/polar/index.ts
Normal file
20
packages/core/src/polar/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod/v4";
|
||||
import { fn } from "../utils/fn.js";
|
||||
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
||||
import { type Customer } from "@polar-sh/sdk/models/components/customer.js";
|
||||
|
||||
export namespace Polar {
|
||||
export const client = new PolarSdk({
|
||||
accessToken: process.env.POLAR_API_KEY || "",
|
||||
server: process.env.APP_STAGE !== "production" ? "sandbox" : "production",
|
||||
});
|
||||
|
||||
export const fromUserEmail = fn(z.email(), async (email) => {
|
||||
const customers = await client.customers.list({ email });
|
||||
|
||||
if (customers.result.items.length === 0) {
|
||||
return await client.customers.create({ email });
|
||||
}
|
||||
return customers.result.items[0] as Customer;
|
||||
});
|
||||
}
|
||||
47
packages/core/src/redis/client.ts
Normal file
47
packages/core/src/redis/client.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Log } from "../utils/log.js";
|
||||
import { Redis, type RedisOptions } from "ioredis";
|
||||
|
||||
export {
|
||||
Redis,
|
||||
Cluster,
|
||||
type Callback,
|
||||
type RedisOptions,
|
||||
type Result,
|
||||
type RedisCommander,
|
||||
} from "ioredis";
|
||||
|
||||
const defaultOptions: Partial<RedisOptions> = {
|
||||
retryStrategy: (times: number) => {
|
||||
const delay = Math.min(times * 50, 1000);
|
||||
return delay;
|
||||
},
|
||||
maxRetriesPerRequest: 20,
|
||||
};
|
||||
|
||||
const logger = Log.create({ namespace: "redis" });
|
||||
|
||||
export namespace RedisClient {
|
||||
export function create(
|
||||
options: RedisOptions,
|
||||
handlers?: { onError?: (err: Error) => void },
|
||||
) {
|
||||
const client = new Redis({
|
||||
...defaultOptions,
|
||||
...options,
|
||||
});
|
||||
|
||||
client.on("error", (error) => {
|
||||
if (handlers?.onError) {
|
||||
handlers.onError(error);
|
||||
} else {
|
||||
logger.error(
|
||||
new Error(
|
||||
`Redis client error:${error} at keyPrefix:${options.keyPrefix}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
755
packages/core/src/redis/queue.ts
Normal file
755
packages/core/src/redis/queue.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import {
|
||||
Client,
|
||||
type Redis,
|
||||
type Callback,
|
||||
type RedisOptions,
|
||||
type Result,
|
||||
} from "./client.js";
|
||||
import { z } from "zod/v4";
|
||||
import { ulid } from "ulid";
|
||||
import { Log } from "../utils/log.js";
|
||||
|
||||
export interface MessageCatalogSchema {
|
||||
[key: string]: z.ZodFirstPartySchemaTypes; //| z.ZodDiscriminatedUnion<any, any>;
|
||||
}
|
||||
|
||||
export type MessageCatalogKey<TMessageCatalog extends MessageCatalogSchema> =
|
||||
keyof TMessageCatalog;
|
||||
export type MessageCatalogValue<
|
||||
TMessageCatalog extends MessageCatalogSchema,
|
||||
TKey extends MessageCatalogKey<TMessageCatalog>,
|
||||
> = z.infer<TMessageCatalog[TKey]>;
|
||||
|
||||
export type AnyMessageCatalog = MessageCatalogSchema;
|
||||
export type QueueItem<TMessageCatalog extends MessageCatalogSchema> = {
|
||||
id: string;
|
||||
job: MessageCatalogKey<TMessageCatalog>;
|
||||
item: MessageCatalogValue<
|
||||
TMessageCatalog,
|
||||
MessageCatalogKey<TMessageCatalog>
|
||||
>;
|
||||
visibilityTimeoutMs: number;
|
||||
attempt: number;
|
||||
timestamp: Date;
|
||||
deduplicationKey?: string;
|
||||
};
|
||||
|
||||
export type AnyQueueItem = {
|
||||
id: string;
|
||||
job: string;
|
||||
item: any;
|
||||
visibilityTimeoutMs: number;
|
||||
attempt: number;
|
||||
timestamp: Date;
|
||||
deduplicationKey?: string;
|
||||
};
|
||||
|
||||
export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
|
||||
name: string;
|
||||
private redis: Redis;
|
||||
private schema: TMessageCatalog;
|
||||
private logger: ReturnType<typeof Log.create>;
|
||||
|
||||
constructor({
|
||||
name,
|
||||
schema,
|
||||
redisOptions,
|
||||
}: {
|
||||
name: string;
|
||||
schema: TMessageCatalog;
|
||||
redisOptions: RedisOptions;
|
||||
}) {
|
||||
this.name = name;
|
||||
this.logger = Log.create({ namespace: "SimpleQueue" });
|
||||
|
||||
this.redis = Client.create(
|
||||
{
|
||||
...redisOptions,
|
||||
keyPrefix: `${redisOptions.keyPrefix ?? ""}{queue:${name}:}`,
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(times * 50, 1000);
|
||||
return delay;
|
||||
},
|
||||
maxRetriesPerRequest: 20,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
this.logger.error(`RedisWorker queue redis client error:`, {
|
||||
error,
|
||||
keyPrefix: redisOptions.keyPrefix,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
this.#registerCommands();
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
async cancel(cancellationKey: string): Promise<void> {
|
||||
await this.redis.set(
|
||||
`cancellationKey:${cancellationKey}`,
|
||||
"1",
|
||||
"EX",
|
||||
60 * 60 * 24,
|
||||
); // 1 day
|
||||
}
|
||||
|
||||
async enqueue({
|
||||
id,
|
||||
job,
|
||||
item,
|
||||
attempt,
|
||||
availableAt,
|
||||
visibilityTimeoutMs,
|
||||
cancellationKey,
|
||||
}: {
|
||||
id?: string;
|
||||
job: MessageCatalogKey<TMessageCatalog>;
|
||||
item: MessageCatalogValue<
|
||||
TMessageCatalog,
|
||||
MessageCatalogKey<TMessageCatalog>
|
||||
>;
|
||||
attempt?: number;
|
||||
availableAt?: Date;
|
||||
visibilityTimeoutMs: number;
|
||||
cancellationKey?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const score = availableAt ? availableAt.getTime() : Date.now();
|
||||
const deduplicationKey = ulid();
|
||||
const serializedItem = JSON.stringify({
|
||||
job,
|
||||
item,
|
||||
visibilityTimeoutMs,
|
||||
attempt,
|
||||
deduplicationKey,
|
||||
});
|
||||
|
||||
const result = cancellationKey
|
||||
? await this.redis.enqueueItemWithCancellationKey(
|
||||
`queue`,
|
||||
`items`,
|
||||
`cancellationKey:${cancellationKey}`,
|
||||
id ?? ulid(),
|
||||
score,
|
||||
serializedItem,
|
||||
)
|
||||
: await this.redis.enqueueItem(
|
||||
`queue`,
|
||||
`items`,
|
||||
id ?? ulid(),
|
||||
score,
|
||||
serializedItem,
|
||||
);
|
||||
|
||||
if (result !== 1) {
|
||||
throw new Error("Enqueue operation failed");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`SimpleQueue ${this.name}.enqueue(): error enqueuing`, {
|
||||
queue: this.name,
|
||||
error: e,
|
||||
id,
|
||||
item,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueOnce({
|
||||
id,
|
||||
job,
|
||||
item,
|
||||
attempt,
|
||||
availableAt,
|
||||
visibilityTimeoutMs,
|
||||
}: {
|
||||
id: string;
|
||||
job: MessageCatalogKey<TMessageCatalog>;
|
||||
item: MessageCatalogValue<
|
||||
TMessageCatalog,
|
||||
MessageCatalogKey<TMessageCatalog>
|
||||
>;
|
||||
attempt?: number;
|
||||
availableAt?: Date;
|
||||
visibilityTimeoutMs: number;
|
||||
}): Promise<boolean> {
|
||||
if (!id) {
|
||||
throw new Error("enqueueOnce requires an id");
|
||||
}
|
||||
try {
|
||||
const score = availableAt ? availableAt.getTime() : Date.now();
|
||||
const deduplicationKey = ulid();
|
||||
const serializedItem = JSON.stringify({
|
||||
job,
|
||||
item,
|
||||
visibilityTimeoutMs,
|
||||
attempt,
|
||||
deduplicationKey,
|
||||
});
|
||||
const result = await this.redis.enqueueItemOnce(
|
||||
`queue`,
|
||||
`items`,
|
||||
id,
|
||||
score,
|
||||
serializedItem,
|
||||
);
|
||||
// 1 if inserted, 0 if already exists
|
||||
return result === 1;
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`SimpleQueue ${this.name}.enqueueOnce(): error enqueuing`,
|
||||
{
|
||||
queue: this.name,
|
||||
error: e,
|
||||
id,
|
||||
item,
|
||||
},
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async dequeue(count: number = 1): Promise<Array<QueueItem<TMessageCatalog>>> {
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const results = await this.redis.dequeueItems(
|
||||
`queue`,
|
||||
`items`,
|
||||
now,
|
||||
count,
|
||||
);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dequeuedItems: Array<QueueItem<TMessageCatalog>> = [];
|
||||
|
||||
for (const [id, serializedItem, score] of results) {
|
||||
const parsedItem = JSON.parse(serializedItem) as any;
|
||||
if (typeof parsedItem.job !== "string") {
|
||||
this.logger.error(`Invalid item in queue`, {
|
||||
queue: this.name,
|
||||
id,
|
||||
item: parsedItem,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = new Date(Number(score));
|
||||
|
||||
const schema = this.schema[parsedItem.job];
|
||||
|
||||
if (!schema) {
|
||||
this.logger.error(`Invalid item in queue, schema not found`, {
|
||||
queue: this.name,
|
||||
id,
|
||||
item: parsedItem,
|
||||
job: parsedItem.job,
|
||||
timestamp,
|
||||
availableJobs: Object.keys(this.schema),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const validatedItem = schema.safeParse(parsedItem.item);
|
||||
|
||||
if (!validatedItem.success) {
|
||||
this.logger.error("Invalid item in queue", {
|
||||
queue: this.name,
|
||||
id,
|
||||
item: parsedItem,
|
||||
errors: validatedItem.error,
|
||||
attempt: parsedItem.attempt,
|
||||
timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const visibilityTimeoutMs = parsedItem.visibilityTimeoutMs as number;
|
||||
|
||||
dequeuedItems.push({
|
||||
id,
|
||||
job: parsedItem.job,
|
||||
item: validatedItem.data,
|
||||
visibilityTimeoutMs,
|
||||
attempt: parsedItem.attempt ?? 0,
|
||||
timestamp,
|
||||
deduplicationKey: parsedItem.deduplicationKey,
|
||||
});
|
||||
}
|
||||
|
||||
return dequeuedItems;
|
||||
} catch (e) {
|
||||
this.logger.error(`SimpleQueue ${this.name}.dequeue(): error dequeuing`, {
|
||||
queue: this.name,
|
||||
error: e,
|
||||
count,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async ack(id: string, deduplicationKey?: string): Promise<void> {
|
||||
try {
|
||||
const result = await this.redis.ackItem(
|
||||
`queue`,
|
||||
`items`,
|
||||
id,
|
||||
deduplicationKey ?? "",
|
||||
);
|
||||
if (result !== 1) {
|
||||
this.logger.debug(
|
||||
`SimpleQueue ${this.name}.ack(): ack operation returned ${result}. This means it was not removed from the queue.`,
|
||||
{
|
||||
queue: this.name,
|
||||
id,
|
||||
deduplicationKey,
|
||||
result,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`SimpleQueue ${this.name}.ack(): error acknowledging item`,
|
||||
{
|
||||
queue: this.name,
|
||||
error: e,
|
||||
id,
|
||||
deduplicationKey,
|
||||
},
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async reschedule(id: string, availableAt: Date): Promise<void> {
|
||||
await this.redis.zadd(`queue`, "XX", availableAt.getTime(), id);
|
||||
}
|
||||
|
||||
async size({
|
||||
includeFuture = false,
|
||||
}: { includeFuture?: boolean } = {}): Promise<number> {
|
||||
try {
|
||||
if (includeFuture) {
|
||||
// If includeFuture is true, return the total count of all items
|
||||
return await this.redis.zcard(`queue`);
|
||||
} else {
|
||||
// If includeFuture is false, return the count of items available now
|
||||
const now = Date.now();
|
||||
return await this.redis.zcount(`queue`, "-inf", now);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`SimpleQueue ${this.name}.size(): error getting queue size`,
|
||||
{
|
||||
queue: this.name,
|
||||
error: e,
|
||||
includeFuture,
|
||||
},
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async getJob(id: string): Promise<QueueItem<TMessageCatalog> | null> {
|
||||
const result = await this.redis.getJob(`queue`, `items`, id);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [_, score, serializedItem] = result;
|
||||
const item = JSON.parse(serializedItem) as QueueItem<TMessageCatalog>;
|
||||
|
||||
return {
|
||||
id,
|
||||
job: item.job,
|
||||
item: item.item,
|
||||
visibilityTimeoutMs: item.visibilityTimeoutMs,
|
||||
attempt: item.attempt ?? 0,
|
||||
timestamp: new Date(Number(score)),
|
||||
deduplicationKey: item.deduplicationKey ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async moveToDeadLetterQueue(id: string, errorMessage: string): Promise<void> {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`SimpleQueue ${this.name}.moveToDeadLetterQueue(): moving item to DLQ`,
|
||||
{
|
||||
queue: this.name,
|
||||
id,
|
||||
errorMessage,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await this.redis.moveToDeadLetterQueue(
|
||||
`queue`,
|
||||
`items`,
|
||||
`dlq`,
|
||||
`dlq:items`,
|
||||
id,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
if (result !== 1) {
|
||||
throw new Error("Move to Dead Letter Queue operation failed");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`SimpleQueue ${this.name}.moveToDeadLetterQueue(): error moving item to DLQ`,
|
||||
{
|
||||
queue: this.name,
|
||||
error: e,
|
||||
id,
|
||||
errorMessage,
|
||||
},
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async sizeOfDeadLetterQueue(): Promise<number> {
|
||||
try {
|
||||
return await this.redis.zcard(`dlq`);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`SimpleQueue ${this.name}.dlqSize(): error getting DLQ size`,
|
||||
{
|
||||
queue: this.name,
|
||||
error: e,
|
||||
},
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async redriveFromDeadLetterQueue(id: string): Promise<void> {
|
||||
try {
|
||||
const result = await this.redis.redriveFromDeadLetterQueue(
|
||||
`queue`,
|
||||
`items`,
|
||||
`dlq`,
|
||||
`dlq:items`,
|
||||
id,
|
||||
);
|
||||
|
||||
if (result !== 1) {
|
||||
throw new Error("Redrive from Dead Letter Queue operation failed");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`SimpleQueue ${this.name}.redriveFromDeadLetterQueue(): error redriving item from DLQ`,
|
||||
{
|
||||
queue: this.name,
|
||||
error: e,
|
||||
id,
|
||||
},
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
}
|
||||
|
||||
#registerCommands() {
|
||||
this.redis.defineCommand("enqueueItem", {
|
||||
numberOfKeys: 2,
|
||||
lua: `
|
||||
local queue = KEYS[1]
|
||||
local items = KEYS[2]
|
||||
local id = ARGV[1]
|
||||
local score = ARGV[2]
|
||||
local serializedItem = ARGV[3]
|
||||
|
||||
redis.call('ZADD', queue, score, id)
|
||||
redis.call('HSET', items, id, serializedItem)
|
||||
|
||||
return 1
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("enqueueItemWithCancellationKey", {
|
||||
numberOfKeys: 3,
|
||||
lua: `
|
||||
local queue = KEYS[1]
|
||||
local items = KEYS[2]
|
||||
local cancellationKey = KEYS[3]
|
||||
|
||||
local id = ARGV[1]
|
||||
local score = ARGV[2]
|
||||
local serializedItem = ARGV[3]
|
||||
|
||||
-- if the cancellation key exists, return 1
|
||||
if redis.call('EXISTS', cancellationKey) == 1 then
|
||||
return 1
|
||||
end
|
||||
|
||||
redis.call('ZADD', queue, score, id)
|
||||
redis.call('HSET', items, id, serializedItem)
|
||||
|
||||
return 1
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("dequeueItems", {
|
||||
numberOfKeys: 2,
|
||||
lua: `
|
||||
local queue = KEYS[1]
|
||||
local items = KEYS[2]
|
||||
local now = tonumber(ARGV[1])
|
||||
local count = tonumber(ARGV[2])
|
||||
|
||||
local result = redis.call('ZRANGEBYSCORE', queue, '-inf', now, 'WITHSCORES', 'LIMIT', 0, count)
|
||||
|
||||
if #result == 0 then
|
||||
return {}
|
||||
end
|
||||
|
||||
local dequeued = {}
|
||||
|
||||
for i = 1, #result, 2 do
|
||||
local id = result[i]
|
||||
local score = tonumber(result[i + 1])
|
||||
|
||||
if score > now then
|
||||
break
|
||||
end
|
||||
|
||||
local serializedItem = redis.call('HGET', items, id)
|
||||
|
||||
if serializedItem then
|
||||
local item = cjson.decode(serializedItem)
|
||||
local visibilityTimeoutMs = tonumber(item.visibilityTimeoutMs)
|
||||
local invisibleUntil = now + visibilityTimeoutMs
|
||||
|
||||
redis.call('ZADD', queue, invisibleUntil, id)
|
||||
table.insert(dequeued, {id, serializedItem, score})
|
||||
else
|
||||
-- Remove the orphaned queue entry if no corresponding item exists
|
||||
redis.call('ZREM', queue, id)
|
||||
end
|
||||
end
|
||||
|
||||
return dequeued
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("getJob", {
|
||||
numberOfKeys: 2,
|
||||
lua: `
|
||||
local queue = KEYS[1]
|
||||
local items = KEYS[2]
|
||||
local jobId = ARGV[1]
|
||||
|
||||
local serializedItem = redis.call('HGET', items, jobId)
|
||||
|
||||
if serializedItem == false then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- get the score from the queue sorted set
|
||||
local score = redis.call('ZSCORE', queue, jobId)
|
||||
|
||||
return { jobId, score, serializedItem }
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("ackItem", {
|
||||
numberOfKeys: 2,
|
||||
lua: `
|
||||
local queueKey = KEYS[1]
|
||||
local itemsKey = KEYS[2]
|
||||
local id = ARGV[1]
|
||||
local deduplicationKey = ARGV[2]
|
||||
|
||||
-- Get the item from the hash
|
||||
local item = redis.call('HGET', itemsKey, id)
|
||||
if not item then
|
||||
return -1
|
||||
end
|
||||
|
||||
-- Only check deduplicationKey if a non-empty one was passed in
|
||||
if deduplicationKey and deduplicationKey ~= "" then
|
||||
local success, parsed = pcall(cjson.decode, item)
|
||||
if success then
|
||||
if parsed.deduplicationKey and parsed.deduplicationKey ~= deduplicationKey then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Remove from sorted set and hash
|
||||
redis.call('ZREM', queueKey, id)
|
||||
redis.call('HDEL', itemsKey, id)
|
||||
return 1
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("moveToDeadLetterQueue", {
|
||||
numberOfKeys: 4,
|
||||
lua: `
|
||||
local queue = KEYS[1]
|
||||
local items = KEYS[2]
|
||||
local dlq = KEYS[3]
|
||||
local dlqItems = KEYS[4]
|
||||
local id = ARGV[1]
|
||||
local errorMessage = ARGV[2]
|
||||
|
||||
local item = redis.call('HGET', items, id)
|
||||
if not item then
|
||||
return 0
|
||||
end
|
||||
|
||||
local parsedItem = cjson.decode(item)
|
||||
parsedItem.errorMessage = errorMessage
|
||||
|
||||
local time = redis.call('TIME')
|
||||
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
||||
|
||||
redis.call('ZREM', queue, id)
|
||||
redis.call('HDEL', items, id)
|
||||
|
||||
redis.call('ZADD', dlq, now, id)
|
||||
redis.call('HSET', dlqItems, id, cjson.encode(parsedItem))
|
||||
|
||||
return 1
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("redriveFromDeadLetterQueue", {
|
||||
numberOfKeys: 4,
|
||||
lua: `
|
||||
local queue = KEYS[1]
|
||||
local items = KEYS[2]
|
||||
local dlq = KEYS[3]
|
||||
local dlqItems = KEYS[4]
|
||||
local id = ARGV[1]
|
||||
|
||||
local item = redis.call('HGET', dlqItems, id)
|
||||
if not item then
|
||||
return 0
|
||||
end
|
||||
|
||||
local parsedItem = cjson.decode(item)
|
||||
parsedItem.errorMessage = nil
|
||||
|
||||
local time = redis.call('TIME')
|
||||
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
||||
|
||||
redis.call('ZREM', dlq, id)
|
||||
redis.call('HDEL', dlqItems, id)
|
||||
|
||||
redis.call('ZADD', queue, now, id)
|
||||
redis.call('HSET', items, id, cjson.encode(parsedItem))
|
||||
|
||||
return 1
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("enqueueItemOnce", {
|
||||
numberOfKeys: 2,
|
||||
lua: `
|
||||
local queue = KEYS[1]
|
||||
local items = KEYS[2]
|
||||
local id = ARGV[1]
|
||||
local score = ARGV[2]
|
||||
local serializedItem = ARGV[3]
|
||||
|
||||
-- Only add if not exists
|
||||
local added = redis.call('HSETNX', items, id, serializedItem)
|
||||
if added == 1 then
|
||||
redis.call('ZADD', queue, 'NX', score, id)
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare module "ioredis" {
|
||||
interface RedisCommander<Context> {
|
||||
enqueueItem(
|
||||
//keys
|
||||
queue: string,
|
||||
items: string,
|
||||
//args
|
||||
id: string,
|
||||
score: number,
|
||||
serializedItem: string,
|
||||
callback?: Callback<number>,
|
||||
): Result<number, Context>;
|
||||
|
||||
enqueueItemWithCancellationKey(
|
||||
//keys
|
||||
queue: string,
|
||||
items: string,
|
||||
cancellationKey: string,
|
||||
//args
|
||||
id: string,
|
||||
score: number,
|
||||
serializedItem: string,
|
||||
callback?: Callback<number>,
|
||||
): Result<number, Context>;
|
||||
|
||||
dequeueItems(
|
||||
//keys
|
||||
queue: string,
|
||||
items: string,
|
||||
//args
|
||||
now: number,
|
||||
count: number,
|
||||
callback?: Callback<Array<[string, string, string]>>,
|
||||
): Result<Array<[string, string, string]>, Context>;
|
||||
|
||||
ackItem(
|
||||
queue: string,
|
||||
items: string,
|
||||
id: string,
|
||||
deduplicationKey: string,
|
||||
callback?: Callback<number>,
|
||||
): Result<number, Context>;
|
||||
|
||||
redriveFromDeadLetterQueue(
|
||||
queue: string,
|
||||
items: string,
|
||||
dlq: string,
|
||||
dlqItems: string,
|
||||
id: string,
|
||||
callback?: Callback<number>,
|
||||
): Result<number, Context>;
|
||||
|
||||
moveToDeadLetterQueue(
|
||||
queue: string,
|
||||
items: string,
|
||||
dlq: string,
|
||||
dlqItems: string,
|
||||
id: string,
|
||||
errorMessage: string,
|
||||
callback?: Callback<number>,
|
||||
): Result<number, Context>;
|
||||
|
||||
enqueueItemOnce(
|
||||
queue: string,
|
||||
items: string,
|
||||
id: string,
|
||||
score: number,
|
||||
serializedItem: string,
|
||||
callback?: Callback<number>,
|
||||
): Result<number, Context>;
|
||||
|
||||
getJob(
|
||||
queue: string,
|
||||
items: string,
|
||||
id: string,
|
||||
callback?: Callback<[string, string, string] | null>,
|
||||
): Result<[string, string, string] | null, Context>;
|
||||
}
|
||||
}
|
||||
3
packages/core/src/session/index.ts
Normal file
3
packages/core/src/session/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./json.js";
|
||||
export * from "./types.js";
|
||||
export * from "./schema.js";
|
||||
118
packages/core/src/session/json.ts
Normal file
118
packages/core/src/session/json.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const LiteralSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
||||
type Literal = z.infer<typeof LiteralSchema>;
|
||||
|
||||
export type DeserializedJson =
|
||||
| Literal
|
||||
| { [key: string]: DeserializedJson }
|
||||
| DeserializedJson[];
|
||||
|
||||
export const DeserializedJsonSchema: z.ZodType<DeserializedJson> = z.lazy(() =>
|
||||
z.union([
|
||||
LiteralSchema,
|
||||
z.array(DeserializedJsonSchema),
|
||||
z.record(z.string(), DeserializedJsonSchema),
|
||||
]),
|
||||
);
|
||||
|
||||
const SerializableSchema = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.date(),
|
||||
z.undefined(),
|
||||
z.symbol(),
|
||||
]);
|
||||
type Serializable = z.infer<typeof SerializableSchema>;
|
||||
|
||||
export type SerializableJson =
|
||||
| Serializable
|
||||
| { [key: string]: SerializableJson }
|
||||
| SerializableJson[];
|
||||
|
||||
export const SerializableJsonSchema: z.ZodType<SerializableJson> = z.lazy(() =>
|
||||
z.union([
|
||||
SerializableSchema,
|
||||
z.array(SerializableJsonSchema),
|
||||
z.record(z.string(), SerializableJsonSchema),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* JSON Schema type definition - compatible with JSON Schema Draft 7
|
||||
* Based on the JSONSchema7 type from @types/json-schema but defined inline to avoid import issues
|
||||
*/
|
||||
export interface JSONSchema {
|
||||
$id?: string;
|
||||
$ref?: string;
|
||||
$schema?: string;
|
||||
$comment?: string;
|
||||
|
||||
type?: JSONSchemaType | JSONSchemaType[];
|
||||
enum?: any[];
|
||||
const?: any;
|
||||
|
||||
// Number/Integer validations
|
||||
multipleOf?: number;
|
||||
maximum?: number;
|
||||
exclusiveMaximum?: number;
|
||||
minimum?: number;
|
||||
exclusiveMinimum?: number;
|
||||
|
||||
// String validations
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
pattern?: string;
|
||||
format?: string;
|
||||
|
||||
// Array validations
|
||||
items?: JSONSchema | JSONSchema[];
|
||||
additionalItems?: JSONSchema | boolean;
|
||||
maxItems?: number;
|
||||
minItems?: number;
|
||||
uniqueItems?: boolean;
|
||||
contains?: JSONSchema;
|
||||
|
||||
// Object validations
|
||||
maxProperties?: number;
|
||||
minProperties?: number;
|
||||
required?: string[];
|
||||
properties?: Record<string, JSONSchema>;
|
||||
patternProperties?: Record<string, JSONSchema>;
|
||||
additionalProperties?: JSONSchema | boolean;
|
||||
dependencies?: Record<string, JSONSchema | string[]>;
|
||||
propertyNames?: JSONSchema;
|
||||
|
||||
// Conditional schemas
|
||||
if?: JSONSchema;
|
||||
then?: JSONSchema;
|
||||
else?: JSONSchema;
|
||||
|
||||
// Boolean logic
|
||||
allOf?: JSONSchema[];
|
||||
anyOf?: JSONSchema[];
|
||||
oneOf?: JSONSchema[];
|
||||
not?: JSONSchema;
|
||||
|
||||
// Metadata
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
readOnly?: boolean;
|
||||
writeOnly?: boolean;
|
||||
examples?: any[];
|
||||
|
||||
// Additional properties for extensibility
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type JSONSchemaType =
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "object"
|
||||
| "array"
|
||||
| "null";
|
||||
152
packages/core/src/session/schema.ts
Normal file
152
packages/core/src/session/schema.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
export type SchemaZodEsque<TInput, TParsedInput> = {
|
||||
_input: TInput;
|
||||
_output: TParsedInput;
|
||||
};
|
||||
|
||||
export function isSchemaZodEsque<TInput, TParsedInput>(
|
||||
schema: Schema,
|
||||
): schema is SchemaZodEsque<TInput, TParsedInput> {
|
||||
return (
|
||||
typeof schema === "object" &&
|
||||
"_def" in schema &&
|
||||
"parse" in schema &&
|
||||
"parseAsync" in schema &&
|
||||
"safeParse" in schema
|
||||
);
|
||||
}
|
||||
|
||||
export type SchemaValibotEsque<TInput, TParsedInput> = {
|
||||
schema: {
|
||||
_types?: {
|
||||
input: TInput;
|
||||
output: TParsedInput;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function isSchemaValibotEsque<TInput, TParsedInput>(
|
||||
schema: Schema,
|
||||
): schema is SchemaValibotEsque<TInput, TParsedInput> {
|
||||
return typeof schema === "object" && "_types" in schema;
|
||||
}
|
||||
|
||||
export type SchemaArkTypeEsque<TInput, TParsedInput> = {
|
||||
inferIn: TInput;
|
||||
infer: TParsedInput;
|
||||
};
|
||||
|
||||
export function isSchemaArkTypeEsque<TInput, TParsedInput>(
|
||||
schema: Schema,
|
||||
): schema is SchemaArkTypeEsque<TInput, TParsedInput> {
|
||||
return (
|
||||
typeof schema === "object" && "_inferIn" in schema && "_infer" in schema
|
||||
);
|
||||
}
|
||||
|
||||
export type SchemaMyZodEsque<TInput> = {
|
||||
parse: (input: any) => TInput;
|
||||
};
|
||||
|
||||
export type SchemaSuperstructEsque<TInput> = {
|
||||
create: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type SchemaCustomValidatorEsque<TInput> = (
|
||||
input: unknown,
|
||||
) => Promise<TInput> | TInput;
|
||||
|
||||
export type SchemaYupEsque<TInput> = {
|
||||
validateSync: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type SchemaScaleEsque<TInput> = {
|
||||
assert(value: unknown): asserts value is TInput;
|
||||
};
|
||||
|
||||
export type SchemaWithoutInput<TInput> =
|
||||
| SchemaCustomValidatorEsque<TInput>
|
||||
| SchemaMyZodEsque<TInput>
|
||||
| SchemaScaleEsque<TInput>
|
||||
| SchemaSuperstructEsque<TInput>
|
||||
| SchemaYupEsque<TInput>;
|
||||
|
||||
export type SchemaWithInputOutput<TInput, TParsedInput> =
|
||||
| SchemaZodEsque<TInput, TParsedInput>
|
||||
| SchemaValibotEsque<TInput, TParsedInput>
|
||||
| SchemaArkTypeEsque<TInput, TParsedInput>;
|
||||
|
||||
export type Schema = SchemaWithInputOutput<any, any> | SchemaWithoutInput<any>;
|
||||
|
||||
export type inferSchema<TSchema extends Schema> =
|
||||
TSchema extends SchemaWithInputOutput<infer $TIn, infer $TOut>
|
||||
? {
|
||||
in: $TIn;
|
||||
out: $TOut;
|
||||
}
|
||||
: TSchema extends SchemaWithoutInput<infer $InOut>
|
||||
? {
|
||||
in: $InOut;
|
||||
out: $InOut;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type inferSchemaIn<
|
||||
TSchema extends Schema | undefined,
|
||||
TDefault = unknown,
|
||||
> = TSchema extends Schema ? inferSchema<TSchema>["in"] : TDefault;
|
||||
|
||||
export type inferSchemaOut<
|
||||
TSchema extends Schema | undefined,
|
||||
TDefault = unknown,
|
||||
> = TSchema extends Schema ? inferSchema<TSchema>["out"] : TDefault;
|
||||
|
||||
export type SchemaParseFn<TType> = (value: unknown) => Promise<TType> | TType;
|
||||
export type AnySchemaParseFn = SchemaParseFn<any>;
|
||||
|
||||
export function getSchemaParseFn<TType>(
|
||||
procedureParser: Schema,
|
||||
): SchemaParseFn<TType> {
|
||||
const parser = procedureParser as any;
|
||||
|
||||
if (typeof parser === "function" && typeof parser.assert === "function") {
|
||||
// ParserArkTypeEsque - arktype schemas shouldn't be called as a function because they return a union type instead of throwing
|
||||
return parser.assert.bind(parser);
|
||||
}
|
||||
|
||||
if (typeof parser === "function") {
|
||||
// ParserValibotEsque (>= v0.31.0)
|
||||
// ParserCustomValidatorEsque
|
||||
return parser;
|
||||
}
|
||||
|
||||
if (typeof parser.parseAsync === "function") {
|
||||
// ParserZodEsque
|
||||
return parser.parseAsync.bind(parser);
|
||||
}
|
||||
|
||||
if (typeof parser.parse === "function") {
|
||||
// ParserZodEsque
|
||||
// ParserValibotEsque (< v0.13.0)
|
||||
return parser.parse.bind(parser);
|
||||
}
|
||||
|
||||
if (typeof parser.validateSync === "function") {
|
||||
// ParserYupEsque
|
||||
return parser.validateSync.bind(parser);
|
||||
}
|
||||
|
||||
if (typeof parser.create === "function") {
|
||||
// ParserSuperstructEsque
|
||||
return parser.create.bind(parser);
|
||||
}
|
||||
|
||||
if (typeof parser.assert === "function") {
|
||||
// ParserScaleEsque
|
||||
return (value) => {
|
||||
parser.assert(value);
|
||||
return value as TType;
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Could not find a validator fn");
|
||||
}
|
||||
1076
packages/core/src/session/types.ts
Normal file
1076
packages/core/src/session/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
100
packages/core/src/team/index.ts
Normal file
100
packages/core/src/team/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { z } from "zod/v4";
|
||||
import { fn } from "../utils/fn.js";
|
||||
import { Examples } from "../examples.js";
|
||||
import { teamTable } from "./team.sql.js";
|
||||
import { createID } from "../utils/id.js";
|
||||
import { Polar } from "../polar/index.js";
|
||||
import { Database } from "../drizzle/index.js";
|
||||
import { VisibleError, ErrorCodes } from "../error.js";
|
||||
import { and, asc, eq, isNull, sql } from "drizzle-orm";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: "The unique identifier of the team",
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
email: z.email().meta({
|
||||
description: "The contact email address for the team",
|
||||
example: Examples.Team.email,
|
||||
}),
|
||||
polarCustomerID: z.string().nullable().meta({
|
||||
description: "Associated Polar.sh customer identifier",
|
||||
example: Examples.Team.polarCustomerID,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "Team",
|
||||
description: "Represents a team entity",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export class TeamExistsError extends VisibleError {
|
||||
constructor(email: string) {
|
||||
super(
|
||||
"already_exists",
|
||||
ErrorCodes.Validation.ALREADY_EXISTS,
|
||||
`A team with this email ${email} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info.omit({ polarCustomerID: true }).partial({ id: true }),
|
||||
async (input) => {
|
||||
const userID = createID("team");
|
||||
const polarCustomerID = await Polar.fromUserEmail(input.email).then(
|
||||
(polarUser) => polarUser?.id || null,
|
||||
);
|
||||
|
||||
const id = input.id ?? userID;
|
||||
|
||||
await Database.transaction((tx) => {
|
||||
return tx
|
||||
.insert(teamTable)
|
||||
.values({
|
||||
id,
|
||||
email: input.email,
|
||||
polarCustomerID,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: teamTable.email,
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
id: sql.raw(`excluded.${teamTable.id.name}`),
|
||||
polarCustomerID: sql.raw(
|
||||
`excluded.${teamTable.polarCustomerID.name}`,
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
export const fromEmail = fn(Info.shape.email, async (email) =>
|
||||
Database.transaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(and(eq(teamTable.email, email), isNull(teamTable.timeDeleted)))
|
||||
.orderBy(asc(teamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0)),
|
||||
),
|
||||
);
|
||||
|
||||
export function serialize(
|
||||
input: typeof teamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
polarCustomerID: input.polarCustomerID,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
packages/core/src/team/team.sql.ts
Normal file
9
packages/core/src/team/team.sql.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { id, timestamps } from "../drizzle/types.js";
|
||||
import { pgTable, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const teamTable = pgTable("team", {
|
||||
...id,
|
||||
...timestamps,
|
||||
email: varchar("email", { length: 255 }).unique().notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }),
|
||||
});
|
||||
30
packages/core/src/utils/fn.ts
Normal file
30
packages/core/src/utils/fn.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type ZodType, z } from "zod/v4";
|
||||
|
||||
export function fn<
|
||||
Arg1 extends ZodType,
|
||||
Callback extends (arg1: z.output<Arg1>) => any,
|
||||
>(arg1: Arg1, cb: Callback) {
|
||||
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
return cb.apply(cb, [parsed as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function doubleFn<
|
||||
Arg1 extends ZodType,
|
||||
Arg2 extends ZodType,
|
||||
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
|
||||
>(arg1: Arg1, arg2: Arg2, cb: Callback) {
|
||||
const result = function (
|
||||
input: z.input<typeof arg1>,
|
||||
input2: z.input<typeof arg2>,
|
||||
): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
const parsed2 = arg2.parse(input2);
|
||||
return cb.apply(cb, [parsed as any, parsed2 as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
23
packages/core/src/utils/id.ts
Normal file
23
packages/core/src/utils/id.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
apiApp: "app",
|
||||
apiSecret: "sec",
|
||||
apiPersonal: "pat",
|
||||
team: "team",
|
||||
member: "mebr",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Generates a unique identifier by concatenating a predefined prefix with a ULID.
|
||||
*
|
||||
* Given a key from the predefined prefixes mapping (e.g.,"team", "member", "steam"),
|
||||
* this function retrieves the corresponding prefix and combines it with a ULID using an underscore
|
||||
* as a separator. The resulting identifier is formatted as "prefix_ulid".
|
||||
*
|
||||
* @param prefix - A key from the prefixes mapping.
|
||||
* @returns A unique identifier string.
|
||||
*/
|
||||
export function createID(prefix: keyof typeof prefixes): string {
|
||||
return [prefixes[prefix], ulid()].join("_");
|
||||
}
|
||||
2
packages/core/src/utils/index.ts
Normal file
2
packages/core/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./log.js";
|
||||
export * from "./types.js";
|
||||
76
packages/core/src/utils/log.ts
Normal file
76
packages/core/src/utils/log.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Context } from "../context.js";
|
||||
|
||||
export namespace Log {
|
||||
const ctx = Context.create<{
|
||||
tags: Record<string, any>;
|
||||
}>();
|
||||
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {};
|
||||
|
||||
const result = {
|
||||
info(msg: string, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.log(prefix, msg);
|
||||
return result;
|
||||
},
|
||||
warn(msg: string, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.warn(prefix, msg);
|
||||
return result;
|
||||
},
|
||||
error(error: Error) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.error(prefix, error);
|
||||
return result;
|
||||
},
|
||||
tag(key: string, value: string) {
|
||||
tags[key] = value;
|
||||
return result;
|
||||
},
|
||||
clone() {
|
||||
return Log.create({ ...tags });
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function provide<R>(tags: Record<string, any>, cb: () => R) {
|
||||
const existing = use();
|
||||
return ctx.provide(
|
||||
{
|
||||
tags: {
|
||||
...existing.tags,
|
||||
...tags,
|
||||
},
|
||||
},
|
||||
cb,
|
||||
);
|
||||
}
|
||||
|
||||
function use() {
|
||||
try {
|
||||
return ctx.use();
|
||||
} catch (e) {
|
||||
return { tags: {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/core/src/utils/types.ts
Normal file
16
packages/core/src/utils/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type RequireKeys<T extends object, K extends keyof T> = Required<
|
||||
Pick<T, K>
|
||||
> &
|
||||
Omit<T, K> extends infer O
|
||||
? { [P in keyof O]: O[P] }
|
||||
: never;
|
||||
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
||||
declare const __brand: unique symbol;
|
||||
type Brand<B> = { [__brand]: B };
|
||||
type Branded<T, B> = T & Brand<B>;
|
||||
|
||||
export type IdempotencyKey = Branded<string, "IdempotencyKey">;
|
||||
35
packages/core/tsconfig.base.json
Normal file
35
packages/core/tsconfig.base.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
8
packages/core/tsconfig.json
Normal file
8
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.src.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
packages/core/tsconfig.src.json
Normal file
9
packages/core/tsconfig.src.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["./src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false,
|
||||
"composite": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
34
packages/function/.gitignore
vendored
Normal file
34
packages/function/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/function/README.md
Normal file
15
packages/function/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# function
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.23. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
20
packages/function/package.json
Normal file
20
packages/function/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@nestri/function",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
"@nestri/core": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"hono": "^4.10.1",
|
||||
"hono-openapi": "^1.1.0",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
69
packages/function/src/api/index.ts
Normal file
69
packages/function/src/api/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { subjects } from "../subjects";
|
||||
import { MiddlewareHandler } from "hono";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { Api } from "@nestri/core/api/index";
|
||||
import { Log } from "@nestri/core/utils/log";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { VisibleError, ErrorCodes } from "@nestri/core/error";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
issuer: process.env.AUTH_URL,
|
||||
subjects,
|
||||
});
|
||||
|
||||
const log = Log.create({ namespace: "api" });
|
||||
|
||||
const auth: MiddlewareHandler = async (c, next) => {
|
||||
const authHeader =
|
||||
c.req.query("authorization") ?? c.req.header("authorization");
|
||||
if (authHeader) {
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
if (!match || !match[1]) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"Bearer token not found or improperly formatted",
|
||||
);
|
||||
}
|
||||
const bearerToken = match[1];
|
||||
|
||||
if (bearerToken?.startsWith("nst_")) {
|
||||
const token = await Api.Personal.fromToken(bearerToken);
|
||||
if (!token)
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||
"Invalid personal access token",
|
||||
);
|
||||
return Actor.provide(
|
||||
"token",
|
||||
{
|
||||
teamID: token.teamID,
|
||||
tokenID: token.id,
|
||||
},
|
||||
next,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await client.verify(bearerToken!);
|
||||
if (result.err)
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||
"Invalid bearer token",
|
||||
);
|
||||
if (result.subject.type === "team") {
|
||||
return Actor.provide(
|
||||
"team",
|
||||
{
|
||||
email: result.subject.properties.email,
|
||||
teamID: result.subject.properties.teamID,
|
||||
},
|
||||
next,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Actor.provide("public", {}, next);
|
||||
};
|
||||
76
packages/function/src/auth/index.ts
Normal file
76
packages/function/src/auth/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod/v4";
|
||||
import { subjects } from "../subjects.js";
|
||||
|
||||
import {redis} from 'bun';
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
import { RedisStorage } from "./src/storage.js";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { CodeProvider } from "@openauthjs/openauth/provider/code";
|
||||
|
||||
//!TODO: Add an auth storage adapter, maybe redis or postgres
|
||||
const storage = RedisStorage({
|
||||
redis: RedisClient.create(),
|
||||
});
|
||||
|
||||
const app = issuer({
|
||||
subjects,
|
||||
providers: {
|
||||
email: CodeProvider({
|
||||
async request(_req, state, form, error) {
|
||||
console.log(state);
|
||||
const params = new URLSearchParams();
|
||||
if (error) {
|
||||
params.set("error", error.type);
|
||||
}
|
||||
if (state.type === "start") {
|
||||
return Response.redirect(
|
||||
process.env.AUTH_FRONTEND_URL + "/auth/email?" + params.toString(),
|
||||
302,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.type === "code") {
|
||||
return Response.redirect(
|
||||
process.env.AUTH_FRONTEND_URL + "/auth/code?" + params.toString(),
|
||||
302,
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("ok");
|
||||
},
|
||||
async sendCode(claims, code) {
|
||||
const email = z.email().parse(claims.email);
|
||||
console.log(`Sending code ${code} to email ${email}`);
|
||||
},
|
||||
}),
|
||||
},
|
||||
success: async (ctx, res) => {
|
||||
let email = res.claims.email;
|
||||
|
||||
if (!email) throw new Error("No email found");
|
||||
let teamID = await Team.fromEmail(email).then((x) => x?.id);
|
||||
if (!teamID) {
|
||||
console.log("creating account for", email);
|
||||
teamID = await Team.create({
|
||||
email: email!,
|
||||
});
|
||||
}
|
||||
return ctx.subject("team", email!, {
|
||||
teamID: teamID!,
|
||||
email: email,
|
||||
});
|
||||
},
|
||||
async allow(input) {
|
||||
const url = new URL(input.redirectURI);
|
||||
return (
|
||||
url.hostname.endsWith("localhost") ||
|
||||
url.hostname.endsWith("nestri.io") ||
|
||||
url.hostname.endsWith("console.nestri.io")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
port: 3000,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
126
packages/function/src/auth/src/storage.ts
Normal file
126
packages/function/src/auth/src/storage.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Configure OpenAuth to use Redis DB as a
|
||||
* storage adapter.
|
||||
*
|
||||
* ```ts
|
||||
* import { RedisStorage } from "./src/storage.js"
|
||||
* import { Redis } from "@nestri/core/redis/client"
|
||||
*
|
||||
* const storage = RedisStorage({
|
||||
* redis: Redis.Client.create()
|
||||
* })
|
||||
*
|
||||
*
|
||||
* export default issuer({
|
||||
* storage,
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
import {
|
||||
joinKey,
|
||||
StorageAdapter,
|
||||
} from "@openauthjs/openauth/storage/storage";
|
||||
import { Cluster, Redis } from "@nestri/core/redis/client";
|
||||
|
||||
const separator = String.fromCharCode(0x1f);
|
||||
|
||||
/**
|
||||
* Configure the Redis connection that's created.
|
||||
*/
|
||||
export interface RedisStorageOptions {
|
||||
redis: Cluster | Redis;
|
||||
keyPrefix?: string;
|
||||
defaultTTL?: number;
|
||||
}
|
||||
/**
|
||||
* Creates a Cloudflare KV store.
|
||||
* @param options - The config for the adapter.
|
||||
*/
|
||||
export function RedisStorage(options: RedisStorageOptions): StorageAdapter {
|
||||
const { redis } = options;
|
||||
const prefix = "storage";
|
||||
|
||||
function createKey(key: string[]): string {
|
||||
return `${prefix}:${joinKey(key)}`;
|
||||
}
|
||||
|
||||
function parseKey(redisKey: string): string[] {
|
||||
const withoutPrefix = redisKey.slice(prefix.length + 1);
|
||||
return withoutPrefix.split(separator);
|
||||
}
|
||||
return {
|
||||
async get(key: string[]) {
|
||||
const redisKey = createKey(key);
|
||||
const value = await redis.get(redisKey);
|
||||
|
||||
if (!value) return;
|
||||
|
||||
return JSON.parse(value) as Record<string, any>;
|
||||
},
|
||||
|
||||
async set(key: string[], value: any, expiry?: Date) {
|
||||
const redisKey = createKey(key);
|
||||
|
||||
const ttlSeconds = expiry
|
||||
? Math.max(Math.floor((expiry.getTime() - Date.now()) / 1000), 1)
|
||||
: undefined;
|
||||
|
||||
if (ttlSeconds) {
|
||||
// SET key value EX ttlSeconds
|
||||
await options.redis.set(
|
||||
redisKey,
|
||||
JSON.stringify(value),
|
||||
"EX",
|
||||
ttlSeconds,
|
||||
);
|
||||
} else {
|
||||
// No expiry → simple SET
|
||||
await options.redis.set(redisKey, JSON.stringify(value));
|
||||
}
|
||||
},
|
||||
|
||||
async remove(key: string[]) {
|
||||
const redisKey = createKey(key);
|
||||
await options.redis.del(redisKey);
|
||||
},
|
||||
|
||||
async *scan(prefix: string[]) {
|
||||
const scanPattern = `${createKey(prefix)}${
|
||||
prefix.length ? separator : ""
|
||||
}*`;
|
||||
let cursor = "0";
|
||||
|
||||
do {
|
||||
// SCAN cursor MATCH pattern COUNT 100
|
||||
const [nextCursor, keys] = await redis.scan(
|
||||
cursor,
|
||||
"MATCH",
|
||||
scanPattern,
|
||||
"COUNT",
|
||||
100,
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
if (keys.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const values = await redis.mget(...keys);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const value = values[i];
|
||||
if (value !== null) {
|
||||
const parsedValue =
|
||||
typeof value === "string" ? JSON.parse(value) : value;
|
||||
yield [parseKey(key), parsedValue];
|
||||
}
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
},
|
||||
};
|
||||
}
|
||||
9
packages/function/src/subjects.ts
Normal file
9
packages/function/src/subjects.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
import { createSubjects } from "@openauthjs/openauth/subject";
|
||||
|
||||
export const subjects = createSubjects({
|
||||
team: z.object({
|
||||
email: z.email(),
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
30
packages/function/src/utils/logger.ts
Normal file
30
packages/function/src/utils/logger.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { format } from "node:util";
|
||||
|
||||
/**
|
||||
* Overrides the default Node.js console logging methods with a custom logger.
|
||||
*
|
||||
* This function patches console.log, console.warn, console.error, console.trace, and console.debug so that each logs
|
||||
* messages prefixed with a log level. The messages are formatted using Node.js formatting conventions, with newline
|
||||
* characters replaced by carriage returns, and are written directly to standard output.
|
||||
*
|
||||
* @example
|
||||
* patchLogger();
|
||||
* console.info("Server started on port %d", 3000);
|
||||
*/
|
||||
export function patchLogger() {
|
||||
const log =
|
||||
(level: "INFO" | "WARN" | "TRACE" | "DEBUG" | "ERROR") =>
|
||||
(msg: string, ...rest: any[]) => {
|
||||
let formattedMessage = format(msg, ...rest);
|
||||
// Split by newlines, prefix each line with the level, and join back
|
||||
const lines = formattedMessage.split("\n");
|
||||
const prefixedLines = lines.map((line) => `${level}\t${line}`);
|
||||
const output = prefixedLines.join("\n");
|
||||
process.stdout.write(output + "\n");
|
||||
};
|
||||
console.log = log("INFO");
|
||||
console.warn = log("WARN");
|
||||
console.error = log("ERROR");
|
||||
console.trace = log("TRACE");
|
||||
console.debug = log("DEBUG");
|
||||
}
|
||||
12
packages/function/tsconfig.json
Normal file
12
packages/function/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/index.ts
Normal file
1
packages/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
console.log("Hello via Bun!");
|
||||
12
packages/package.json
Normal file
12
packages/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "packages",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
34
packages/sdk/.gitignore
vendored
Normal file
34
packages/sdk/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/sdk/README.md
Normal file
15
packages/sdk/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# sdk
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.23. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
22
packages/sdk/examples/test.ts
Normal file
22
packages/sdk/examples/test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { session } from "../src/index.js";
|
||||
|
||||
const myTestSession = session({
|
||||
id: "test-session",
|
||||
description: "A game test session",
|
||||
resolution: "1080x1920",
|
||||
videoBitDepth: 8,
|
||||
machine: "gpu-medium",
|
||||
videoCodec: "h264",
|
||||
framerate: 60,
|
||||
audioBitrate: 128,
|
||||
gpuCardPath: "/dev/dri/renderD128",
|
||||
|
||||
run: async (payload) => {
|
||||
console.log("Running test session with payload:", payload);
|
||||
return { message: "Test session completed" };
|
||||
},
|
||||
});
|
||||
|
||||
myTestSession.start().then((result) => {
|
||||
console.log("Session result:", result);
|
||||
});
|
||||
18
packages/sdk/package.json
Normal file
18
packages/sdk/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@nestri/sdk",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.86.2",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
"@nestri/core": "workspace:^"
|
||||
}
|
||||
}
|
||||
217
packages/sdk/src/index.ts
Normal file
217
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
type Session,
|
||||
type RunTypes,
|
||||
type InitOutput,
|
||||
type StartOptions,
|
||||
type inferSchemaIn,
|
||||
type SchemaParseFn,
|
||||
type SessionSchema,
|
||||
type SessionOptions,
|
||||
type SessionWithSchema,
|
||||
type SessionWithSchemaOptions,
|
||||
type RunHandleFromTypes,
|
||||
type AnyRunTypes,
|
||||
type SessionOptionsWithSchema,
|
||||
getSchemaParseFn,
|
||||
SessionRunPromise,
|
||||
} from "@nestri/core/session/index";
|
||||
|
||||
// Overload: when payloadSchema is provided, payload type should be any
|
||||
export function createRun<
|
||||
TIdentifier extends string,
|
||||
TOutput = unknown,
|
||||
TInitOutput extends InitOutput = any,
|
||||
>(
|
||||
params: SessionOptionsWithSchema<TIdentifier, TOutput, TInitOutput>,
|
||||
): Session<TIdentifier, any, TOutput>;
|
||||
|
||||
// Overload: normal case without payloadSchema
|
||||
export function createRun<
|
||||
TIdentifier extends string,
|
||||
TInput = void,
|
||||
TOutput = unknown,
|
||||
TInitOutput extends InitOutput = any,
|
||||
>(
|
||||
params: SessionOptions<TIdentifier, TInput, TOutput, TInitOutput>,
|
||||
): Session<TIdentifier, TInput, TOutput>;
|
||||
|
||||
export function createRun<
|
||||
TIdentifier extends string,
|
||||
TInput = void,
|
||||
TOutput = unknown,
|
||||
TInitOutput extends InitOutput = any,
|
||||
>(
|
||||
params:
|
||||
| SessionOptions<TIdentifier, TInput, TOutput, TInitOutput>
|
||||
| SessionOptionsWithSchema<TIdentifier, TOutput, TInitOutput>,
|
||||
): Session<TIdentifier, TInput, TOutput> | Session<TIdentifier, any, TOutput> {
|
||||
const session: Session<TIdentifier, TInput, TOutput> = {
|
||||
id: params.id,
|
||||
description: params.description,
|
||||
jsonSchema: params.jsonSchema,
|
||||
start: async (payload, options) => {
|
||||
return await run_internal<RunTypes<TIdentifier, TInput, TOutput>>(
|
||||
"run()",
|
||||
params.id,
|
||||
payload,
|
||||
undefined,
|
||||
{
|
||||
queue: params.queue?.name,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
},
|
||||
startAndWait: (payload, options) => {
|
||||
return new SessionRunPromise<TIdentifier, TOutput>((resolve, reject) => {
|
||||
triggerAndWait_internal<TIdentifier, TInput, TOutput>(
|
||||
"triggerAndWait()",
|
||||
params.id,
|
||||
payload,
|
||||
undefined,
|
||||
{
|
||||
queue: params.queue?.name,
|
||||
...options,
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}, params.id);
|
||||
},
|
||||
};
|
||||
|
||||
registerTaskLifecycleHooks(params.id, params);
|
||||
|
||||
resourceCatalog.registerTaskMetadata({
|
||||
id: params.id,
|
||||
description: params.description,
|
||||
queue: params.queue,
|
||||
retry: params.retry
|
||||
? { ...defaultRetryOptions, ...params.retry }
|
||||
: undefined,
|
||||
machine:
|
||||
typeof params.machine === "string"
|
||||
? { preset: params.machine }
|
||||
: params.machine,
|
||||
maxDuration: params.maxDuration,
|
||||
payloadSchema: params.jsonSchema,
|
||||
fns: {
|
||||
run: params.run,
|
||||
},
|
||||
});
|
||||
|
||||
const queue = params.queue;
|
||||
|
||||
if (queue && typeof queue.name === "string") {
|
||||
resourceCatalog.registerQueueMetadata({
|
||||
name: queue.name,
|
||||
concurrencyLimit: queue.concurrencyLimit,
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
session[Symbol.for("nestri/session")] = true;
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function createSchemaTask<
|
||||
TIdentifier extends string,
|
||||
TSchema extends SessionSchema | undefined = undefined,
|
||||
TOutput = unknown,
|
||||
TInitOutput extends InitOutput = any,
|
||||
>(
|
||||
params: SessionWithSchemaOptions<TIdentifier, TSchema, TOutput, TInitOutput>,
|
||||
): SessionWithSchema<TIdentifier, TSchema, TOutput> {
|
||||
const parsePayload = params.schema
|
||||
? getSchemaParseFn<inferSchemaIn<TSchema>>(params.schema)
|
||||
: undefined;
|
||||
|
||||
const session: SessionWithSchema<TIdentifier, TSchema, TOutput> = {
|
||||
id: params.id,
|
||||
description: params.description,
|
||||
schema: params.schema,
|
||||
start: async (payload, options, requestOptions) => {
|
||||
return await run_internal<
|
||||
RunTypes<TIdentifier, inferSchemaIn<TSchema>, TOutput>
|
||||
>(
|
||||
"run()",
|
||||
params.id,
|
||||
payload,
|
||||
parsePayload,
|
||||
{
|
||||
queue: params.queue?.name,
|
||||
...options,
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
startAndWait: (payload, options) => {
|
||||
return new SessionRunPromise<TIdentifier, TOutput>((resolve, reject) => {
|
||||
triggerAndWait_internal<TIdentifier, inferSchemaIn<TSchema>, TOutput>(
|
||||
"triggerAndWait()",
|
||||
params.id,
|
||||
payload,
|
||||
parsePayload,
|
||||
{
|
||||
queue: params.queue?.name,
|
||||
...options,
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}, params.id);
|
||||
},
|
||||
};
|
||||
|
||||
registerTaskLifecycleHooks(params.id, params);
|
||||
|
||||
resourceCatalog.registerTaskMetadata({
|
||||
id: params.id,
|
||||
description: params.description,
|
||||
queue: params.queue,
|
||||
retry: params.retry
|
||||
? { ...defaultRetryOptions, ...params.retry }
|
||||
: undefined,
|
||||
machine: { preset: params.machine },
|
||||
maxDuration: params.maxDuration,
|
||||
fns: {
|
||||
run: params.run,
|
||||
parsePayload,
|
||||
},
|
||||
schema: params.schema,
|
||||
});
|
||||
|
||||
const queue = params.queue;
|
||||
|
||||
if (queue && typeof queue.name === "string") {
|
||||
resourceCatalog.registerQueueMetadata({
|
||||
name: queue.name,
|
||||
concurrencyLimit: queue.concurrencyLimit,
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
session[Symbol.for("nestri/session")] = true;
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export const session = createRun;
|
||||
export const schemaSession = createSchemaTask;
|
||||
|
||||
async function run_internal<TRunTypes extends AnyRunTypes>(
|
||||
name: string,
|
||||
id: TRunTypes["sessionIdentifier"],
|
||||
payload: TRunTypes["payload"],
|
||||
parsePayload?: SchemaParseFn<TRunTypes["payload"]>,
|
||||
options?: StartOptions,
|
||||
requestOptions?: any, //TriggerApiRequestOptions,
|
||||
): Promise<RunHandleFromTypes<TRunTypes>> {}
|
||||
42
packages/sdk/src/script/build.ts
Normal file
42
packages/sdk/src/script/build.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bun
|
||||
//TODO: Fix this to import JSON
|
||||
|
||||
const dir = new URL("..", import.meta.url).pathname;
|
||||
process.chdir(dir);
|
||||
|
||||
import { $ } from "bun";
|
||||
import path from "path";
|
||||
|
||||
import { createClient } from "@hey-api/openapi-ts";
|
||||
|
||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(
|
||||
path.resolve(dir, "../../opencode"),
|
||||
);
|
||||
|
||||
await createClient({
|
||||
input: "./openapi.json",
|
||||
output: {
|
||||
path: "./src/gen",
|
||||
tsConfigPath: path.join(dir, "tsconfig.json"),
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "@hey-api/typescript",
|
||||
exportFromIndex: false,
|
||||
},
|
||||
{
|
||||
name: "@hey-api/sdk",
|
||||
instance: "OpencodeClient",
|
||||
exportFromIndex: false,
|
||||
auth: false,
|
||||
},
|
||||
{
|
||||
name: "@hey-api/client-fetch",
|
||||
exportFromIndex: false,
|
||||
baseUrl: "http://localhost:4096",
|
||||
},
|
||||
],
|
||||
});
|
||||
await $`bun prettier --write src/gen`;
|
||||
await $`rm -rf dist`;
|
||||
await $`bun tsc`;
|
||||
9
packages/sdk/tsconfig.build.json
Normal file
9
packages/sdk/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@nestri/core/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"sourceMap": true,
|
||||
"stripInternal": true,
|
||||
"isolatedDeclarations": false
|
||||
}
|
||||
}
|
||||
10
packages/sdk/tsconfig.json
Normal file
10
packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@nestri/core/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false,
|
||||
"composite": true,
|
||||
"sourceMap": true,
|
||||
"stripInternal": true
|
||||
},
|
||||
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts"]
|
||||
}
|
||||
34
packages/studio/.gitignore
vendored
Normal file
34
packages/studio/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/studio/README.md
Normal file
15
packages/studio/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# studio
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.23. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
1
packages/studio/index.ts
Normal file
1
packages/studio/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
console.log("Hello via Bun!");
|
||||
15
packages/studio/package.json
Normal file
15
packages/studio/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nestri/studio",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
10
packages/studio/tsconfig.json
Normal file
10
packages/studio/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@nestri/core/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false,
|
||||
"composite": true,
|
||||
"sourceMap": true,
|
||||
"stripInternal": true
|
||||
},
|
||||
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts"]
|
||||
}
|
||||
29
packages/tsconfig.json
Normal file
29
packages/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export default new Map();
|
||||
@@ -1 +0,0 @@
|
||||
export default new Map();
|
||||
@@ -1 +0,0 @@
|
||||
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.11.2","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false},\"legacy\":{\"collections\":false}}"]
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1752741260450
|
||||
}
|
||||
}
|
||||
1
packages/web/.astro/types.d.ts
vendored
1
packages/web/.astro/types.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="astro/client" />
|
||||
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="410"
|
||||
height="50"
|
||||
viewBox="0 0 108.47917 13.229167"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="logo-black.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
|
||||
sodipodi:docname="logo.black.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1.9477379"
|
||||
inkscape:cx="243.35923"
|
||||
inkscape:cy="61.353224"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1046"
|
||||
inkscape:window-x="-11"
|
||||
inkscape:window-y="-11"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs1"><linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint1"
|
||||
x1="317.5"
|
||||
x2="314.00699"
|
||||
y1="-51.5"
|
||||
y2="126"
|
||||
gradientTransform="matrix(0.06483182,0,0,0.06483182,7.793483,1.7716596)"><stop
|
||||
stop-color="white"
|
||||
id="stop1" /><stop
|
||||
offset="1"
|
||||
stop-opacity="0"
|
||||
id="stop2" /></linearGradient></defs><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"><path
|
||||
fill="url(#paint1)"
|
||||
pathLength="1"
|
||||
stroke="url(#paint1)"
|
||||
d="m 66.688022,7.9135153 h -8.573986 q -3.000755,0 -3.000755,-2.572126 V 3.1978936 a 3.1280616,3.1280616 0 0 1 0.175245,-1.0930831 q 0.549966,-1.4792191 2.82551,-1.4792191 h 12.432349 v 2.5809064 h -11.58352 q -0.333456,0 -0.40229,0.2702417 a 0.64443619,0.64443619 0 0 0 -0.01774,0.1583869 v 1.2776343 q 0,0.4286286 0.420026,0.4286286 h 8.58259 q 3.000928,0 3.000928,2.572126 v 2.1434957 a 3.1280616,3.1280616 0 0 1 -0.175243,1.09326 q -0.549967,1.479042 -2.825685,1.479042 h -12.43217 v -2.580731 h 11.574741 a 0.61721885,0.61721885 0 0 0 0.169801,-0.02125 q 0.259003,-0.07428 0.259003,-0.4075567 v -1.277454 a 0.61721885,0.61721885 0 0 0 -0.02125,-0.169627 q -0.07428,-0.259178 -0.407558,-0.259178 z M 53.398413,10.065616 v 2.563697 H 40.966062 q -3.000929,0 -3.000929,-2.572302 V 3.1978936 a 3.1280616,3.1280616 0 0 1 0.175419,-1.0930831 q 0.549966,-1.4792191 2.82551,-1.4792191 h 9.43142 q 3.000931,0 3.000931,2.5723022 v 3.0009297 a 3.1303444,3.1303444 0 0 1 -0.176122,1.0955408 q -0.513266,1.3780752 -2.52331,1.4774622 a 6.4805345,6.4805345 0 0 1 -0.301499,0.0077 h -9.002615 l 0.0085,0.857433 q 0,0.4286287 0.4202,0.4372327 z M 24.246719,3.1978936 v 9.4314194 h -3.42956 V 0.6255914 h 12.432352 q 3.000929,0 3.000929,2.5723022 V 12.629313 H 32.820705 V 3.6265222 q 0,-0.4286286 -0.420024,-0.4286286 z m 75.451365,9.4314194 -3.34387,-3.8583647 h -5.23012 V 12.629313 H 87.694359 V 0.6255914 H 100.1267 a 4.3200052,4.3200052 0 0 1 1.18755,0.1489057 q 1.33173,0.3813933 1.68537,1.7406798 a 4.4211483,4.4211483 0 0 1 0.12801,1.1113453 v 2.5723011 a 3.1988267,3.1988267 0 0 1 -0.15154,1.0263568 q -0.33942,1.0033542 -1.43794,1.3564762 a 4.0817218,4.0817218 0 0 1 -0.91417,0.172259 l 3.36108,3.8753977 z M 72.261256,0.5999546 85.979669,0.617163 v 2.5807306 h -5.13583 v 9.4314194 h -3.438161 l -0.0086,-9.4314194 -5.13582,0.00858 z m 32.581244,0.025641 h 3.42956 V 12.629316 H 104.8425 Z M 91.124094,3.2065009 v 2.9923255 h 8.15377 q 0.33365,0 0.4023,-0.2704175 a 0.64443619,0.64443619 0 0 0 0.0179,-0.1583869 V 3.6351296 q 0,-0.4286286 -0.42021,-0.4286286 z m -49.729227,2.9837213 8.145181,0.017211 q 0.343115,-0.00688 0.411597,-0.2772657 a 0.61704326,0.61704326 0 0 0 0.01721,-0.1515397 V 3.6265286 q 0,-0.3334559 -0.270419,-0.4022896 A 0.64443619,0.64443619 0 0 0 49.54005,3.206499 l -7.716551,-0.00858 q -0.42863,0 -0.42863,0.4202002 z"
|
||||
id="path1"
|
||||
style="font-size:12px;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.0438989mm;stroke-linecap:round;stroke-opacity:1" /><path
|
||||
d="M 1.50309,1.4982293 H 15.924554 V 2.2921471 H 1.50309 Z m 0,4.7193972 H 15.924554 V 7.0115422 H 1.50309 Z m 0,4.7193935 h 14.421464 v 0.793917 H 1.50309 Z"
|
||||
id="path1-8"
|
||||
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:2.75789;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:export-filename="logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" /></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="410"
|
||||
height="50"
|
||||
viewBox="0 0 108.47917 13.229167"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="logo-black.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
|
||||
sodipodi:docname="logo.white.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1.9477379"
|
||||
inkscape:cx="243.35923"
|
||||
inkscape:cy="61.353224"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1046"
|
||||
inkscape:window-x="-11"
|
||||
inkscape:window-y="-11"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs1"><linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint1"
|
||||
x1="317.5"
|
||||
x2="314.00699"
|
||||
y1="-51.5"
|
||||
y2="126"
|
||||
gradientTransform="matrix(0.06483182,0,0,0.06483182,7.793483,1.7716596)"><stop
|
||||
stop-color="white"
|
||||
id="stop1" /><stop
|
||||
offset="1"
|
||||
stop-opacity="0"
|
||||
id="stop2" /></linearGradient></defs><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"><path
|
||||
fill="url(#paint1)"
|
||||
pathLength="1"
|
||||
stroke="url(#paint1)"
|
||||
d="m 66.688022,7.9135153 h -8.573986 q -3.000755,0 -3.000755,-2.572126 V 3.1978936 a 3.1280616,3.1280616 0 0 1 0.175245,-1.0930831 q 0.549966,-1.4792191 2.82551,-1.4792191 h 12.432349 v 2.5809064 h -11.58352 q -0.333456,0 -0.40229,0.2702417 a 0.64443619,0.64443619 0 0 0 -0.01774,0.1583869 v 1.2776343 q 0,0.4286286 0.420026,0.4286286 h 8.58259 q 3.000928,0 3.000928,2.572126 v 2.1434957 a 3.1280616,3.1280616 0 0 1 -0.175243,1.09326 q -0.549967,1.479042 -2.825685,1.479042 h -12.43217 v -2.580731 h 11.574741 a 0.61721885,0.61721885 0 0 0 0.169801,-0.02125 q 0.259003,-0.07428 0.259003,-0.4075567 v -1.277454 a 0.61721885,0.61721885 0 0 0 -0.02125,-0.169627 q -0.07428,-0.259178 -0.407558,-0.259178 z M 53.398413,10.065616 v 2.563697 H 40.966062 q -3.000929,0 -3.000929,-2.572302 V 3.1978936 a 3.1280616,3.1280616 0 0 1 0.175419,-1.0930831 q 0.549966,-1.4792191 2.82551,-1.4792191 h 9.43142 q 3.000931,0 3.000931,2.5723022 v 3.0009297 a 3.1303444,3.1303444 0 0 1 -0.176122,1.0955408 q -0.513266,1.3780752 -2.52331,1.4774622 a 6.4805345,6.4805345 0 0 1 -0.301499,0.0077 h -9.002615 l 0.0085,0.857433 q 0,0.4286287 0.4202,0.4372327 z M 24.246719,3.1978936 v 9.4314194 h -3.42956 V 0.6255914 h 12.432352 q 3.000929,0 3.000929,2.5723022 V 12.629313 H 32.820705 V 3.6265222 q 0,-0.4286286 -0.420024,-0.4286286 z m 75.451365,9.4314194 -3.34387,-3.8583647 h -5.23012 V 12.629313 H 87.694359 V 0.6255914 H 100.1267 a 4.3200052,4.3200052 0 0 1 1.18755,0.1489057 q 1.33173,0.3813933 1.68537,1.7406798 a 4.4211483,4.4211483 0 0 1 0.12801,1.1113453 v 2.5723011 a 3.1988267,3.1988267 0 0 1 -0.15154,1.0263568 q -0.33942,1.0033542 -1.43794,1.3564762 a 4.0817218,4.0817218 0 0 1 -0.91417,0.172259 l 3.36108,3.8753977 z M 72.261256,0.5999546 85.979669,0.617163 v 2.5807306 h -5.13583 v 9.4314194 h -3.438161 l -0.0086,-9.4314194 -5.13582,0.00858 z m 32.581244,0.025641 h 3.42956 V 12.629316 H 104.8425 Z M 91.124094,3.2065009 v 2.9923255 h 8.15377 q 0.33365,0 0.4023,-0.2704175 a 0.64443619,0.64443619 0 0 0 0.0179,-0.1583869 V 3.6351296 q 0,-0.4286286 -0.42021,-0.4286286 z m -49.729227,2.9837213 8.145181,0.017211 q 0.343115,-0.00688 0.411597,-0.2772657 a 0.61704326,0.61704326 0 0 0 0.01721,-0.1515397 V 3.6265286 q 0,-0.3334559 -0.270419,-0.4022896 A 0.64443619,0.64443619 0 0 0 49.54005,3.206499 l -7.716551,-0.00858 q -0.42863,0 -0.42863,0.4202002 z"
|
||||
id="path1"
|
||||
style="font-size:12px;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:0.0438989mm;stroke-linecap:round;stroke-opacity:1" /><path
|
||||
d="M 1.50309,1.4982293 H 15.924554 V 2.2921471 H 1.50309 Z m 0,4.7193972 H 15.924554 V 7.0115422 H 1.50309 Z m 0,4.7193935 h 14.421464 v 0.793917 H 1.50309 Z"
|
||||
id="path1-8"
|
||||
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:2.75789;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:export-filename="logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" /></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -27,7 +27,7 @@
|
||||
"@macaron-css/solid": "1.5.3",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@nestri/core": "*",
|
||||
"@nestri/functions": "*",
|
||||
"@nestri/cloud-functions": "*",
|
||||
"@nestri/zero": "*",
|
||||
"@openauthjs/openauth": "*",
|
||||
"@openauthjs/solid": "0.0.0-20250311201457",
|
||||
@@ -47,4 +47,4 @@
|
||||
"valibot": "^1.0.0-rc.3",
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { hc } from "hono/client";
|
||||
import { useSteam } from "./context";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { type app } from "@nestri/functions/api/index";
|
||||
import { type app } from "@nestri/cloud-functions/api/index";
|
||||
import { createInitializedContext } from "@nestri/www/common/context";
|
||||
|
||||
|
||||
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||
"ApiContext",
|
||||
() => {
|
||||
const steam = useSteam();
|
||||
const auth = useOpenAuth();
|
||||
"ApiContext",
|
||||
() => {
|
||||
const steam = useSteam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
|
||||
async fetch(...args: Parameters<typeof fetch>): Promise<Response> {
|
||||
const [input, init] = args;
|
||||
const request =
|
||||
input instanceof Request ? input : new Request(input, init);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("authorization", `Bearer ${await auth.access()}`);
|
||||
headers.set("x-nestri-steam", steam().id);
|
||||
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
|
||||
async fetch(...args: Parameters<typeof fetch>): Promise<Response> {
|
||||
const [input, init] = args;
|
||||
const request =
|
||||
input instanceof Request ? input : new Request(input, init);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("authorization", `Bearer ${await auth.access()}`);
|
||||
headers.set("x-nestri-steam", steam().id);
|
||||
|
||||
return fetch(
|
||||
new Request(request, {
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
return {
|
||||
client,
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
return fetch(
|
||||
new Request(request, {
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
return {
|
||||
client,
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
47
sst-env.d.ts
vendored
47
sst-env.d.ts
vendored
@@ -1,47 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"DISCORD_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"host": string
|
||||
"name": string
|
||||
"password": string
|
||||
"type": "sst.sst.Linkable"
|
||||
"user": string
|
||||
}
|
||||
"POLAR_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Urls": {
|
||||
"api": string
|
||||
"auth": string
|
||||
"openapi": string
|
||||
"site": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Auth": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,26 +0,0 @@
|
||||
/// <reference path="./.sst/platform/config.d.ts" />
|
||||
export default $config({
|
||||
app(input) {
|
||||
return {
|
||||
name: "nestri",
|
||||
removal: input?.stage === "production" ? "retain" : "remove",
|
||||
protect: ["production"].includes(input?.stage),
|
||||
home: "cloudflare",
|
||||
providers: {
|
||||
cloudflare: "6.6.0",
|
||||
random: "4.17.0",
|
||||
command: "1.0.2",
|
||||
neon: "0.9.0",
|
||||
},
|
||||
};
|
||||
},
|
||||
async run() {
|
||||
const fs = await import("fs");
|
||||
const outputs = {};
|
||||
for (const value of fs.readdirSync("./cloud/infra/")) {
|
||||
const result = await import("./cloud/infra/" + value);
|
||||
if (result.outputs) Object.assign(outputs, result.outputs);
|
||||
}
|
||||
return outputs;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user