├── .npmrc ├── packages ├── gemi │ ├── http │ │ ├── Cookie.ts │ │ ├── Policy.ts │ │ ├── BroadcastingRouter.ts │ │ ├── Error.ts │ │ ├── MiddlewareServiceProvider.ts │ │ ├── getCookies.ts │ │ ├── PoliciesServiceProvider.ts │ │ ├── Middleware.ts │ │ ├── Controller.ts │ │ ├── CacheMiddleware.ts │ │ ├── CSRFMiddleware.ts │ │ ├── index.ts │ │ ├── Router.ts │ │ ├── RateLimitMiddleware.ts │ │ ├── AuthenticationMiddlware.ts │ │ ├── CorsMiddleware.ts │ │ ├── errors.ts │ │ └── Metadata.ts │ ├── services │ │ ├── rate-limiter │ │ │ ├── types.ts │ │ │ ├── drivers │ │ │ │ ├── RateLimiterDriver.ts │ │ │ │ └── InMemoryRateLimiterDriver.ts │ │ │ ├── RateLimiterServiceContainer.ts │ │ │ └── RateLimiterServiceProvider.ts │ │ ├── ServiceProvider.ts │ │ ├── pubsub │ │ │ ├── types.ts │ │ │ └── BroadcastingServiceProvider.ts │ │ ├── email │ │ │ ├── drivers │ │ │ │ ├── EmailDriver.ts │ │ │ │ ├── types.ts │ │ │ │ └── ResendDriver.ts │ │ │ ├── EmailServiceContainer.ts │ │ │ └── EmailServiceProvider.ts │ │ ├── kernel-id │ │ │ ├── KernelIdServiceProvider.ts │ │ │ └── KernelIdServiceContainer.ts │ │ ├── image-optimization │ │ │ ├── drivers │ │ │ │ ├── ImageOptimizationDriver.ts │ │ │ │ ├── types.ts │ │ │ │ └── SharpDriver.ts │ │ │ └── ImageOptimizationServiceProvider.ts │ │ ├── queue │ │ │ ├── QueueServiceProvider.ts │ │ │ ├── queueWorker.ts │ │ │ └── Job.ts │ │ ├── logging │ │ │ ├── types.ts │ │ │ ├── LoggingServiceProvider.ts │ │ │ └── LoggingRouter.ts │ │ ├── cron │ │ │ ├── CronJob.ts │ │ │ ├── CronServiceProvider.ts │ │ │ └── CronServiceContainer.ts │ │ ├── file-storage │ │ │ ├── FileStorageServiceProvider.ts │ │ │ ├── FileStorageServiceContainer.ts │ │ │ └── drivers │ │ │ │ ├── FileStorageDriver.ts │ │ │ │ ├── types.ts │ │ │ │ └── FileSystemDriver.ts │ │ ├── router │ │ │ ├── ApiRouterServiceProvider.ts │ │ │ ├── ViewRouterServiceProvider.ts │ │ │ ├── createComponentTree.ts │ │ │ ├── createRouteManifest.ts │ │ │ └── createFlatViewRoutes.test.ts │ │ ├── ServiceContainer.ts │ │ ├── middleware │ │ │ └── MiddlewareServiceContainer.ts │ │ └── index.ts │ ├── email │ │ └── index.ts │ ├── client │ │ ├── .env.d.ts │ │ ├── auth │ │ │ ├── useSignUp.ts │ │ │ ├── useResetPassword.ts │ │ │ ├── useForgotPassword.ts │ │ │ ├── useSignIn.tsx │ │ │ ├── useSignOut.ts │ │ │ └── useUser.ts │ │ ├── useParams.ts │ │ ├── rpc.ts │ │ ├── isPlainObject.ts │ │ ├── useRouteData.ts │ │ ├── helpers │ │ │ └── flattenComponentTree.ts │ │ ├── useAppIdMissmatch.ts │ │ ├── useRoute.ts │ │ ├── useLocation.ts │ │ ├── useIsNavigationPending.ts │ │ ├── useBroadcast.ts │ │ ├── createRoot.tsx │ │ ├── useNavigationProgress.ts │ │ ├── OpenGraphImage.tsx │ │ ├── HttpClientContext.tsx │ │ ├── Redirect.tsx │ │ ├── useBreadcrumbs.ts │ │ ├── useSubscription.ts │ │ ├── useForm.tsx │ │ ├── RouteStateContext.tsx │ │ ├── QueryManagerContext.tsx │ │ ├── useLocale.ts │ │ ├── ComponentContext.tsx │ │ ├── RouteTransitionProvider.tsx │ │ ├── HttpReload.tsx │ │ ├── useTranslator.ts │ │ ├── QueryStore.ts │ │ ├── ServerDataProvider.tsx │ │ ├── Image.tsx │ │ ├── ThemeProvider.tsx │ │ ├── index.ts │ │ ├── types.ts │ │ └── WebsocketContext.tsx │ ├── broadcasting │ │ ├── index.ts │ │ └── BroadcastingChannel.ts │ ├── app │ │ ├── index.ts │ │ ├── __snapshots__ │ │ │ └── App.test.ts.snap │ │ ├── prismaExtension.ts │ │ ├── createComponentTree.ts │ │ └── createRouteManifest.ts │ ├── server │ │ ├── index.ts │ │ ├── createProdServer.ts │ │ ├── generateEtag.ts │ │ ├── createServer.ts │ │ ├── Server.ts │ │ ├── renderErrorPage.ts │ │ └── styles.tsx │ ├── i18n │ │ ├── index.ts │ │ ├── I18nServiceProvider.ts │ │ ├── Translation.tsx │ │ ├── I18nRouter.ts │ │ ├── I18nServiceContainer.ts │ │ └── Dictionary.ts │ ├── utils │ │ ├── sleep.ts │ │ ├── toCamelCase.ts │ │ ├── omitNullishValues.ts │ │ ├── debounce.ts │ │ ├── Subject.ts │ │ ├── autobind.ts │ │ └── applyParams.ts │ ├── kernel │ │ ├── context.ts │ │ ├── index.ts │ │ └── KernelContext.ts │ ├── internal │ │ ├── isConstructor.ts │ │ └── type-utils.ts │ ├── vite │ │ ├── test │ │ │ ├── test.ts │ │ │ ├── Controller.ts │ │ │ ├── OrgController.ts │ │ │ └── ApiRouter.ts │ │ ├── input.ts │ │ └── index.ts │ ├── tsconfig.lint.json │ ├── rfc │ │ ├── broadcasting.ts │ │ ├── feature-toggle.ts │ │ ├── Router.ts │ │ ├── orm.ts │ │ ├── i18n.ts │ │ └── workflow.ts │ ├── .eslintrc.js │ ├── runtime │ │ └── index.ts │ ├── auth │ │ ├── oauth │ │ │ └── OAuthProvider.ts │ │ └── adapters │ │ │ └── blank.ts │ ├── facades │ │ ├── index.ts │ │ ├── Meta.ts │ │ ├── Cookie.ts │ │ ├── Url.ts │ │ ├── Broadcast.ts │ │ ├── I18n.ts │ │ ├── FileStorage.ts │ │ ├── Log.ts │ │ └── Redirect.ts │ ├── vite.plugin.config.mts │ ├── scripts │ │ ├── prepare-bin.ts │ │ ├── setupExports.ts │ │ └── build.ts │ ├── tsconfig.json │ ├── vite.client.config.mts │ ├── tsconfig.browser.json │ ├── bin │ │ ├── createRollupInput.ts │ │ └── gemi.ts │ ├── exports.json │ └── ide │ │ └── emacs │ │ └── gemi.el ├── eslint-config │ ├── README.md │ ├── package.json │ ├── library.js │ ├── next.js │ └── react-internal.js ├── typescript-config │ ├── package.json │ ├── react-library.json │ ├── nextjs.json │ └── base.json └── create-gemi-app │ ├── .gitignore │ ├── tsconfig.json │ ├── scripts │ └── prepare-bin.ts │ └── package.json ├── templates └── saas-starter │ ├── public │ ├── .well-known │ │ └── test │ ├── favicon.ico │ ├── GeistMonoVF.ttf │ ├── logo.svg │ └── gemi.svg │ ├── prisma │ ├── dev.db │ └── migrations │ │ └── migration_lock.toml │ ├── app │ ├── views │ │ ├── 404.tsx │ │ ├── auth │ │ │ ├── ResetPassword.tsx │ │ │ ├── ForgotPassword.tsx │ │ │ ├── MagicLinkSignIn.tsx │ │ │ ├── OauthCallback.tsx │ │ │ ├── SignIn.tsx │ │ │ └── SignUp.tsx │ │ ├── Dashboard.tsx │ │ ├── Test.tsx │ │ ├── components │ │ │ ├── lib │ │ │ │ └── utils.ts │ │ │ ├── ui │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ └── button.tsx │ │ │ ├── hooks │ │ │ │ └── use-mobile.tsx │ │ │ ├── FormField.tsx │ │ │ └── AppSidebar.tsx │ │ ├── Pricing.tsx │ │ ├── _Redirect.tsx │ │ ├── RootLayout.tsx │ │ ├── AppLayout.tsx │ │ ├── Inbox.tsx │ │ ├── About.tsx │ │ └── Home.tsx │ ├── bootstrap.ts │ ├── kernel │ │ ├── providers │ │ │ ├── QueueServiceProvider.ts │ │ │ ├── EmailServiceProvider.ts │ │ │ ├── ApiRouterServiceProvider.ts │ │ │ ├── FileStorageServiceProvider.ts │ │ │ ├── I18nServiceProvider.ts │ │ │ ├── ViewRouterServiceProvider.ts │ │ │ ├── CronServiceProvider.ts │ │ │ ├── MiddlewareServiceProvider.ts │ │ │ ├── LoggingServiceProvider.ts │ │ │ └── AuthenticationServiceProvider.ts │ │ └── Kernel.ts │ ├── http │ │ ├── controllers │ │ │ └── HomeController.ts │ │ └── routes │ │ │ ├── api.ts │ │ │ └── view.ts │ ├── client.tsx │ ├── database │ │ └── prisma.ts │ └── i18n │ │ └── index.ts │ ├── postcss.config.js │ ├── .gitignore │ ├── vite.config.mjs │ ├── README.md │ ├── .gemi │ └── rollupInput.json │ ├── .env.example │ ├── gemi.d.ts │ ├── components.json │ ├── tsconfig.json │ ├── Dockerfile │ ├── package.json │ └── tailwind.config.js ├── tsconfig.json ├── docs ├── ViewRoutes_.md ├── Email.md ├── Authorization.md ├── Navigation.md └── Day 1.md ├── .eslintrc.js ├── turbo.json ├── package.json ├── .gitignore ├── biome.json └── LICENCE /.npmrc: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/gemi/http/Cookie.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/gemi/services/rate-limiter/types.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/saas-starter/public/.well-known/test: -------------------------------------------------------------------------------- 1 | Hello there! 2 | -------------------------------------------------------------------------------- /packages/gemi/email/index.ts: -------------------------------------------------------------------------------- 1 | export { Email } from "./Email"; 2 | -------------------------------------------------------------------------------- /packages/gemi/client/.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/base.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/gemi/broadcasting/index.ts: -------------------------------------------------------------------------------- 1 | export { BroadcastingChannel } from "./BroadcastingChannel"; 2 | -------------------------------------------------------------------------------- /packages/eslint-config/README.md: -------------------------------------------------------------------------------- 1 | # `@turbo/eslint-config` 2 | 3 | Collection of internal eslint configurations. 4 | -------------------------------------------------------------------------------- /packages/gemi/app/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from "./App"; 2 | export { prismaExtension } from "./prismaExtension"; 3 | -------------------------------------------------------------------------------- /packages/gemi/server/index.ts: -------------------------------------------------------------------------------- 1 | export { startDevServer } from "./dev"; 2 | export { startProdServer } from "./prod"; 3 | -------------------------------------------------------------------------------- /templates/saas-starter/prisma/dev.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nstfkc/gemi/HEAD/templates/saas-starter/prisma/dev.db -------------------------------------------------------------------------------- /templates/saas-starter/app/views/404.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return
Not found anywhere
; 3 | } 4 | -------------------------------------------------------------------------------- /templates/saas-starter/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nstfkc/gemi/HEAD/templates/saas-starter/public/favicon.ico -------------------------------------------------------------------------------- /packages/gemi/server/createProdServer.ts: -------------------------------------------------------------------------------- 1 | export async function createProdServer() { 2 | console.log("Not implemented yet."); 3 | } 4 | -------------------------------------------------------------------------------- /templates/saas-starter/public/GeistMonoVF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nstfkc/gemi/HEAD/templates/saas-starter/public/GeistMonoVF.ttf -------------------------------------------------------------------------------- /packages/gemi/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export { I18nServiceProvider } from "./I18nServiceProvider"; 2 | export { Dictionary } from "./Dictionary"; 3 | -------------------------------------------------------------------------------- /packages/gemi/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(time: number) { 2 | return new Promise((resolve) => setTimeout(resolve, time)); 3 | } 4 | -------------------------------------------------------------------------------- /packages/gemi/kernel/context.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "async_hooks"; 2 | 3 | export const kernelContext = new AsyncLocalStorage(); 4 | -------------------------------------------------------------------------------- /packages/gemi/services/ServiceProvider.ts: -------------------------------------------------------------------------------- 1 | export abstract class ServiceProvider { 2 | abstract boot(...p: any[]): Promise | any; 3 | } 4 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/auth/ResetPassword.tsx: -------------------------------------------------------------------------------- 1 | export default function ResetPassword() { 2 | return
Reset password
; 3 | } 4 | -------------------------------------------------------------------------------- /templates/saas-starter/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/auth/ForgotPassword.tsx: -------------------------------------------------------------------------------- 1 | export default function ForgotPassword() { 2 | return
Forgot password
; 3 | } 4 | -------------------------------------------------------------------------------- /packages/gemi/utils/toCamelCase.ts: -------------------------------------------------------------------------------- 1 | export function toCamelCase(input: string) { 2 | return input.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); 3 | } 4 | -------------------------------------------------------------------------------- /packages/gemi/http/Policy.ts: -------------------------------------------------------------------------------- 1 | export class Policies { 2 | all(_operation: string, _args: any): Promise | boolean { 3 | return true; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/gemi/client/auth/useSignUp.ts: -------------------------------------------------------------------------------- 1 | import { usePost } from "../useMutation"; 2 | 3 | export function useSignUp() { 4 | return usePost("/auth/sign-up"); 5 | } 6 | -------------------------------------------------------------------------------- /packages/gemi/internal/isConstructor.ts: -------------------------------------------------------------------------------- 1 | export function isConstructor(value: any) { 2 | return typeof value === "function" && value.prototype !== undefined; 3 | } 4 | -------------------------------------------------------------------------------- /packages/gemi/services/rate-limiter/drivers/RateLimiterDriver.ts: -------------------------------------------------------------------------------- 1 | export abstract class RateLimiterDriver { 2 | abstract consume(userId: string, requestPath: string): number; 3 | } 4 | -------------------------------------------------------------------------------- /templates/saas-starter/app/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { App } from "gemi/app"; 2 | import Kernel from "./kernel/Kernel"; 3 | 4 | export const app = new App({ 5 | kernel: Kernel, 6 | }); 7 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/providers/QueueServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { QueueServiceProvider } from "gemi/services"; 2 | 3 | export default class extends QueueServiceProvider {} 4 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | export default function Dashboard() { 2 | return ( 3 |
4 |

Dashboard

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /templates/saas-starter/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /packages/gemi/services/pubsub/types.ts: -------------------------------------------------------------------------------- 1 | export type PublishArgs = [ 2 | topic: string, 3 | data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer, 4 | compress?: boolean, 5 | ]; 6 | -------------------------------------------------------------------------------- /templates/saas-starter/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | *.tern-port 7 | node_modules/ 8 | *.tsbuildinfo 9 | .npm 10 | .eslintcache 11 | 12 | .gemi 13 | .env 14 | .debug 15 | -------------------------------------------------------------------------------- /templates/saas-starter/app/http/controllers/HomeController.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "gemi/http"; 2 | 3 | export class HomeController extends Controller { 4 | async index() { 5 | return {}; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/gemi/services/email/drivers/EmailDriver.ts: -------------------------------------------------------------------------------- 1 | import type { SendEmailParams } from "./types"; 2 | 3 | export abstract class EmailDriver { 4 | abstract send(params: SendEmailParams): Promise | boolean; 5 | } 6 | -------------------------------------------------------------------------------- /packages/gemi/vite/test/test.ts: -------------------------------------------------------------------------------- 1 | import { customRequestParser } from "../customRequestParser"; 2 | 3 | const orgFile = await Bun.file(__dirname + "/ApiRouter.ts").text(); 4 | 5 | console.log(await customRequestParser(orgFile)); 6 | -------------------------------------------------------------------------------- /packages/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/typescript-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/Test.tsx: -------------------------------------------------------------------------------- 1 | import { useLocale, useQuery, type ViewProps } from "gemi/client"; 2 | 3 | export default function Test(props: ViewProps<"/test/:testId">) { 4 | return
{props.message}
; 5 | } 6 | -------------------------------------------------------------------------------- /packages/gemi/kernel/index.ts: -------------------------------------------------------------------------------- 1 | export { Kernel } from "./Kernel"; 2 | 3 | export { AuthenticationServiceProvider } from "../auth/AuthenticationServiceProvider"; 4 | export { PrismaAuthenticationAdapter } from "../auth/adapters/prisma"; 5 | -------------------------------------------------------------------------------- /packages/gemi/services/kernel-id/KernelIdServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { ServiceProvider } from "../ServiceProvider"; 2 | 3 | export class KernelIdServiceProvider extends ServiceProvider { 4 | id = crypto.randomUUID(); 5 | boot() {} 6 | } 7 | -------------------------------------------------------------------------------- /packages/typescript-config/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/providers/EmailServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { EmailServiceProvider, ResendDriver } from "gemi/services"; 2 | 3 | export default class extends EmailServiceProvider { 4 | driver = new ResendDriver(); 5 | } 6 | -------------------------------------------------------------------------------- /packages/gemi/tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/react-library.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src", "turbo"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /templates/saas-starter/vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import gemi from "gemi/vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [react(), gemi()], 7 | }); 8 | -------------------------------------------------------------------------------- /packages/create-gemi-app/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | *.tern-port 7 | node_modules/ 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | *.tsbuildinfo 12 | .npm 13 | .eslintcache 14 | 15 | bin 16 | -------------------------------------------------------------------------------- /packages/gemi/broadcasting/BroadcastingChannel.ts: -------------------------------------------------------------------------------- 1 | export class BroadcastingChannel { 2 | async subscribe(user: any): Promise { 3 | return true; 4 | } 5 | 6 | publish(input: any): any { 7 | return {}; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/gemi/kernel/KernelContext.ts: -------------------------------------------------------------------------------- 1 | import { ServiceContainer } from "../services/ServiceContainer"; 2 | import { kernelContext } from "./context"; 3 | 4 | export class KernelContext { 5 | static getStore = () => kernelContext.getStore(); 6 | } 7 | -------------------------------------------------------------------------------- /packages/gemi/client/useParams.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { RouteStateContext } from "./RouteStateContext"; 3 | 4 | export function useParams() { 5 | const { params = {} } = useContext(RouteStateContext); 6 | return params; 7 | } 8 | -------------------------------------------------------------------------------- /packages/gemi/internal/type-utils.ts: -------------------------------------------------------------------------------- 1 | export type KeyAndValue = { 2 | key: K; 3 | value: V; 4 | }; 5 | 6 | export type KeyAndValueToObject> = { 7 | [T in TUnion as T["key"]]: T["value"]; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/gemi/utils/omitNullishValues.ts: -------------------------------------------------------------------------------- 1 | export function omitNullishValues(input: T) { 2 | return Object.fromEntries( 3 | Object.entries(input).filter(([, value]) => { 4 | return value !== null && value !== undefined; 5 | }), 6 | ) as T; 7 | } 8 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/Pricing.tsx: -------------------------------------------------------------------------------- 1 | import type { ViewProps } from "gemi/client"; 2 | 3 | export default function Pricing(props: ViewProps<"/pricing">) { 4 | return ( 5 |
6 |

{props.title}

7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/providers/ApiRouterServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { ApiRouterServiceProvider } from "gemi/services"; 2 | import RootApiRouter from "@/app/http/routes/api"; 3 | 4 | export default class extends ApiRouterServiceProvider { 5 | rootRouter = RootApiRouter; 6 | } 7 | -------------------------------------------------------------------------------- /packages/gemi/vite/test/Controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, HttpRequest } from "gemi/http"; 2 | 3 | class CustomReq extends HttpRequest {} 4 | 5 | export class HomeController extends Controller { 6 | public async foo(req: CustomReq) { 7 | return "Hello world"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/auth/MagicLinkSignIn.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from "gemi/client"; 2 | 3 | export default function MagicLinkSignIn({ session }) { 4 | if (session) { 5 | return ; 6 | } 7 | return
Error
; 8 | } 9 | -------------------------------------------------------------------------------- /packages/gemi/services/image-optimization/drivers/ImageOptimizationDriver.ts: -------------------------------------------------------------------------------- 1 | import type { ResizeParameters } from "./types"; 2 | 3 | export abstract class ImageOptimizationDriver { 4 | abstract resize( 5 | buffer: Buffer, 6 | parameters: ResizeParameters, 7 | ): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/providers/FileStorageServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileStorageServiceProvider, 3 | FileSystemDriver, 4 | S3Driver, 5 | } from "gemi/services"; 6 | 7 | export default class extends FileStorageServiceProvider { 8 | driver = new S3Driver(); 9 | } 10 | -------------------------------------------------------------------------------- /packages/gemi/rfc/broadcasting.ts: -------------------------------------------------------------------------------- 1 | class BroadcastingChannel { 2 | onSubscribe() {} 3 | 4 | message() { 5 | return {}; 6 | } 7 | } 8 | 9 | class FooChannel extends BroadcastingChannel {} 10 | 11 | class BroadcastingService { 12 | channels = { 13 | FooChannel, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/_Redirect.tsx: -------------------------------------------------------------------------------- 1 | export default function _Redirect({ destination } = { destination: "/" }) { 2 | return ( 3 | "`; 4 | -------------------------------------------------------------------------------- /packages/gemi/client/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentProps, useEffect } from "react"; 2 | import type { Link } from "./Link"; 3 | 4 | import { useNavigate } from "./useNavigate"; 5 | 6 | export const Redirect = ( 7 | props: ComponentProps & { action: "push" | "replace" }, 8 | ) => { 9 | const { href, params = {}, search = {}, action } = props; 10 | const { push, replace } = useNavigate(); 11 | 12 | useEffect(() => { 13 | if (action === "push") { 14 | push(href, { params, search } as any); 15 | } else { 16 | replace(href, { params, search } as any); 17 | } 18 | }, [replace, action, push, params, search, href]); 19 | 20 | return null; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/gemi/facades/Url.ts: -------------------------------------------------------------------------------- 1 | import type { UrlParser, ViewPaths } from "../client/types"; 2 | import { applyParams } from "../utils/applyParams"; 3 | 4 | export class Url { 5 | static absolute( 6 | key: T, 7 | ...args: UrlParser extends Record 8 | ? [] 9 | : [params: UrlParser] 10 | ) { 11 | return `${process.env.HOST_NAME}${applyParams(String(key), args[0] ?? {})}`; 12 | } 13 | 14 | static relative( 15 | key: T, 16 | ...args: UrlParser extends Record 17 | ? [] 18 | : [params: UrlParser] 19 | ) { 20 | return applyParams(String(key), args[0] ?? {}); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/providers/MiddlewareServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MiddlewareServiceProvider, 3 | AuthenticationMiddleware, 4 | RateLimitMiddleware, 5 | CacheMiddleware, 6 | CSRFMiddleware, 7 | CorsMiddleware, 8 | } from "gemi/http"; 9 | 10 | export default class extends MiddlewareServiceProvider { 11 | aliases = { 12 | auth: AuthenticationMiddleware, 13 | cache: CacheMiddleware, 14 | "rate-limit": RateLimitMiddleware, 15 | csrf: CSRFMiddleware, 16 | cors: CorsMiddleware.configure({ 17 | origins: { 18 | "http://localhost:3000": { 19 | "Access-Control-Allow-Methods": "GET, POST", 20 | }, 21 | }, 22 | }), 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /docs/Day 1.md: -------------------------------------------------------------------------------- 1 | 2 | When you create a fresh project, you will see a lot folders and files in your project. This might feel a little bit too much, the easiest way to get over it is to start with small changes and build on top of that. Over time everything will make more sense. 3 | 4 | ## Creating your first page 5 | 6 | Gemi uses a declarative routing system where you define your routes with code instead of a file-system routing most popular frameworks like next.js use. 7 | 8 | And the routes that render your page and api routes are defined separately `view.ts` and `api.ts` files where you defined them respectively. Both files are located in `/app/http/routes` directory. 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/gemi/services/email/drivers/ResendDriver.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | import type { SendEmailParams } from "./types"; 3 | import { EmailDriver } from "./EmailDriver"; 4 | 5 | export class ResendDriver extends EmailDriver { 6 | constructor(private apiKey = process.env.RESEND_API_KEY) { 7 | super(); 8 | } 9 | 10 | async send(params: SendEmailParams) { 11 | const resend = new Resend(this.apiKey); 12 | const { data, error } = await resend.emails.send({ 13 | ...params, 14 | }); 15 | 16 | if (error) { 17 | console.error(error); 18 | return false; 19 | } 20 | 21 | if (data) { 22 | return true; 23 | } 24 | 25 | return false; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/About.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useLocale, 3 | useQuery, 4 | useTranslator, 5 | type ViewProps, 6 | } from "gemi/client"; 7 | 8 | export default function About(props: ViewProps<"/about">) { 9 | const x = useTranslator("About"); 10 | const [locale] = useLocale(); 11 | const { data } = useQuery("/test", { 12 | search: { locale }, 13 | }); 14 | return ( 15 |
16 |

17 | {props.title} {data?.message ?? "Loading..."} 18 |

19 |
{x.jsx("title", { version: (v) => {v} })}
20 |
21 |

{x.jsx("para", { break: () =>
})}

22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/create-gemi-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-gemi-app", 3 | "description": "Create gemi app with one command", 4 | "version": "0.3.5", 5 | "author": "Enes Tufekci ", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "bun build --external=bun --target=bun --outfile=bin/index.js index.ts && bun ./scripts/prepare-bin.ts", 9 | "prepublishOnly": "bun run build" 10 | }, 11 | "files": ["bin"], 12 | "bin": { 13 | "create-gemi-app": "./bin/index.js" 14 | }, 15 | "dependencies": { 16 | "commander": "^12.1.0", 17 | "prompts": "^2.4.2", 18 | "tar": "^7.4.0" 19 | }, 20 | "devDependencies": { 21 | "@types/bun": "^1.1.4", 22 | "@types/prompts": "^2.4.9" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/FormField.tsx: -------------------------------------------------------------------------------- 1 | import { ValidationErrors } from "gemi/client"; 2 | import type { ReactNode } from "react"; 3 | 4 | interface FormFieldProps { 5 | children: ReactNode; 6 | name: string; 7 | label: string; 8 | } 9 | 10 | export const FormField = (props: FormFieldProps) => { 11 | const { name, label, children } = props; 12 | return ( 13 |
14 | 20 | {children} 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /templates/saas-starter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext", "DOM"], 4 | "target": "ESNext", 5 | "module": "esnext", 6 | "moduleDetection": "force", 7 | "jsx": "react-jsx", 8 | "allowJs": true, 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "verbatimModuleSyntax": true, 12 | "noEmit": true, 13 | "strict": true, 14 | "skipLibCheck": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noPropertyAccessFromIndexSignature": false, 19 | "paths": { 20 | "@/app/*": ["./app/*"] 21 | }, 22 | "types": ["vite/client", "bun", "./gemi.d.ts"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/gemi/services/queue/Job.ts: -------------------------------------------------------------------------------- 1 | import { QueueServiceContainer } from "./QueueServiceContainer"; 2 | 3 | export class Job { 4 | static name = "unset"; 5 | worker = false; 6 | maxAttempts = 3; 7 | 8 | run(..._args: any[]): Promise | any {} 9 | 10 | onFail(_error: Error, ..._args: any[]): void {} 11 | onSuccess(_result: any, ..._args: any[]): void {} 12 | onDeadletter(_error: Error, ..._args: any[]): void {} 13 | 14 | static dispatch( 15 | this: new () => T, 16 | ...args: Parameters 17 | ): void { 18 | if (this.name === "unset") { 19 | throw new Error("Cannot dispatch a job with no name"); 20 | } 21 | 22 | QueueServiceContainer.use().push(this, JSON.stringify(args)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/eslint-config/library.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], 8 | plugins: ["only-warn"], 9 | globals: { 10 | React: true, 11 | JSX: true, 12 | }, 13 | env: { 14 | node: true, 15 | }, 16 | settings: { 17 | "import/resolver": { 18 | typescript: { 19 | project, 20 | }, 21 | }, 22 | }, 23 | ignorePatterns: [ 24 | // Ignore dotfiles 25 | ".*.js", 26 | "node_modules/", 27 | "dist/", 28 | ], 29 | overrides: [ 30 | { 31 | files: ["*.js?(x)", "*.ts?(x)"], 32 | }, 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /packages/gemi/services/pubsub/BroadcastingServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { ServiceProvider } from "../ServiceProvider"; 2 | import { BroadcastingChannel } from "../../broadcasting/BroadcastingChannel"; 3 | import type { UrlParser } from "../../client/types"; 4 | 5 | type Channels = Record BroadcastingChannel>; 6 | 7 | export class BroadcastingServiceProvider extends ServiceProvider { 8 | channels: Record BroadcastingChannel> = {}; 9 | 10 | boot() {} 11 | } 12 | 13 | type Parser = { 14 | [K in keyof T]: InstanceType["publish"] extends (p: infer P) => infer O 15 | ? { params: UrlParser<`${K & string}`>; input: P; output: O } 16 | : never; 17 | }; 18 | 19 | type X = Parser["channels"]>; 20 | -------------------------------------------------------------------------------- /packages/gemi/tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext", "DOM"], 4 | "target": "ESNext", 5 | "module": "esnext", 6 | "moduleDetection": "force", 7 | "jsx": "react-jsx", 8 | "allowJs": true, 9 | 10 | "moduleResolution": "node", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "emitDeclarationOnly": true, 14 | "declaration": true, 15 | "declarationMap": true, 16 | 17 | "strict": false, 18 | "skipLibCheck": true, 19 | "noFallthroughCasesInSwitch": true, 20 | 21 | "noUnusedLocals": false, 22 | "noUnusedParameters": false, 23 | "noPropertyAccessFromIndexSignature": false, 24 | "types": ["vite/client", "bun"], 25 | "outDir": "dist" 26 | }, 27 | "include": ["client"] 28 | } 29 | -------------------------------------------------------------------------------- /templates/saas-starter/app/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from "gemi/i18n"; 2 | 3 | const HomePage = Dictionary.create("HomePage", { 4 | title: { 5 | "en-US": "Welcome to Gemi {{version}}", 6 | "tr-TR": "Gemi'ye Hoş Geldiniz {{version}}", 7 | }, 8 | description: { 9 | "en-US": "A simple and fast framework for building web applications.", 10 | "tr-TR": "Web uygulamaları oluşturmak için basit ve hızlı bir çerçeve.", 11 | }, 12 | }); 13 | 14 | const About = Dictionary.create("About", { 15 | title: { 16 | "en-US": "About {{version:[hi]}}", 17 | "tr-TR": "Hakkında {{version:[hi]}}", 18 | }, 19 | para: { 20 | "en-US": "You are! {{break}} hello there", 21 | "tr-TR": "You are! {{break}} hello there", 22 | }, 23 | }); 24 | 25 | export default { HomePage, About }; 26 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false 10 | }, 11 | "formatter": { 12 | "enabled": true, 13 | "indentStyle": "space", 14 | "indentWidth": 2 15 | }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "recommended": true, 20 | "nursery": { 21 | "useExplicitType": "off" 22 | }, 23 | "suspicious": { 24 | "noExplicitAny": "off" 25 | }, 26 | "security": { 27 | "noDangerouslySetInnerHTML": "off" 28 | } 29 | } 30 | }, 31 | "javascript": { 32 | "formatter": { 33 | "quoteStyle": "double" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/gemi/services/rate-limiter/drivers/InMemoryRateLimiterDriver.ts: -------------------------------------------------------------------------------- 1 | import { RateLimiterDriver } from "./RateLimiterDriver"; 2 | 3 | export class InMemoryRateLimiter extends RateLimiterDriver { 4 | requests: Map = new Map(); 5 | 6 | consume(id: string, path: string) { 7 | if (!this.requests.has(id)) { 8 | this.requests.set(id, { date: Date.now(), count: 1 }); 9 | } else { 10 | const record = this.requests.get(id); 11 | const now = Date.now(); 12 | if (now - record.date >= 1000 * 60) { 13 | this.requests.set(id, { date: now, count: 1 }); 14 | } else { 15 | this.requests.set(id, { date: record.date, count: record.count + 1 }); 16 | } 17 | } 18 | 19 | return this.requests.get(id).count; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/gemi/bin/createRollupInput.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import type { ComponentTree } from "../client/types"; 3 | import { flattenComponentTree } from "../client/helpers/flattenComponentTree"; 4 | 5 | export default async function () { 6 | const { app } = await import(path.resolve("./app/bootstrap.ts")); 7 | 8 | const entries = app.getComponentTree(); 9 | 10 | function getEntries(componentTree: ComponentTree) { 11 | const out: string[] = ["/app/client.tsx"]; 12 | if (!componentTree) { 13 | return out; 14 | } 15 | 16 | return Array.from(new Set(flattenComponentTree(componentTree))); 17 | } 18 | 19 | return Array.from( 20 | new Set([ 21 | "/app/client.tsx", 22 | ...getEntries(entries).map((item) => `/app/views/${item}.tsx`), 23 | ]), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/eslint-config/next.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: [ 8 | "eslint:recommended", 9 | "prettier", 10 | require.resolve("@vercel/style-guide/eslint/next"), 11 | "eslint-config-turbo", 12 | ], 13 | globals: { 14 | React: true, 15 | JSX: true, 16 | }, 17 | env: { 18 | node: true, 19 | browser: true, 20 | }, 21 | plugins: ["only-warn"], 22 | settings: { 23 | "import/resolver": { 24 | typescript: { 25 | project, 26 | }, 27 | }, 28 | }, 29 | ignorePatterns: [ 30 | // Ignore dotfiles 31 | ".*.js", 32 | "node_modules/", 33 | ], 34 | overrides: [{ files: ["*.js?(x)", "*.ts?(x)"] }], 35 | }; 36 | -------------------------------------------------------------------------------- /packages/gemi/client/useBreadcrumbs.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { useRoute } from "./useRoute"; 3 | import { ClientRouterContext } from "./ClientRouterContext"; 4 | 5 | export type Breadcrumb = { 6 | label: string; 7 | href: string; 8 | }; 9 | 10 | export function useBreadcrumbs() { 11 | const { pathname } = useRoute(); 12 | const { getViewPathsFromPathname, breadcrumbsCache } = 13 | useContext(ClientRouterContext); 14 | 15 | let breadcrumbs: Breadcrumb[] = []; 16 | const viewPaths = getViewPathsFromPathname(pathname); 17 | for (const viewPath of viewPaths) { 18 | if (breadcrumbsCache.has(`${viewPath}:${pathname}`)) { 19 | breadcrumbs.push(breadcrumbsCache.get(`${viewPath}:${pathname}`)); 20 | } 21 | } 22 | 23 | return breadcrumbs.filter((breadcrumb) => breadcrumb?.label.length > 0); 24 | } 25 | -------------------------------------------------------------------------------- /packages/gemi/services/ServiceContainer.ts: -------------------------------------------------------------------------------- 1 | import { kernelContext } from "../kernel/context"; 2 | import type { ServiceProvider } from "./ServiceProvider"; 3 | 4 | export class ServiceContainer { 5 | static _name: string; 6 | service: ServiceProvider; 7 | 8 | static use( 9 | this: new ( 10 | service: ServiceProvider, 11 | ) => T, 12 | ): T { 13 | const store = kernelContext.getStore(); 14 | if (!store) { 15 | // console.error("Kernel is not available"); 16 | return; 17 | } 18 | // @ts-ignore 19 | if (!store?.[this._name]) { 20 | // @ts-ignore 21 | console.log("Container is not registered", this._name); 22 | console.log("Available containers", Object.keys(store)); 23 | } 24 | 25 | // @ts-ignore 26 | return store[this._name]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/gemi/utils/autobind.ts: -------------------------------------------------------------------------------- 1 | const getAllProperties = (object: any): Set => { 2 | const properties = new Set(); 3 | 4 | do { 5 | for (const key of Reflect.ownKeys(object)) { 6 | properties.add([object, key]); 7 | } 8 | } while ( 9 | (object = Reflect.getPrototypeOf(object)) && 10 | object !== Object.prototype 11 | ); 12 | 13 | return properties; 14 | }; 15 | 16 | export function autobind(self: new () => T): T { 17 | for (const [object, key] of getAllProperties(self.constructor.prototype)) { 18 | if (key === "constructor") { 19 | continue; 20 | } 21 | 22 | const descriptor = Reflect.getOwnPropertyDescriptor(object, key); 23 | if (descriptor && typeof descriptor.value === "function") { 24 | self[key] = self[key].bind(self); 25 | } 26 | } 27 | return self as any; 28 | } 29 | -------------------------------------------------------------------------------- /packages/gemi/rfc/Router.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | class ApiRouter { 3 | #routes = {}; 4 | 5 | static routes(routes: any) {} 6 | static post() {} 7 | } 8 | 9 | class Middleware { 10 | run() {} 11 | static pipe(...args: any[]) { 12 | return { 13 | next: (fn: any) => {}, 14 | }; 15 | } 16 | } 17 | 18 | class CSRFMiddleware { 19 | run() {} 20 | static next(fn: any) {} 21 | } 22 | 23 | class CacheMiddleware { 24 | run() {} 25 | static next(fn: any) {} 26 | } 27 | 28 | class AuthMiddleware { 29 | run() {} 30 | static next(fn: any) {} 31 | } 32 | 33 | ApiRouter.routes( 34 | AuthMiddleware.next({ 35 | "POST::/": CSRFMiddleware.next(HomeController.use("index")), 36 | "RESOURCE::/products/:productId": () => {}, 37 | "GET::/products/:productId": Middleware.pipe(CacheMiddleware).next( 38 | () => {}, 39 | ), 40 | }), 41 | ); 42 | -------------------------------------------------------------------------------- /packages/gemi/app/prismaExtension.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { KernelContext } from "../kernel/KernelContext"; 3 | import { InsufficientPermissionsError } from "../http/errors"; 4 | 5 | export const prismaExtension = Prisma.defineExtension({ 6 | name: "Gemi Policies", 7 | 8 | query: { 9 | async $allOperations({ args, operation, query, model }) { 10 | // const provider = KernelContext.getStore().policiesServiceProvider; 11 | 12 | // const policies = provider.policiesList[`${model}Policies`]; 13 | 14 | // if (!policies) { 15 | // return await query(args); 16 | // } 17 | 18 | // const isPassed = await policies.all.call(policies, operation, args); 19 | // if (!isPassed) { 20 | // throw new InsufficientPermissionsError(); 21 | // } 22 | 23 | return await query(args); 24 | }, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /packages/gemi/http/Controller.ts: -------------------------------------------------------------------------------- 1 | import type { HttpRequest } from "./HttpRequest"; 2 | 3 | export class Controller { 4 | static kind = "controller" as const; 5 | 6 | constructor() {} 7 | } 8 | 9 | type PromiseOrData = T | Promise; 10 | 11 | export abstract class ResourceController extends Controller { 12 | abstract store(req: HttpRequest): PromiseOrData; 13 | abstract update(req: HttpRequest): PromiseOrData; 14 | abstract delete(req: HttpRequest): PromiseOrData; 15 | abstract list(req: HttpRequest): PromiseOrData; 16 | abstract show(req: HttpRequest): PromiseOrData; 17 | } 18 | 19 | export type ControllerMethods< 20 | T extends new () => Controller | ResourceController, 21 | > = { 22 | [K in keyof InstanceType]: InstanceType[K] extends Function ? K : never; 23 | }[keyof InstanceType]; 24 | -------------------------------------------------------------------------------- /packages/gemi/auth/adapters/blank.ts: -------------------------------------------------------------------------------- 1 | import type { IAuthenticationAdapter } from "./types"; 2 | 3 | class AdapterNotFound extends Error { 4 | constructor() { 5 | super("Adapter not found"); 6 | this.name = "AdapterNotFound"; 7 | } 8 | } 9 | 10 | export class BlankAdapter implements IAuthenticationAdapter { 11 | // @ts-ignore 12 | createSession() { 13 | throw new AdapterNotFound(); 14 | } 15 | 16 | // @ts-ignore 17 | deleteSession() { 18 | throw new AdapterNotFound(); 19 | } 20 | 21 | // @ts-ignore 22 | findSession() { 23 | throw new AdapterNotFound(); 24 | } 25 | 26 | // @ts-ignore 27 | findUserByEmailAddress() { 28 | throw new AdapterNotFound(); 29 | } 30 | 31 | // @ts-ignore 32 | updateSession() { 33 | throw new AdapterNotFound(); 34 | } 35 | 36 | // @ts-ignore 37 | createUser() { 38 | throw new AdapterNotFound(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/app/views/components/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /packages/gemi/app/createComponentTree.ts: -------------------------------------------------------------------------------- 1 | import { ComponentTree } from "../client/types"; 2 | import { ViewRoutes } from "../http/ViewRouter"; 3 | 4 | export function createComponentTree(routes: ViewRoutes): ComponentTree { 5 | const componentTree: ComponentTree = []; 6 | 7 | for (const [_, routeHandler] of Object.entries(routes)) { 8 | if ("run" in routeHandler) { 9 | const viewPath = routeHandler.viewPath; 10 | if ("children" in routeHandler) { 11 | const router = new routeHandler.children(); 12 | const branch = createComponentTree(router.routes); 13 | componentTree.push([viewPath, branch]); 14 | } else { 15 | componentTree.push([viewPath, []]); 16 | } 17 | } else { 18 | const router = new routeHandler(); 19 | const branch = createComponentTree(router.routes); 20 | componentTree.push(...branch); 21 | } 22 | } 23 | 24 | return componentTree; 25 | } 26 | -------------------------------------------------------------------------------- /packages/gemi/client/useSubscription.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useMemo } from "react"; 2 | import { WebSocketContext } from "./WebsocketContext"; 3 | import { applyParams } from "../utils/applyParams"; 4 | 5 | export function useSubscription( 6 | route: string, 7 | options: { params: {}; cb: (data: any) => void }, 8 | ) { 9 | const { cb, params } = options; 10 | const { subscribe, unsubscribe } = useContext(WebSocketContext); 11 | 12 | const topic = useMemo( 13 | () => applyParams(route, options.params), 14 | [route, params], 15 | ); 16 | 17 | const handler = (event: MessageEvent) => { 18 | const message = JSON.parse(event.data); 19 | if (topic === message.topic) { 20 | cb(message.data); 21 | } 22 | }; 23 | 24 | useEffect(() => { 25 | subscribe(topic, handler); 26 | 27 | return () => { 28 | unsubscribe(topic, handler); 29 | }; 30 | }, [topic]); 31 | } 32 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/app/views/components/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /packages/eslint-config/react-internal.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * internal (bundled by their consumer) libraries 8 | * that utilize React. 9 | */ 10 | 11 | /** @type {import("eslint").Linter.Config} */ 12 | module.exports = { 13 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], 14 | plugins: ["only-warn"], 15 | globals: { 16 | React: true, 17 | JSX: true, 18 | }, 19 | env: { 20 | browser: true, 21 | }, 22 | settings: { 23 | "import/resolver": { 24 | typescript: { 25 | project, 26 | }, 27 | }, 28 | }, 29 | ignorePatterns: [ 30 | // Ignore dotfiles 31 | ".*.js", 32 | "node_modules/", 33 | "dist/", 34 | ], 35 | overrides: [ 36 | // Force ESLint to detect .tsx files 37 | { files: ["*.js?(x)", "*.ts?(x)"] }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /packages/gemi/exports.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": { 3 | "./http": "./http/index.ts", 4 | "./client": "./client/index.ts", 5 | "./app": "./app/index.ts", 6 | "./facades": "./facades/index.ts", 7 | "./email": "./email/index.ts", 8 | "./vite": "./dist/vite/index.mjs", 9 | "./runtime": "./client/runtime.ts", 10 | "./kernel": "./kernel/index.ts", 11 | "./services": "./services/index.ts", 12 | "./broadcasting": "./broadcasting/index.ts", 13 | "./i18n": "./i18n/index.ts" 14 | }, 15 | "prod": { 16 | "./http": "./dist/http/index.js", 17 | "./client": "./dist/client/index.js", 18 | "./app": "./dist/app/index.js", 19 | "./facades": "./dist/facades/index.js", 20 | "./email": "./dist/email/index.js", 21 | "./vite": "./dist/vite/index.mjs", 22 | "./runtime": "./dist/runtime/index.js", 23 | "./kernel": "./dist/kernel/index.js", 24 | "./services": "./dist/services/index.js", 25 | "./i18n": "./dist/i18n/index.js" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/gemi/http/CacheMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "./Middleware"; 2 | 3 | export class CacheMiddleware extends Middleware { 4 | async run(...args: [scope: string, maxAge: string, ...rest: string[]]) { 5 | const [scope = "public", maxAge = "864000", ...rest] = args; 6 | let value = `${scope}, max-age=${maxAge}${rest.length > 0 ? ", " : ""}${rest.join(", ")}`; 7 | if (args.length === 0) { 8 | value = 9 | "public, max-age=864000, stale-while-revalidate=300, stale-if-error=600"; 10 | } 11 | if (args.length === 1 && args[0] === "public") { 12 | value = 13 | "public, max-age=864000, stale-while-revalidate=300, stale-if-error=600"; 14 | } 15 | if (args.length === 1 && args[0] === "private") { 16 | value = 17 | "private, max-age=0, stale-while-revalidate=300, stale-if-error=600"; 18 | } 19 | if (this.req.rawRequest.method === "GET") { 20 | this.req.ctx().setHeaders("Cache-Control", value); 21 | } 22 | 23 | return {}; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/gemi/http/CSRFMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { RequestBreakerError } from "./Error"; 2 | import { Middleware } from "./Middleware"; 3 | 4 | export class CSRFMiddleware extends Middleware { 5 | async run() { 6 | const csrfToken = this.req.cookies.get("csrf_token"); 7 | if (!csrfToken) { 8 | throw new InvalidCSRFTokenError(); 9 | } 10 | const NON_GET_METHODS = ["POST", "PUT", "PATCH", "DELETE"]; 11 | if (!NON_GET_METHODS.includes(this.req.rawRequest.method)) { 12 | return {}; 13 | } 14 | 15 | if (!Bun.CSRF.verify(csrfToken, { secret: process.env.SECRET })) { 16 | throw new InvalidCSRFTokenError(); 17 | } 18 | 19 | return {}; 20 | } 21 | } 22 | 23 | export class InvalidCSRFTokenError extends RequestBreakerError { 24 | constructor() { 25 | super("Invalid CSRF token"); 26 | this.name = "InvalidCSRFTokenError"; 27 | } 28 | 29 | payload = { 30 | api: { 31 | status: 403, 32 | data: { error: "Invalid CSRF token" }, 33 | }, 34 | view: {}, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /packages/gemi/utils/applyParams.ts: -------------------------------------------------------------------------------- 1 | export function applyParams( 2 | url: T, 3 | params: Record, 4 | ): string { 5 | return ( 6 | url 7 | .replace(/:([^/]+[*?]?)/g, (_, key) => { 8 | const hasSuffix = key.endsWith("?") || key.endsWith("*"); 9 | const paramName = hasSuffix ? key.slice(0, -1) : key; 10 | const value = params[paramName]; 11 | 12 | if (value === undefined) { 13 | if (hasSuffix) { 14 | return ""; // Remove the optional segment if no value is provided 15 | } 16 | // @ts-ignore 17 | if (import.meta.env.DEV) { 18 | throw new Error(`Missing parameter: ${paramName}`); 19 | } 20 | console.error(`Missing parameter: ${paramName} in URL: ${url}`); 21 | } 22 | 23 | return String(value); 24 | }) 25 | // remove double slashes 26 | .replace(/\/\//g, "/") 27 | // remove trailing slash 28 | .replace(/\/$/, "") 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/gemi/client/useForm.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentProps, type ReactNode } from "react"; 2 | 3 | export function useFormBuilder(args: { fields: T }) { 4 | const Form = ({ children }: { children: ReactNode }) => { 5 | return
{children}
; 6 | }; 7 | Form.Field = ({ 8 | name, 9 | ...rest 10 | }: { 11 | name: K; 12 | // @ts-expect-error 13 | } & ComponentProps<(typeof args.fields)[K]>) => { 14 | const Component = args.fields[name]; 15 | // @ts-expect-error 16 | return ; 17 | }; 18 | 19 | return Form; 20 | } 21 | 22 | const Test = () => { 23 | const Form = useFormBuilder({ 24 | // 25 | fields: { 26 | // 27 | name: (props: ComponentProps<"input">) => ( 28 | 29 | ), 30 | age: (props: ComponentProps<"input">) => , 31 | }, 32 | }); 33 | 34 | return ( 35 |
36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/gemi/facades/Broadcast.ts: -------------------------------------------------------------------------------- 1 | import { KernelContext } from "../kernel/KernelContext"; 2 | import { BroadcastingServiceContainer } from "../services/pubsub/BroadcastingServiceContainer"; 3 | import { applyParams } from "../utils/applyParams"; 4 | 5 | export class Broadcast { 6 | static channel(route: T, params: any) { 7 | const channel = 8 | BroadcastingServiceContainer.use().service.channels[route as any]; 9 | if (!channel) { 10 | console.error(`Channel ${route} not found`); 11 | return { 12 | publish: () => {}, 13 | }; 14 | } 15 | const instance = new channel(); 16 | return { 17 | publish: ( 18 | data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer, 19 | compress: boolean = false, 20 | ) => { 21 | const topic = applyParams(route as any, params); 22 | BroadcastingServiceContainer.use().publish( 23 | topic, 24 | JSON.stringify({ topic, data: instance.publish(data) }), 25 | compress, 26 | ); 27 | }, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/gemi/http/index.ts: -------------------------------------------------------------------------------- 1 | export { Controller, ResourceController } from "./Controller"; 2 | export { ApiRouter, type CreateRPC } from "./ApiRouter"; 3 | export { ViewRouter, type CreateViewRPC, type ViewHandler } from "./ViewRouter"; 4 | export { ValidationError } from "./Router"; 5 | export { HttpRequest } from "./HttpRequest"; 6 | export { Middleware } from "./Middleware"; 7 | export { getCookies } from "./getCookies"; 8 | export { RequestBreakerError } from "./Error"; 9 | 10 | export { MiddlewareServiceProvider } from "./MiddlewareServiceProvider"; 11 | export { AuthenticationMiddleware } from "./AuthenticationMiddlware"; 12 | export { CacheMiddleware } from "./CacheMiddleware"; 13 | export { CorsMiddleware } from "./CorsMiddleware"; 14 | export { RateLimitMiddleware } from "./RateLimitMiddleware"; 15 | export { CSRFMiddleware } from "./CSRFMiddleware"; 16 | 17 | export { PoliciesServiceProvider } from "./PoliciesServiceProvider"; 18 | export { Policies } from "./Policy"; 19 | 20 | export { 21 | AuthenticationError, 22 | AuthorizationError, 23 | InsufficientPermissionsError, 24 | } from "./errors"; 25 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Enes Tüfekçi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/gemi/client/RouteStateContext.tsx: -------------------------------------------------------------------------------- 1 | import type { Action } from "history"; 2 | import { createContext, type PropsWithChildren } from "react"; 3 | 4 | export interface RouteState { 5 | views: string[]; 6 | params: Record; 7 | search: string; 8 | state: Record; 9 | pathname: string; 10 | hash: string; 11 | action: Action | null; 12 | routePath: string; 13 | locale: string | null; 14 | } 15 | 16 | export type PageData = { 17 | data: Record; 18 | i18n: { 19 | currentLocale: string; 20 | dictionary: Record>; 21 | supportedLocales: string[]; 22 | }; 23 | prefetchedData: Record; 24 | breadcrumbs: any; 25 | appId: string; 26 | }; 27 | 28 | export const RouteStateContext = createContext({} as RouteState & PageData); 29 | 30 | export const RouteStateProvider = ( 31 | props: PropsWithChildren<{ 32 | state: RouteState & PageData; 33 | }>, 34 | ) => { 35 | return ( 36 | 37 | {props.children} 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/gemi/http/Router.ts: -------------------------------------------------------------------------------- 1 | import { RequestBreakerError } from "./Error"; 2 | import type { HttpRequest } from "./HttpRequest"; 3 | 4 | type MiddlewareResult = Partial<{ 5 | headers: Record; 6 | cookies: Record; 7 | }>; 8 | 9 | export type MiddlewareReturnType = 10 | | void 11 | | Promise 12 | | MiddlewareResult; 13 | 14 | export type RouterMiddleware = ( 15 | req: HttpRequest, 16 | ctx: any, 17 | ) => MiddlewareReturnType; 18 | 19 | export class ValidationError extends RequestBreakerError { 20 | errors: Record = {}; 21 | constructor(errors: Record) { 22 | super("Validation error"); 23 | this.name = "ValidationError"; 24 | this.errors = errors; 25 | this.payload = { 26 | api: { 27 | status: 400, 28 | data: { 29 | error: { 30 | kind: "validation_error", 31 | messages: errors, 32 | }, 33 | }, 34 | headers: { 35 | "Content-Type": "application/json", 36 | }, 37 | }, 38 | view: { 39 | status: 400, 40 | }, 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/gemi/i18n/I18nRouter.ts: -------------------------------------------------------------------------------- 1 | import { ApiRouter, HttpRequest } from "../http"; 2 | import { I18nServiceContainer } from "./I18nServiceContainer"; 3 | 4 | export class I18nRouter extends ApiRouter { 5 | middlewares = ["cache:private,0,no-store"]; 6 | routes = { 7 | "/set-locale/:locale": this.get(() => { 8 | const req = new HttpRequest(); 9 | req.ctx().setCookie("i18n-locale", req.params.locale); 10 | return {}; 11 | }), 12 | "/translations/:locale/:scope*": this.get(async () => { 13 | const req = new HttpRequest(); 14 | 15 | const scope = `/${req.params.scope ?? ""}`; 16 | const forcedLocale = req.params.locale; 17 | 18 | const locale = 19 | forcedLocale ?? I18nServiceContainer.use().detectLocale(req); 20 | 21 | const translations = I18nServiceContainer.use().getPageTranslations( 22 | locale, 23 | scope, 24 | ); 25 | 26 | req.ctx().setCookie("i18n-locale", locale, { 27 | expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), 28 | }); 29 | 30 | return { 31 | [locale]: translations, 32 | }; 33 | }), 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/providers/LoggingServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { FileStorage } from "gemi/facades"; 2 | import { LoggingServiceProvider, type LogEntry } from "gemi/services"; 3 | 4 | export default class extends LoggingServiceProvider { 5 | maxFileSize = 1024 * 1024 * 10; 6 | 7 | async onLogFileClosed(file: File) { 8 | try { 9 | await FileStorage.put({ 10 | body: file, 11 | name: `logs/${file.name}`, 12 | }); 13 | } catch (err) { 14 | console.log(err); 15 | } 16 | } 17 | 18 | async onLogCreated(logEntry: LogEntry) { 19 | const { level, message } = logEntry; 20 | if (level === "error" || level === "critical" || level === "emergency") { 21 | await fetch( 22 | `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, 23 | { 24 | method: "POST", 25 | headers: { 26 | "Content-Type": "application/json", 27 | }, 28 | body: JSON.stringify({ 29 | text: `${level.toUpperCase()}: ${message}`, 30 | chat_id: process.env.TELEGRAM_CHAT_ID, 31 | }), 32 | }, 33 | ); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/gemi/services/router/createComponentTree.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentTree } from "../../client/types"; 2 | import type { ViewRoutes } from "../../http/ViewRouter"; 3 | 4 | export function createComponentTree(routes: ViewRoutes): ComponentTree { 5 | const componentTree: ComponentTree = []; 6 | 7 | for (const [_, routeHandler] of Object.entries(routes)) { 8 | if ("run" in routeHandler) { 9 | const viewPath = routeHandler.viewPath; 10 | if (viewPath === "__") { 11 | continue; 12 | } 13 | 14 | if (viewPath === "REDIRECT") { 15 | continue; 16 | } 17 | 18 | if (viewPath === "FILE") { 19 | continue; 20 | } 21 | 22 | if ("children" in routeHandler) { 23 | const router = new routeHandler.children(); 24 | const branch = createComponentTree(router.routes); 25 | componentTree.push([viewPath, branch]); 26 | } else { 27 | componentTree.push([viewPath, []]); 28 | } 29 | } else { 30 | const router = new routeHandler(); 31 | const branch = createComponentTree(router.routes); 32 | componentTree.push(...branch); 33 | } 34 | } 35 | 36 | return componentTree; 37 | } 38 | -------------------------------------------------------------------------------- /packages/gemi/facades/I18n.ts: -------------------------------------------------------------------------------- 1 | import { I18nServiceContainer } from "../i18n/I18nServiceContainer"; 2 | import { RequestContext } from "../http/requestContext"; 3 | 4 | export class I18n { 5 | static getSupportedLocales() { 6 | return I18nServiceContainer.use().service.supportedLocales; 7 | } 8 | 9 | static getDefaultLocale() { 10 | return I18nServiceContainer.use().service.defaultLocale; 11 | } 12 | 13 | static locale() { 14 | const container = I18nServiceContainer.use(); 15 | const requestStore = RequestContext.getStore(); 16 | if (requestStore) { 17 | return container.detectLocale(requestStore.req); 18 | } 19 | 20 | return container.service.defaultLocale; 21 | } 22 | 23 | static setLocale(locale = I18n.locale()) { 24 | const container = I18nServiceContainer.use(); 25 | let _locale = locale; 26 | if (!container.service.supportedLocales.includes(locale)) { 27 | _locale = container.service.defaultLocale; 28 | } 29 | 30 | RequestContext.getStore().setCookie("i18n-locale", _locale, { 31 | expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), 32 | secure: false, 33 | httpOnly: false, 34 | }); 35 | 36 | return _locale; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useAppIdMissmatch, useQuery, useSearchParams } from "gemi/client"; 2 | 3 | function useAppRefresh() { 4 | const appIdMissmatch = useAppIdMissmatch(); 5 | 6 | if (appIdMissmatch) { 7 | if (confirm("The application has been updated. Reload now?")) { 8 | caches.keys().then((names) => { 9 | for (const name of names) { 10 | console.log("here"); 11 | if (name.includes(".js") && caches) { 12 | caches.delete(name); 13 | } 14 | } 15 | window.location.reload(); 16 | }); 17 | } 18 | } 19 | } 20 | 21 | export default function Home() { 22 | useAppRefresh(); 23 | const { data, version, mutate } = useQuery("/health"); 24 | const searchParams = useSearchParams(); 25 | 26 | return ( 27 |
28 |

Home

29 |

Status: {data?.status ?? "Loading..."}

30 |

Version: {version}

31 | 34 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /packages/gemi/facades/FileStorage.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import sharp from "sharp"; 3 | 4 | import { Buffer } from "node:buffer"; 5 | import type { Prettify } from "../utils/type"; 6 | 7 | import { KernelContext } from "../kernel/KernelContext"; 8 | import type { 9 | PutFileParams, 10 | ReadFileParams, 11 | } from "../services/file-storage/drivers/types"; 12 | import { FileStorageServiceContainer } from "../services/file-storage/FileStorageServiceContainer"; 13 | 14 | type Metadata = Prettify; 15 | 16 | export class FileStorage { 17 | static async put(params: PutFileParams | Blob) { 18 | return FileStorageServiceContainer.use().service.driver.put(params); 19 | } 20 | 21 | static async metadata(obj: Blob | File): Promise> { 22 | const buffer = Buffer.from(await obj.arrayBuffer()); 23 | try { 24 | return await sharp(buffer).metadata(); 25 | } catch { 26 | return {}; 27 | } 28 | } 29 | 30 | static async fetch(params: ReadFileParams | string) { 31 | return FileStorageServiceContainer.use().service.driver.fetch(params); 32 | } 33 | static list(folder: string) { 34 | return FileStorageServiceContainer.use().service.driver.list(folder); 35 | } 36 | static delete() {} 37 | } 38 | -------------------------------------------------------------------------------- /packages/gemi/http/RateLimitMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { RateLimiterServiceContainer } from "../services/rate-limiter/RateLimiterServiceContainer"; 2 | import { RequestBreakerError } from "./Error"; 3 | import { Middleware } from "./Middleware"; 4 | 5 | class RateLimitExceededError extends RequestBreakerError { 6 | constructor() { 7 | super("Rate limit exceeded"); 8 | this.payload = { 9 | api: { 10 | status: 429, 11 | data: { 12 | error: { 13 | message: "Rate limit exceeded", 14 | }, 15 | }, 16 | headers: { 17 | "Content-Type": "application/json", 18 | }, 19 | }, 20 | view: { 21 | error: { 22 | message: "Rate limit exceeded", 23 | }, 24 | status: 429, 25 | }, 26 | }; 27 | } 28 | } 29 | 30 | export class RateLimitMiddleware extends Middleware { 31 | async run(limit = 1000) { 32 | const userId = this.req.headers.get("x-forwarded-for"); 33 | const driver = RateLimiterServiceContainer.use().service.driver; 34 | const result = driver.consume.call(driver, userId, this.req.routePath); 35 | if (result > limit) { 36 | throw new RateLimitExceededError(); 37 | } 38 | 39 | return {}; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/gemi/client/QueryManagerContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | type PropsWithChildren, 4 | useContext, 5 | useRef, 6 | } from "react"; 7 | import { QueryResource } from "./QueryResource"; 8 | import { HttpClientContext } from "./HttpClientContext"; 9 | 10 | export const QueryManagerContext = createContext({ 11 | getResource: (key: string, initialState: Record = {}) => { 12 | return new QueryResource(key, initialState, fetch, ""); 13 | }, 14 | }); 15 | 16 | export const QueryManagerProvider = ({ children }: PropsWithChildren<{}>) => { 17 | const resourcesRef = useRef>(new Map()); 18 | const { fetch, host } = useContext(HttpClientContext); 19 | 20 | return ( 21 | ) => { 24 | if (!resourcesRef.current.has(key)) { 25 | resourcesRef.current.set( 26 | key, 27 | new QueryResource(key, initialState ?? {}, fetch as any, host), 28 | ); 29 | } 30 | return resourcesRef.current.get(key); 31 | }, 32 | }} 33 | > 34 | {children} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/gemi/scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { rmdir } from "node:fs/promises"; 2 | 3 | try { 4 | await rmdir("dist", { recursive: true }); 5 | } catch (err) {} 6 | 7 | const result = await Bun.build({ 8 | entrypoints: [ 9 | "./http/index.ts", 10 | "./app/index.ts", 11 | "./facades/index.ts", 12 | "./email/index.ts", 13 | "./vite/index.ts", 14 | "./server/index.ts", 15 | "./kernel/index.ts", 16 | "./services/index.ts", 17 | "./broadcasting/index.ts", 18 | "./i18n/index.ts", 19 | ], 20 | outdir: "./dist", 21 | external: [ 22 | "vite", 23 | "react", 24 | "react-dom", 25 | "react/jsx-runtime", 26 | "bun", 27 | "jsx-email", 28 | "sharp", 29 | ], 30 | target: "bun", 31 | format: "esm", 32 | minify: true, 33 | splitting: true, 34 | sourcemap: "external", 35 | }); 36 | 37 | if (!result.success) { 38 | console.error("Build failed"); 39 | for (const message of result.logs) { 40 | // Bun will pretty print the message object 41 | console.error(message); 42 | } 43 | } else { 44 | result.logs.forEach((message) => { 45 | console.log(message); 46 | }); 47 | 48 | result.outputs.forEach((output) => { 49 | console.log(output.path); 50 | }); 51 | 52 | console.log("Build succeeded"); 53 | } 54 | -------------------------------------------------------------------------------- /packages/gemi/client/useLocale.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from "./useLocation"; 2 | import { useNavigate } from "./useNavigate"; 3 | import { useParams } from "./useParams"; 4 | import { useRouteData } from "./useRouteData"; 5 | 6 | const setCookie = async (locale: string) => { 7 | try { 8 | return await globalThis.cookieStore.set("i18n-locale", locale); 9 | } catch (err) { 10 | return await fetch(`/api/__gemi__/services/i18n/set-locale/${locale}`); 11 | // TODO: show unsuported browser error 12 | // console.log(err); 13 | } 14 | }; 15 | 16 | export function useLocale() { 17 | const { i18n } = useRouteData(); 18 | const { pathname, search } = useLocation(); 19 | const { replace } = useNavigate(); 20 | const params = useParams(); 21 | 22 | const setLocale = async (locale: string) => { 23 | const urlSearchParams = new URLSearchParams(search); 24 | setCookie(locale).then(() => { 25 | replace(pathname, { 26 | locale, 27 | // TODO: fix: this conversion is wrong, because there can be multiple 28 | // search params with the same name 29 | search: Object.fromEntries(urlSearchParams.entries()), 30 | params, 31 | } as any); 32 | }); 33 | }; 34 | 35 | return [i18n.currentLocale, setLocale] as const; 36 | } 37 | -------------------------------------------------------------------------------- /packages/gemi/client/ComponentContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, lazy, type PropsWithChildren } from "react"; 2 | import { flattenComponentTree } from "./helpers/flattenComponentTree"; 3 | import type { ServerDataContextValue } from "./ServerDataProvider"; 4 | 5 | declare const window: { 6 | __GEMI_DATA__: ServerDataContextValue; 7 | loaders: Record< 8 | string, 9 | () => Promise<{ 10 | default: React.ComponentType; 11 | }> 12 | >; 13 | } & Window; 14 | 15 | let viewImportMap: Record> | null = null; 16 | if (typeof window !== "undefined" && process.env.NODE_ENV !== "test") { 17 | viewImportMap = {}; 18 | const { componentTree = [] } = window.__GEMI_DATA__ ?? {}; 19 | 20 | for (const viewName of flattenComponentTree(componentTree)) { 21 | viewImportMap[viewName] = lazy(window.loaders[viewName]); 22 | } 23 | } 24 | 25 | export const ComponentsContext = createContext({ viewImportMap }); 26 | 27 | export const ComponentsProvider = ( 28 | props: PropsWithChildren<{ viewImportMap: typeof viewImportMap }>, 29 | ) => { 30 | return ( 31 | 34 | {props.children} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/gemi/client/RouteTransitionProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, type PropsWithChildren } from "react"; 2 | 3 | const RouteTransitionContext = createContext<{ 4 | isTransitioning: boolean; 5 | targetPath: string; 6 | currentPath: string; 7 | }>({ 8 | isTransitioning: false, 9 | targetPath: "", 10 | currentPath: "", 11 | }); 12 | 13 | interface RouteTransitionProviderProps { 14 | isPending: boolean; 15 | isFetching: boolean; 16 | transitionPath: [string, string]; 17 | } 18 | 19 | export const RouteTransitionProvider = ( 20 | props: PropsWithChildren, 21 | ) => { 22 | const { isPending, isFetching, transitionPath } = props; 23 | 24 | return ( 25 | 32 | {props.children} 33 | 34 | ); 35 | }; 36 | 37 | export function useRouteTransition() { 38 | const context = useContext(RouteTransitionContext); 39 | if (!context) { 40 | throw new Error( 41 | "useRouteTransition must be used within a RouteTransitionProvider", 42 | ); 43 | } 44 | return context; 45 | } 46 | -------------------------------------------------------------------------------- /templates/saas-starter/Dockerfile: -------------------------------------------------------------------------------- 1 | # use the official Bun image 2 | # see all versions at https://hub.docker.com/r/oven/bun/tags 3 | FROM oven/bun:1 AS base 4 | WORKDIR /usr/src/app 5 | 6 | # install dependencies into temp directory 7 | # this will cache them and speed up future builds 8 | FROM base AS install 9 | RUN mkdir -p /temp/dev 10 | COPY package.json bun.lockb /temp/dev/ 11 | RUN cd /temp/dev && bun install --frozen-lockfile 12 | 13 | # install with --production (exclude devDependencies) 14 | RUN mkdir -p /temp/prod 15 | COPY package.json bun.lockb /temp/prod/ 16 | RUN cd /temp/prod && bun install --frozen-lockfile --production 17 | 18 | # copy node_modules from temp directory 19 | # then copy all (non-ignored) project files into the image 20 | FROM base AS prerelease 21 | COPY --from=install /temp/dev/node_modules node_modules 22 | COPY . . 23 | ENV NODE_ENV=production 24 | RUN bun run build 25 | ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true 26 | RUN bunx prisma generate 27 | 28 | # copy production dependencies and source code into final image 29 | FROM base AS release 30 | COPY --from=install /temp/prod/node_modules node_modules 31 | COPY --from=prerelease /usr/src/app . 32 | COPY --from=prerelease /usr/src/app/package.json . 33 | 34 | 35 | # run the app 36 | USER bun 37 | ENTRYPOINT [ "bun", "run", "start" ] 38 | -------------------------------------------------------------------------------- /packages/gemi/http/AuthenticationMiddlware.ts: -------------------------------------------------------------------------------- 1 | import type { HttpRequest } from "./HttpRequest"; 2 | import { Middleware } from "./Middleware"; 3 | import { RequestContext } from "./requestContext"; 4 | import { AuthenticationError } from "./errors"; 5 | import { AuthenticationServiceContainer } from "../auth/AuthenticationServiceContainer"; 6 | 7 | export class AuthenticationMiddleware extends Middleware { 8 | async run(_req: HttpRequest) { 9 | const requestContextStore = RequestContext.getStore(); 10 | const accessTokenCookie = 11 | requestContextStore.req.cookies.get("access_token"); 12 | const accessTokenHeader = 13 | requestContextStore.req.headers.get("access_token"); 14 | 15 | const accessToken = accessTokenCookie || accessTokenHeader; 16 | 17 | if (!accessToken) { 18 | throw new AuthenticationError(); 19 | } 20 | 21 | let user = requestContextStore.user; 22 | 23 | if (!user) { 24 | const session = await AuthenticationServiceContainer.use().getSession( 25 | accessToken, 26 | requestContextStore.req.headers.get("User-Agent"), 27 | ); 28 | if (!session) { 29 | throw new AuthenticationError(); 30 | } 31 | user = session?.user; 32 | requestContextStore.setUser(user); 33 | } 34 | 35 | return {}; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /templates/saas-starter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gemi-template-saas-starter", 3 | "scripts": { 4 | "dev": "gemi dev", 5 | "build": "gemi build", 6 | "start": "gemi start", 7 | "test": "vitest" 8 | }, 9 | "dependencies": { 10 | "@radix-ui/react-dialog": "^1.1.6", 11 | "@radix-ui/react-separator": "^1.1.2", 12 | "@radix-ui/react-slot": "^1.1.2", 13 | "@radix-ui/react-tooltip": "^1.1.8", 14 | "class-variance-authority": "^0.7.1", 15 | "clsx": "^2.1.1", 16 | "gemi": "*", 17 | "jsx-email": "2.0.0-rc2.1", 18 | "lucide-react": "^0.484.0", 19 | "react": "19.1.0", 20 | "react-dom": "19.1.0", 21 | "sharp": "^0.34.3", 22 | "tailwind-merge": "^3.0.2", 23 | "tailwindcss-animate": "^1.0.7" 24 | }, 25 | "devDependencies": { 26 | "@biomejs/biome": "^1.9.4", 27 | "@prisma/client": "^6.5.0", 28 | "@types/bun": "^1.1.6", 29 | "@types/react": "19.0.2", 30 | "@types/react-dom": "19.0.2", 31 | "@vitejs/plugin-react-swc": "^3.6.0", 32 | "autoprefixer": "^10.4.19", 33 | "postcss": "^8.4.38", 34 | "prisma": "^6.5.0", 35 | "tailwindcss": "^3.4.3", 36 | "typescript": "^5.5.4", 37 | "vite": "^5.3.1", 38 | "vitest": "^1.5.0" 39 | }, 40 | "resolution": { 41 | "react": "19.1.0", 42 | "react-dom": "19.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/gemi/facades/Log.ts: -------------------------------------------------------------------------------- 1 | import { LoggingServiceContainer } from "../services/logging/LoggingServiceContainer"; 2 | 3 | export class Log { 4 | static debug(message: string, metadata?: Record) { 5 | LoggingServiceContainer.use().log("debug", message, metadata); 6 | } 7 | 8 | static info(message: string, metadata?: Record) { 9 | LoggingServiceContainer.use().log("info", message, metadata); 10 | } 11 | 12 | static notice(message: string, metadata?: Record) { 13 | LoggingServiceContainer.use().log("notice", message, metadata); 14 | } 15 | 16 | static warning(message: string, metadata?: Record) { 17 | LoggingServiceContainer.use().log("warning", message, metadata); 18 | } 19 | 20 | static error(message: string, metadata?: Record) { 21 | LoggingServiceContainer.use().log("error", message, metadata); 22 | } 23 | 24 | static critical(message: string, metadata?: Record) { 25 | LoggingServiceContainer.use().log("critical", message, metadata); 26 | } 27 | 28 | static alert(message: string, metadata?: Record) { 29 | LoggingServiceContainer.use().log("alert", message, metadata); 30 | } 31 | 32 | static emergency(message: string, metadata?: Record) { 33 | LoggingServiceContainer.use().log("emergency", message, metadata); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/gemi/http/CorsMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "./Middleware"; 2 | 3 | type CorsHeaders = { 4 | "Access-Control-Allow-Methods": string; 5 | "Access-Control-Allow-Headers": string; 6 | "Access-Control-Allow-Credentials": string; 7 | }; 8 | 9 | const defaultHeaders: CorsHeaders = { 10 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", 11 | "Access-Control-Allow-Headers": 12 | "Content-Type, Authorization, X-Requested-With", 13 | "Access-Control-Allow-Credentials": "true", 14 | }; 15 | 16 | export class CorsMiddleware extends Middleware { 17 | config = { 18 | origins: { 19 | "": { 20 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", 21 | "Access-Control-Allow-Headers": 22 | "Content-Type, Authorization, X-Requested-With", 23 | "Access-Control-Allow-Credentials": "true", 24 | }, 25 | } as Record>, 26 | }; 27 | run() { 28 | const req = this.req; 29 | const origin = req.rawRequest.headers.get("Origin"); 30 | if (this.config.origins[origin]) { 31 | req.ctx().setHeaders("Access-Control-Allow-Origin", origin); 32 | const headers = { ...defaultHeaders, ...this.config.origins[origin] }; 33 | 34 | for (const [key, value] of Object.entries(headers)) { 35 | req.ctx().setHeaders(key, value); 36 | } 37 | } 38 | 39 | return {}; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/gemi/server/Server.ts: -------------------------------------------------------------------------------- 1 | type RequestHandler = ( 2 | req: Request, 3 | next: () => Response | Promise, 4 | ) => Response | Promise; 5 | 6 | export class Server { 7 | requestHandlers = new Map(); 8 | 9 | constructor(private urlPattern: any) {} 10 | 11 | public use(pattern: string, ...requestHandlers: RequestHandler[]) { 12 | const requestHandler = requestHandlers.reverse().reduce( 13 | (acc, handler) => { 14 | return (req: Request) => 15 | handler(req, () => acc(req, () => new Response("404"))); 16 | }, 17 | () => new Response("404", { status: 404 }), 18 | ); 19 | this.requestHandlers.set(pattern, requestHandler); 20 | } 21 | 22 | public fetch(req: Request) { 23 | const { pathname } = new URL(req.url); 24 | const handlerCandidates: [string, RequestHandler][] = []; 25 | 26 | for (const [p, rh] of this.requestHandlers.entries()) { 27 | const pattern = new this.urlPattern({ pathname: p }); 28 | if (pattern.test({ pathname })) { 29 | handlerCandidates.push([p, rh]); 30 | } 31 | } 32 | const [, handler] = handlerCandidates.sort( 33 | ([pa], [pb]) => pb.length - pa.length, 34 | )[0]; 35 | 36 | if (handler) { 37 | return handler(req, () => new Response("404")); 38 | } else { 39 | return new Response("404 not found", { status: 404 }); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/app/views/components/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )) 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 33 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/Kernel.ts: -------------------------------------------------------------------------------- 1 | import { Kernel } from "gemi/kernel"; 2 | 3 | import AuthenticationServiceProvider from "./providers/AuthenticationServiceProvider"; 4 | import MiddlewareServiceProvider from "./providers/MiddlewareServiceProvider"; 5 | import I18nServiceProvider from "./providers/I18nServiceProvider"; 6 | import FileStorageServiceProvider from "./providers/FileStorageServiceProvider"; 7 | import ViewRouterServiceProvider from "./providers/ViewRouterServiceProvider"; 8 | import ApiRouterServiceProvider from "./providers/ApiRouterServiceProvider"; 9 | import LoggingServiceProvider from "./providers/LoggingServiceProvider"; 10 | import QueueServiceProvider from "./providers/QueueServiceProvider"; 11 | import EmailServiceProvider from "./providers/EmailServiceProvider"; 12 | import CronServiceProvider from "./providers/CronServiceProvider"; 13 | 14 | export default class extends Kernel { 15 | authenticationServiceProvider = AuthenticationServiceProvider; 16 | apiRouterServiceProvider = ApiRouterServiceProvider; 17 | emailServiceProvider = EmailServiceProvider; 18 | fileStorageServiceProvider = FileStorageServiceProvider; 19 | i18nServiceProvider = I18nServiceProvider; 20 | middlewareServiceProvider = MiddlewareServiceProvider; 21 | loggingServiceProvider = LoggingServiceProvider; 22 | queueServiceProvider = QueueServiceProvider; 23 | viewRouterServiceProvider = ViewRouterServiceProvider; 24 | cronServiceProvider = CronServiceProvider; 25 | } 26 | -------------------------------------------------------------------------------- /packages/gemi/app/createRouteManifest.ts: -------------------------------------------------------------------------------- 1 | import { ViewRoutes } from "../http/ViewRouter"; 2 | 3 | export function createRouteManifest(routes: ViewRoutes) { 4 | const routeManifest: Record = {}; 5 | for (const [routePath, routeHandler] of Object.entries(routes)) { 6 | if ("run" in routeHandler) { 7 | const viewPath = routeHandler.viewPath; 8 | 9 | if ("children" in routeHandler) { 10 | routeManifest[routePath] = [viewPath]; 11 | } 12 | 13 | if ("children" in routeHandler) { 14 | const children = new routeHandler.children(); 15 | const manifest = createRouteManifest(children.routes); 16 | for (const [path, viewPaths] of Object.entries(manifest)) { 17 | const key = routePath === "/" ? path : `${routePath}${path}`; 18 | const _key = path === "/" && routePath !== "/" ? routePath : key; 19 | routeManifest[_key] = [viewPath, ...viewPaths]; 20 | } 21 | } else { 22 | routeManifest[routePath] = [viewPath]; 23 | } 24 | } else { 25 | const router = new routeHandler(); 26 | 27 | const manifest = createRouteManifest(router.routes); 28 | for (const [path, viewPaths] of Object.entries(manifest)) { 29 | const key = routePath === "/" ? path : `${routePath}${path}`; 30 | const _key = path === "/" && routePath !== "/" ? routePath : key; 31 | routeManifest[_key] = viewPaths; 32 | } 33 | } 34 | } 35 | 36 | return routeManifest; 37 | } 38 | -------------------------------------------------------------------------------- /packages/gemi/http/errors.ts: -------------------------------------------------------------------------------- 1 | import { RequestBreakerError } from "./Error"; 2 | 3 | export class AuthorizationError extends RequestBreakerError { 4 | private error: string; 5 | constructor(error: string = "Not authorized") { 6 | super("Authentication error"); 7 | this.name = "AuthenticationError"; 8 | this.error = error; 9 | this.payload = { 10 | api: { 11 | status: 401, 12 | data: { error: this.error }, 13 | }, 14 | view: {}, 15 | }; 16 | } 17 | } 18 | 19 | export class InsufficientPermissionsError extends RequestBreakerError { 20 | private error: string; 21 | constructor(error: string = "Insufficient permissions") { 22 | super("Authentication error"); 23 | this.name = "AuthenticationError"; 24 | this.error = error; 25 | this.payload = { 26 | api: { 27 | status: 401, 28 | data: { error: this.error }, 29 | }, 30 | view: {}, 31 | }; 32 | } 33 | } 34 | 35 | export class AuthenticationError extends RequestBreakerError { 36 | constructor() { 37 | super("Authentication error"); 38 | this.name = "AuthenticationError"; 39 | } 40 | 41 | payload = { 42 | api: { 43 | status: 401, 44 | data: { error: "Authentication error" }, 45 | }, 46 | view: { 47 | status: 302, 48 | headers: { 49 | "Cache-Control": 50 | "private, no-cache, no-store, max-age=0, must-revalidate", 51 | Location: "/auth/sign-in", 52 | }, 53 | }, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /packages/gemi/rfc/orm.ts: -------------------------------------------------------------------------------- 1 | // class Schema { 2 | // __brand = "Schema"; 3 | // fields: T; 4 | // constructor(fields: T = {} as T) { 5 | // this.fields = fields; 6 | // } 7 | 8 | // static create(fields: U) { 9 | // return new Schema(fields); 10 | // } 11 | // } 12 | 13 | // class Model { 14 | // __brand = "Model"; 15 | // schema: Schema; 16 | 17 | // static findOne Model>( 18 | // this: T, 19 | // fields: InstanceType["schema"]["fields"], 20 | // ) { 21 | // // Simulate a database query 22 | // return new this(); 23 | // } 24 | // } 25 | 26 | // class User extends Model { 27 | // schema = Schema.create({ name: "string", age: "number" }); 28 | // } 29 | 30 | // User.findOne({ name: "enes", age: "30" }); 31 | 32 | class Field { 33 | __brand = "Field"; 34 | value: T; 35 | } 36 | 37 | class Table { 38 | __brand = "Table"; 39 | __name: string; 40 | } 41 | 42 | class User extends Table { 43 | id = new Field(); 44 | name = new Field(); 45 | email = new Field(); 46 | } 47 | 48 | class Account extends Table { 49 | id = new Field(); 50 | userId = new Field(); 51 | } 52 | 53 | class Schema { 54 | tables: Record Table>; 55 | 56 | static table< 57 | T extends new () => Schema, 58 | const U extends keyof InstanceType["tables"], 59 | >(this: T, tables: U) {} 60 | } 61 | 62 | class MySchema extends Schema { 63 | tables = { 64 | user: User, 65 | account: Account, 66 | }; 67 | } 68 | 69 | MySchema.table("user"); 70 | -------------------------------------------------------------------------------- /packages/gemi/rfc/i18n.ts: -------------------------------------------------------------------------------- 1 | class I18NDictionary {} 2 | 3 | interface II18NComponent { 4 | dictionary: Readonly>>; 5 | } 6 | 7 | class I18NComponent implements II18NComponent { 8 | dictionary = {} as any; 9 | static reference< 10 | T extends new () => I18NComponent, 11 | K extends keyof InstanceType["dictionary"], 12 | >(this: T, key: K): InstanceType["dictionary"][K] { 13 | const instance = new this(); 14 | return instance.dictionary[key]; 15 | } 16 | } 17 | 18 | class SignUp extends I18NComponent { 19 | dictionary = { 20 | "sign-up": { 21 | default: "Sign Up", 22 | "de-DE": "Registrieren", 23 | }, 24 | submit: { 25 | default: "Submit", 26 | "de-DE": "Einreichen", 27 | }, 28 | }; 29 | } 30 | 31 | class SignIn extends I18NComponent { 32 | dictionary = { 33 | "sign-in": { 34 | default: "Sign In", 35 | "de-DE": (test: string) => `Anmelden ${test}`, 36 | }, 37 | "forgot-password": SignUp.reference("submit"), 38 | }; 39 | } 40 | 41 | class I18NServiceProvider { 42 | components = {}; 43 | } 44 | 45 | class CustomI18NServiceProvider extends I18NServiceProvider { 46 | components = { 47 | SignUp, 48 | SignIn, 49 | }; 50 | } 51 | 52 | type ParseComponent = { 53 | [K in keyof T]: T[K] extends typeof I18NComponent 54 | ? InstanceType["dictionary"] 55 | : never; 56 | }; 57 | 58 | type Parser = T extends I18NServiceProvider 59 | ? ParseComponent 60 | : never; 61 | 62 | type Result = Parser; 63 | -------------------------------------------------------------------------------- /packages/gemi/client/HttpReload.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate } from "./useNavigate"; 3 | import { useSearchParams } from "./useSearchParams"; 4 | import { useRoute } from "./useRoute"; 5 | import { useParams } from "./useParams"; 6 | import { createPortal } from "react-dom"; 7 | 8 | export const HttpReload = () => { 9 | const { replace } = useNavigate(); 10 | const searchParams = useSearchParams(); 11 | const { pathname } = useRoute(); 12 | const params = useParams(); 13 | const [reloading, setReloading] = useState(false); 14 | 15 | const handleReload = () => { 16 | setReloading(true); 17 | // replace(pathname, { 18 | // params: params, 19 | // search: searchParams.toJSON(), 20 | // } as any) 21 | // .catch(console.log) 22 | // .finally(() => { 23 | // setReloading(false); 24 | // }); 25 | }; 26 | 27 | useEffect(() => { 28 | // @ts-ignore 29 | if (import.meta.hot) { 30 | // @ts-ignore 31 | import.meta.hot.on("http-reload", handleReload); 32 | } 33 | return () => { 34 | // @ts-ignore 35 | if (import.meta.hot) { 36 | // @ts-ignore 37 | import.meta.hot.off("http-reload", handleReload); 38 | } 39 | }; 40 | }, [handleReload]); 41 | 42 | if (!reloading || typeof document === "undefined") { 43 | return null; 44 | } 45 | return createPortal( 46 |
47 |
...
48 |
, 49 | document.body, 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/auth/SignIn.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useNavigate, ValidationErrors } from "gemi/client"; 2 | import { FormField } from "../components/FormField"; 3 | import { Input } from "../components/ui/input"; 4 | import { Button } from "../components/ui/button"; 5 | 6 | export default function SignIn() { 7 | const { push } = useNavigate(); 8 | 9 | return ( 10 |
11 |
push("/dashboard")} 15 | className="flex flex-col gap-8 w-full" 16 | > 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |

27 | You don't have an account? 28 |
29 | 30 | Sign Up 31 | 32 |

33 |
34 |
35 | 36 |
37 |
38 | 39 | 40 | Sign in with google 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /packages/gemi/client/useTranslator.ts: -------------------------------------------------------------------------------- 1 | import type { I18nDictionary } from "./rpc"; 2 | import type { ParseTranslationParams, Prettify } from "../utils/type"; 3 | import type { JSX } from "react"; 4 | import { parseTranslation } from "../utils/parseTranslation"; 5 | import { useRouteData } from "./useRouteData"; 6 | 7 | type Parser> = Prettify< 8 | { 9 | [K in keyof T]: ParseTranslationParams; 10 | }[keyof T] 11 | >; 12 | 13 | type ParamsOrNever = T extends Record 14 | ? [params?: never] 15 | : [params: T]; 16 | 17 | export function useTranslator(component: T) { 18 | const { i18n } = useRouteData(); 19 | 20 | function parse< 21 | K extends keyof I18nDictionary[T]["dictionary"], 22 | U extends Record = I18nDictionary[T]["dictionary"][K], 23 | >(key: K, ...args: ParamsOrNever>) { 24 | try { 25 | const translations = i18n.dictionary[i18n.currentLocale][component]; 26 | const [params = {}] = args; 27 | return parseTranslation(translations[key as any], params); 28 | } catch (err) { 29 | console.error( 30 | `Unresolved translation Component:${component} key:${String(key)}`, 31 | ); 32 | return String(key); 33 | } 34 | } 35 | 36 | parse.jsx = < 37 | K extends keyof I18nDictionary[T]["dictionary"], 38 | U extends Record = I18nDictionary[T]["dictionary"][K], 39 | >( 40 | key: K, 41 | ...args: ParamsOrNever> 42 | ) => { 43 | return parse(key, ...(args as any)) as unknown as JSX.Element; 44 | }; 45 | 46 | return parse; 47 | } 48 | -------------------------------------------------------------------------------- /templates/saas-starter/app/http/routes/api.ts: -------------------------------------------------------------------------------- 1 | import { I18n } from "gemi/facades"; 2 | import { 3 | ApiRouter, 4 | ResourceController, 5 | ValidationError, 6 | type HttpRequest, 7 | } from "gemi/http"; 8 | import { Dictionary } from "gemi/i18n"; 9 | 10 | class ProductsController extends ResourceController { 11 | async list() {} 12 | async show() {} 13 | async store() {} 14 | async update(req: HttpRequest<{ id: 1 }>) {} 15 | async delete() {} 16 | } 17 | 18 | class OrgRouter extends ApiRouter { 19 | routes = { 20 | "/:orgId/products/:productId": this.resource(ProductsController), 21 | }; 22 | } 23 | 24 | export default class extends ApiRouter { 25 | middlewares = ["cache:private", "csrf"]; 26 | 27 | routes = { 28 | "/org": OrgRouter, 29 | "/test": this.get((req: HttpRequest) => { 30 | return { 31 | message: Dictionary.text( 32 | { 33 | "en-US": "ok {{message}}", 34 | "tr-TR": "d'accord {{message:[test]}}", 35 | }, 36 | { params: { message: (test) => test } }, 37 | ), 38 | }; 39 | }), 40 | "/upload": this.post(async (req: HttpRequest<{ file: File | File[] }>) => { 41 | const input = await req.input(); 42 | const file = input.get("file"); 43 | const files = Array.isArray(file) ? file : [file]; 44 | 45 | return files.map((file) => { 46 | return { 47 | filename: file.name, 48 | size: file.size, 49 | type: file.type, 50 | }; 51 | }); 52 | }), 53 | "/health": this.get(() => { 54 | return { 55 | status: "ok", 56 | }; 57 | }), 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /packages/gemi/client/QueryStore.ts: -------------------------------------------------------------------------------- 1 | type State = { 2 | data: any; 3 | error: any; 4 | status: "idle" | "pending" | "resolved" | "rejected"; 5 | createdAt: number; 6 | }; 7 | 8 | export class QueryStore { 9 | store = new Map(); 10 | 11 | async resolve(url: string, fresh = false) { 12 | if (!this.store.has(url)) { 13 | this.store.set(url, { 14 | createdAt: Date.now(), 15 | data: null, 16 | error: null, 17 | status: "idle", 18 | }); 19 | } 20 | 21 | const state = this.store.get(url); 22 | 23 | if (state.createdAt < Date.now() - 1000 * 60) { 24 | if (!fresh && state.data) { 25 | return { data: state.data, error: state.error }; 26 | } 27 | } 28 | 29 | if (state.status === "pending") { 30 | while (true) { 31 | if (this.store.get(url)?.status === "pending") { 32 | break; 33 | } 34 | } 35 | return { 36 | data: this.store.get(url)?.data, 37 | error: this.store.get(url)?.error, 38 | }; 39 | } 40 | 41 | const response = await fetch(url); 42 | 43 | if (response.ok) { 44 | const data = await response.json(); 45 | this.store.set(url, { 46 | data, 47 | createdAt: Date.now(), 48 | status: "resolved", 49 | error: null, 50 | }); 51 | return { data, error: null }; 52 | } else { 53 | const error = await response.json(); 54 | this.store.set(url, { 55 | data: null, 56 | createdAt: Date.now(), 57 | status: "rejected", 58 | error, 59 | }); 60 | return { data: null, error }; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/gemi/vite/index.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | import { customRequestParser } from "./customRequestParser"; 3 | 4 | const gemi = (): PluginOption[] => { 5 | return [ 6 | { 7 | name: "gemi-plugin-config", 8 | enforce: "pre", 9 | config: async () => { 10 | const appPath = `${process.cwd()}/app`; 11 | return { 12 | assetsInclude: ["/public"], 13 | build: { 14 | manifest: true, 15 | ssrEmitAssets: true, 16 | rollupOptions: { 17 | input: Array.from(JSON.parse(process.env.GEMI_INPUT ?? "[]")), 18 | }, 19 | }, 20 | resolve: { 21 | alias: { 22 | "@/app": appPath, 23 | }, 24 | }, 25 | }; 26 | }, 27 | }, 28 | { 29 | name: "gemi-plugin-hot-reload", 30 | handleHotUpdate({ server, modules }) { 31 | if (modules?.[0]?.id?.includes("/app/http/")) { 32 | // server.ws.send({ 33 | // type: "custom", 34 | // event: "http-reload", 35 | // data: { 36 | // id: modules[0].id, 37 | // }, 38 | // }); 39 | 40 | return []; 41 | } 42 | }, 43 | }, 44 | { 45 | name: "gemi-plugin-custom-request", 46 | enforce: "pre", 47 | async transform(src, id) { 48 | if (id.includes("/http/controllers/") || id.includes("/http/routes/")) { 49 | const code = await customRequestParser(src); 50 | return { 51 | code, 52 | }; 53 | } 54 | }, 55 | }, 56 | ]; 57 | }; 58 | 59 | export default gemi; 60 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/auth/SignUp.tsx: -------------------------------------------------------------------------------- 1 | import { Form, FormError, Link, useNavigate } from "gemi/client"; 2 | import { Input } from "../components/ui/input"; 3 | import { FormField } from "../components/FormField"; 4 | import { Button } from "../components/ui/button"; 5 | 6 | export default function SignUp() { 7 | const { replace } = useNavigate(); 8 | return ( 9 |
10 |
{ 15 | replace("/auth/sign-in"); 16 | }} 17 | > 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |

31 | Do you have an account? 32 |
33 | 34 | Sign In 35 | 36 |

37 |
38 |
39 | 40 |
41 |
42 | 43 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /packages/gemi/rfc/workflow.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | import { ServiceProvider } from "../services/ServiceProvider"; 3 | import { ServiceContainer } from "../services/ServiceContainer"; 4 | 5 | class WorkflowServiceProvider extends ServiceProvider { 6 | boot() {} 7 | 8 | async getWorkflow(id: string): Promise { 9 | return new Workflow(id); 10 | } 11 | async getWorkflowStep() {} 12 | async updateWorkflowStep() {} 13 | async completeWorkflow() {} 14 | } 15 | 16 | class WorkflorServiceContainer extends ServiceContainer { 17 | static _name = "WorkflorServiceContainer"; 18 | } 19 | 20 | const WorkflowContext = new AsyncLocalStorage<{ 21 | workflowId: string; 22 | }>(); 23 | 24 | class Workflow { 25 | constructor(public id: string) {} 26 | 27 | handler(..._args: unknown[]) {} 28 | 29 | static sleep(duration: string) { 30 | Workflow.waitUntil(() => { 31 | WorkflowContext; 32 | }); 33 | } 34 | 35 | static waitUntil(fn: () => boolean | Promise) { 36 | Workflow.step("wait-until", fn); 37 | } 38 | 39 | static step(id: string, handler: T) {} 40 | static start Workflow>( 41 | this: T, 42 | id: string, 43 | ...args: Parameters["handler"]> 44 | ) { 45 | const service = WorkflorServiceContainer.use().service; 46 | const w = new Workflow(id); 47 | w.handler(...args); 48 | } 49 | } 50 | 51 | class ReminderWorkflow extends Workflow { 52 | async handler(userId: string) { 53 | Workflow.step("", () => {}); 54 | 55 | Workflow.sleep("1d"); 56 | 57 | Workflow.step("", () => {}); 58 | } 59 | } 60 | 61 | ReminderWorkflow.start("reminder-workflow", "user-123"); 62 | -------------------------------------------------------------------------------- /packages/gemi/client/ServerDataProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, type PropsWithChildren } from "react"; 2 | import type { Translations } from "./I18nContext"; 3 | import type { ComponentTree } from "./types"; 4 | import type { User } from "../auth/adapters/types"; 5 | 6 | type Data = Record; 7 | 8 | export interface ServerDataContextValue { 9 | routeManifest: Record; 10 | pageData: Record>; 11 | breadcrumbs: Record; 12 | prefetchedData: Record; 13 | router: { 14 | pathname: string; 15 | params: Record; 16 | currentPath: string; 17 | is404: boolean; 18 | searchParams: string; 19 | urlLocaleSegment: string | null; 20 | }; 21 | i18n: { 22 | dictionary: Translations; 23 | currentLocale: string; 24 | supportedLocales: string[]; 25 | defaultLocale: string; 26 | }; 27 | componentTree: ComponentTree; 28 | auth: { 29 | user: User; 30 | }; 31 | __csrf: string; 32 | cssManifest: Record; 33 | meta: any; 34 | appId: string; 35 | } 36 | 37 | export const ServerDataContext = createContext({} as ServerDataContextValue); 38 | 39 | interface ServerDataProviderProps { 40 | value?: ServerDataContextValue; 41 | } 42 | 43 | export const ServerDataProvider = ( 44 | props: PropsWithChildren, 45 | ) => { 46 | let _value = props.value; 47 | // Server 48 | if (props.value) { 49 | _value = props.value; 50 | } else { 51 | // Client 52 | _value = (window as any).__GEMI_DATA__; 53 | } 54 | 55 | return ( 56 | 57 | {props.children} 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /templates/saas-starter/public/gemi.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /packages/gemi/server/renderErrorPage.ts: -------------------------------------------------------------------------------- 1 | function cleanupTerminalText(text) { 2 | return ( 3 | text 4 | // Remove ANSI escape sequences (colors, formatting) 5 | .replace(/\x1b\[[0-9;]*m/g, "") 6 | // Remove ANSI escape sequences with different format 7 | .replace(/\[[0-9;]*m/g, "") 8 | // Remove excessive whitespace and normalize line breaks 9 | .replace(/\n\s*\n/g, "\n") 10 | // Trim each line 11 | .split("\n") 12 | .map((line) => line.trim()) 13 | .filter((line) => line.length > 0) 14 | .join("\n") 15 | .trim() 16 | ); 17 | } 18 | 19 | export function renderErrorPage(err: any) { 20 | return ` 21 | 22 | 23 | 24 | 25 | 29 | 30 | Error 31 | 40 | 41 | 42 |
43 | 44 | 45 | 58 | 59 | 60 | `; 61 | } 62 | -------------------------------------------------------------------------------- /packages/gemi/client/Image.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from "react"; 2 | 3 | const defaultScreen = [390, 768, 1024]; 4 | const defaultContainer = [100, 100, 100, 100]; 5 | 6 | function generateImageProps( 7 | src: string, 8 | width: number, 9 | container = defaultContainer, 10 | screen = defaultScreen, 11 | quality = 80, 12 | ) { 13 | const baseUrl = src; 14 | 15 | const widths = [...container.map((c, i) => (screen[i] * c) / 100), width * 2]; 16 | 17 | return { 18 | srcSet: [ 19 | ...screen.map((size, i) => { 20 | return `/api/__gemi__/services/image/resize?url=${baseUrl}&q=${quality}&w=${widths[i]} ${size}${isNaN(Number(size)) ? "" : "w"}`; 21 | }), 22 | `/api/__gemi__/services/image/resize?url=${baseUrl}&q=${quality}&w=${width * 2} 2x`, 23 | ].join(", "), 24 | sources: [ 25 | ...container.map((c, i) => { 26 | if (!screen[i]) { 27 | return `${c}vw`; 28 | } 29 | return `(max-width: ${screen[i]}px) ${c}vw`; 30 | }), 31 | ].join(", "), 32 | }; 33 | } 34 | 35 | function fillRestWithLast(arr: T[], length: number): T[] { 36 | return [ 37 | ...arr, 38 | ...Array.from({ length: length - arr.length }).fill(arr[arr.length - 1]), 39 | ] as T[]; 40 | } 41 | 42 | interface ImageProps { 43 | src: string; 44 | width: number; 45 | container?: number[]; 46 | screen?: number[]; 47 | quality?: number; 48 | } 49 | 50 | export const Image = (props: ComponentProps<"img"> & ImageProps) => { 51 | const { 52 | screen = defaultScreen, 53 | container = defaultContainer, 54 | src, 55 | width, 56 | quality = 80, 57 | srcSet: __, 58 | ...rest 59 | } = props; 60 | 61 | if (!src) { 62 | return null; 63 | } 64 | 65 | const srcProps = generateImageProps( 66 | src, 67 | width, 68 | fillRestWithLast(container, 4), 69 | screen, 70 | quality, 71 | ); 72 | 73 | return ; 74 | }; 75 | -------------------------------------------------------------------------------- /packages/gemi/http/Metadata.ts: -------------------------------------------------------------------------------- 1 | export type OpenGraphParams = { 2 | title: string; 3 | description?: string; 4 | type: string; 5 | url: string; 6 | image: string; 7 | imageAlt?: string; 8 | imageWidth?: number; 9 | imageHeight?: number; 10 | twitterImage?: string; 11 | twitterImageAlt?: string; 12 | twitterImageWidth?: number; 13 | twitterImageHeight?: number; 14 | }; 15 | export class Metadata { 16 | content: any = { 17 | title: "Gemi App", 18 | description: null, 19 | openGraph: null, 20 | }; 21 | 22 | render() { 23 | return { 24 | title: this.content.title, 25 | description: this.content.description, 26 | openGraph: this.content.openGraph, 27 | }; 28 | } 29 | 30 | title(title: string) { 31 | this.content.title = title; 32 | } 33 | 34 | description(description: string) { 35 | this.content.description = description; 36 | } 37 | 38 | openGraph({ 39 | title, 40 | description, 41 | type, 42 | url, 43 | image, 44 | imageAlt, 45 | imageWidth, 46 | imageHeight, 47 | twitterImage = image, 48 | twitterImageAlt = imageAlt, 49 | twitterImageWidth = imageWidth, 50 | twitterImageHeight = imageHeight, 51 | }: OpenGraphParams) { 52 | let _image = image; 53 | let _twitterImage = twitterImage; 54 | if (image && !image.startsWith("http")) { 55 | _image = `${process.env.HOST_NAME}${image}`; 56 | _twitterImage = `${process.env.HOST_NAME}${twitterImage}`; 57 | } 58 | this.content.openGraph = Object.fromEntries( 59 | Object.entries({ 60 | title, 61 | description, 62 | type, 63 | url, 64 | image: _image, 65 | imageAlt, 66 | imageWidth, 67 | imageHeight, 68 | twitterImage: _twitterImage, 69 | twitterImageAlt, 70 | twitterImageWidth, 71 | twitterImageHeight, 72 | }).filter(([_, value]) => value !== undefined), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/gemi/facades/Redirect.ts: -------------------------------------------------------------------------------- 1 | import type { UrlParser, ViewPaths } from "../client/types"; 2 | import { RequestBreakerError } from "../http/Error"; 3 | import { applyParams } from "../utils/applyParams"; 4 | 5 | class RedirectError extends RequestBreakerError { 6 | constructor(path: string, status = 307) { 7 | super("Redirect error"); 8 | this.name = "RedirectError"; 9 | this.payload = { 10 | api: { 11 | status: 200, 12 | data: {}, 13 | directive: { kind: "Redirect", path }, 14 | }, 15 | view: { 16 | status, 17 | headers: { 18 | "Cache-Control": 19 | "private, no-cache, no-store, max-age=0, must-revalidate", 20 | Location: path, 21 | }, 22 | }, 23 | }; 24 | } 25 | } 26 | 27 | type Options = UrlParser extends Record 28 | ? { 29 | search?: Record; 30 | status?: number; 31 | permanent?: boolean; 32 | } 33 | : { 34 | search?: Record; 35 | params: UrlParser; 36 | status?: number; 37 | permanent?: boolean; 38 | }; 39 | 40 | export class Redirect { 41 | static to( 42 | path: T, 43 | ...args: UrlParser extends Record 44 | ? [options?: Options] 45 | : [options: Options] 46 | ) { 47 | const [options = {}] = args; 48 | const { 49 | search = {}, 50 | params = {}, 51 | permanent, 52 | status, 53 | } = { 54 | params: {}, 55 | status: 307, 56 | permanent: false, 57 | ...options, 58 | }; 59 | const searchParams = new URLSearchParams(search).toString(); 60 | throw new RedirectError( 61 | [applyParams(path, params), searchParams.toString()] 62 | .filter(Boolean) 63 | .join("?"), 64 | status ?? (permanent ? 301 : 307), 65 | ); 66 | } 67 | 68 | static external(url: string, status = 307) { 69 | throw new RedirectError(url, status); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/gemi/ide/emacs/gemi.el: -------------------------------------------------------------------------------- 1 | (defun gemi/project-root () 2 | "Find the project root directory." 3 | (let* ((default-directory (or (buffer-file-name) default-directory)) 4 | (root-markers '(".git" ".project" ".projectile" "Makefile" "package.json" ".gemi"))) 5 | (locate-dominating-file default-directory 6 | (lambda (dir) 7 | (seq-some 8 | (lambda (marker) 9 | (file-exists-p (expand-file-name marker dir))) 10 | root-markers))))) 11 | 12 | (defun gemi/ide (root) 13 | (condition-case err 14 | (let* ((default-directory root) 15 | (json-output (shell-command-to-string "gemi ide:generate-api-manifest")) 16 | (json-data (json-read-from-string json-output))) 17 | json-data) 18 | (error 19 | (progn 20 | (message "Error parsing JSON: %s" (error-message-string err)) 21 | nil)))) 22 | 23 | (defun gemi/discover-api-route-handlers () 24 | "Discover gemi API route handlers." 25 | (interactive) 26 | 27 | (let* ((project-root (gemi/project-root)) 28 | (json-data (gemi/ide project-root)) 29 | (first-level-keys (mapcar #'symbol-name (map-keys json-data))) 30 | (first-choice (completing-read "Select a route " first-level-keys nil t)) 31 | (first-level-value (cdr (assoc (intern first-choice) json-data))) 32 | (second-level-keys (mapcar #'symbol-name (map-keys first-level-value))) 33 | (second-choice (if (= (length second-level-keys) 1) 34 | (car second-level-keys) 35 | (completing-read "Select a method: " second-level-keys nil t))) 36 | (file-info (cdr (assoc (intern second-choice) first-level-value)))) 37 | (let ((file-path (cdr (assoc 'file file-info))) 38 | (line (cdr (assoc 'line file-info))) 39 | (column (cdr (assoc 'column file-info)))) 40 | (find-file (concat project-root file-path)) 41 | (goto-line line) 42 | (move-to-column column) 43 | (recenter)))) 44 | -------------------------------------------------------------------------------- /packages/gemi/services/cron/CronServiceContainer.ts: -------------------------------------------------------------------------------- 1 | import Baker from "cronbake"; 2 | import { ServiceContainer } from "../ServiceContainer"; 3 | import type { CronServiceProvider } from "./CronServiceProvider"; 4 | 5 | export class CronServiceContainer extends ServiceContainer { 6 | static _name = "CronServiceContainer"; 7 | 8 | constructor(public service: CronServiceProvider) { 9 | super(); 10 | 11 | if (this.service.jobs.length === 0) { 12 | return; 13 | } 14 | 15 | const driver = Baker.create({ autoStart: true }); 16 | const kernelRun = async (cb: () => T) => { 17 | await this.service.kernel.waitForBoot.call(this.service.kernel); 18 | return await this.service.kernel.run.call(this.service.kernel, cb); 19 | }; 20 | for (const Job of this.service.jobs) { 21 | const job = new Job(); 22 | if (!job.name) { 23 | console.error(`Cron job must have a name. Job: ${JSON.stringify(job)}`); 24 | continue; 25 | } 26 | if (!job.cron) { 27 | console.error(`Cron job must have an expression. Job: ${job.name}`); 28 | continue; 29 | } 30 | driver.add({ 31 | name: job.name, 32 | cron: job.cron, 33 | callback: () => 34 | kernelRun(async () => { 35 | try { 36 | await job.callback.call(job); 37 | } catch (error) { 38 | console.error(`Error in cron job ${job.name}:`, error); 39 | } 40 | }), 41 | onComplete: () => 42 | kernelRun(async () => { 43 | try { 44 | await job.onComplete.call(job); 45 | } catch (error) { 46 | console.error(`Error completing cron job ${job.name}:`, error); 47 | } 48 | }), 49 | onTick: () => 50 | kernelRun(async () => { 51 | try { 52 | await job.onTick.call(job); 53 | } catch (error) { 54 | console.error(`Error executing cron job ${job.name}:`, error); 55 | } 56 | }), 57 | }); 58 | } 59 | driver.bakeAll(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/gemi/services/router/createRouteManifest.ts: -------------------------------------------------------------------------------- 1 | import type { ViewRoutes } from "../../http/ViewRouter"; 2 | function removeGroupPrefix(input: string) { 3 | // Remove all (str) patterns 4 | const withoutParentheses = input.replace(/\([^)]*\)/g, ""); 5 | 6 | // Remove all double slashes // by replacing with single slash 7 | const withoutDoubleSlashes = withoutParentheses.replace(/\/\//g, "/"); 8 | return withoutDoubleSlashes; 9 | } 10 | 11 | export function createRouteManifest(routes: ViewRoutes) { 12 | const routeManifest: Record = {}; 13 | for (const [routePath, routeHandler] of Object.entries(routes)) { 14 | if ("run" in routeHandler) { 15 | const viewPath = routeHandler.viewPath; 16 | 17 | if ("children" in routeHandler) { 18 | // Add the layout view 19 | routeManifest[routePath] = [viewPath]; 20 | const children = new routeHandler.children(); 21 | const manifest = createRouteManifest(children.routes); 22 | for (const [path, viewPaths] of Object.entries(manifest)) { 23 | const key = routePath === "/" ? path : `${routePath}${path}`; 24 | const _key = path === "/" && routePath !== "/" ? routePath : key; 25 | routeManifest[_key] = [viewPath, ...viewPaths]; 26 | } 27 | if (routeManifest[routePath].length === 1) { 28 | // If the layout doesn't have any children, remove it from the manifest 29 | delete routeManifest[routePath]; 30 | } 31 | } else { 32 | routeManifest[routePath] = [viewPath]; 33 | } 34 | } else { 35 | const router = new routeHandler(); 36 | 37 | const manifest = createRouteManifest(router.routes); 38 | for (const [path, viewPaths] of Object.entries(manifest)) { 39 | const key = routePath === "/" ? path : `${routePath}${path}`; 40 | const _key = path === "/" && routePath !== "/" ? routePath : key; 41 | routeManifest[_key] = viewPaths; 42 | } 43 | } 44 | } 45 | 46 | return Object.fromEntries( 47 | Object.entries(routeManifest).map(([key, value]) => [ 48 | removeGroupPrefix(key), 49 | value, 50 | ]), 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /packages/gemi/services/file-storage/drivers/FileSystemDriver.ts: -------------------------------------------------------------------------------- 1 | import type { PutFileParams, ReadFileParams } from "./types"; 2 | import { FileStorageDriver } from "./FileStorageDriver"; 3 | import { readdir } from "fs/promises"; 4 | 5 | export class FileSystemDriver extends FileStorageDriver { 6 | constructor(private folderPath: string = `${process.env.ROOT_DIR}/storage`) { 7 | super(); 8 | } 9 | 10 | async put(params: PutFileParams | Blob) { 11 | let body: Blob | File | Buffer; 12 | let name: string; 13 | 14 | if (params instanceof Blob) { 15 | body = params; 16 | name = `${Bun.randomUUIDv7()}.${params.type.split("/")[1].split(";")[0]}`; 17 | } else { 18 | body = params.body; 19 | name = params.name; 20 | } 21 | 22 | const buffer = 23 | body instanceof Buffer 24 | ? body 25 | : body instanceof Blob || body instanceof File 26 | ? Buffer.from(await body.arrayBuffer()) 27 | : ""; 28 | 29 | const path = `${this.folderPath}/${name}`; 30 | 31 | await Bun.write(path, buffer as any); 32 | 33 | return name; 34 | } 35 | 36 | async fetch(params: ReadFileParams | string) { 37 | let bucket = process.env.BUCKET_NAME; 38 | let name: string | undefined; 39 | 40 | if (typeof params === "string") { 41 | name = params; 42 | } else { 43 | bucket = params.bucket ?? bucket; 44 | name = params.name; 45 | } 46 | 47 | if (!name) { 48 | throw new Error("Object name has to be specified"); 49 | } 50 | 51 | const path = `${this.folderPath}/${name}`; 52 | const file = Bun.file(path); 53 | const result = Bun.file(path).stream(); 54 | const date = new Date(file.lastModified).toUTCString(); 55 | 56 | return new Response(result, { 57 | headers: { 58 | "Content-Type": file.type, 59 | "Content-Length": String(file.size), 60 | "Cache-Control": "private, max-age=12000, must-revalidate", 61 | // TODO: fix this. 62 | "Last-Modified": date, 63 | }, 64 | }); 65 | } 66 | 67 | async list() { 68 | const files = await readdir(this.folderPath); 69 | 70 | return files; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/app/views/components/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /packages/gemi/client/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | type ReactNode, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from "react"; 8 | 9 | type Theme = "light" | "dark" | "system"; 10 | 11 | const ThemeContext = createContext({ 12 | theme: "light" as Theme, 13 | setTheme: (theme: Theme) => {}, // Function to set the theme 14 | }); 15 | 16 | function storeTheme(theme: string) { 17 | try { 18 | localStorage.setItem("theme", theme); 19 | } catch (error) { 20 | console.error("Failed to store theme in localStorage:", error); 21 | } 22 | } 23 | 24 | export const ThemeProvider = (props: { children: ReactNode }) => { 25 | const [theme, setTheme] = useState(() => { 26 | if (typeof window === "undefined") { 27 | return "light"; // Default theme for server-side rendering 28 | } 29 | return localStorage.getItem("theme") || "light"; 30 | }); 31 | 32 | useEffect(() => { 33 | if (theme === "system") { 34 | window 35 | .matchMedia("(prefers-color-scheme: dark)") 36 | .addEventListener("change", ({ matches }) => { 37 | document.documentElement.classList.remove("light", "dark"); 38 | document.documentElement.classList.add(matches ? "dark" : "light"); 39 | }); 40 | } 41 | }, [theme]); 42 | 43 | return ( 44 | { 48 | setTheme(newTheme); 49 | storeTheme(newTheme); 50 | 51 | let documentTheme = newTheme as Theme; 52 | if (newTheme === "system") { 53 | const media = window.matchMedia("(prefers-color-scheme: dark)"); 54 | documentTheme = media.matches ? "dark" : "light"; 55 | } 56 | document.documentElement.classList.remove("light", "dark"); 57 | document.documentElement.classList.add(documentTheme); 58 | }, 59 | }} 60 | > 61 | {props.children} 62 | 63 | ); 64 | }; 65 | 66 | export function useTheme() { 67 | const context = useContext(ThemeContext); 68 | if (!context) { 69 | throw new Error("useTheme must be used within a ThemeProvider"); 70 | } 71 | 72 | return context; 73 | } 74 | -------------------------------------------------------------------------------- /templates/saas-starter/app/http/routes/view.ts: -------------------------------------------------------------------------------- 1 | import { Cookie, I18n, Meta, Query } from "gemi/facades"; 2 | import { type HttpRequest, ViewRouter } from "gemi/http"; 3 | 4 | class AuthViewRouter extends ViewRouter { 5 | routes = { 6 | "/sign-in": this.view("auth/SignIn"), 7 | "/sign-up": this.view("auth/SignUp"), 8 | "/reset-password": this.view("auth/ResetPassword"), 9 | "/forgot-password": this.view("auth/ForgotPassword"), 10 | }; 11 | } 12 | 13 | class AppRouter extends ViewRouter { 14 | middlewares = ["auth", "cache:private"]; 15 | routes = { 16 | "/": this.layout("AppLayout", { 17 | "/dashboard": this.view("Dashboard"), 18 | "/inbox": this.view("Inbox"), 19 | }), 20 | }; 21 | } 22 | 23 | export default class extends ViewRouter { 24 | middlewares = ["cache:public,12840,must-revalidate"]; 25 | 26 | override routes = { 27 | "/": this.layout( 28 | "PublicLayout", 29 | () => { 30 | Meta.title("GEMI here"); 31 | Meta.description("GEMI here"); 32 | Meta.openGraph({ 33 | title: "GEMI here", 34 | image: "/.og", 35 | type: "image/svg+xml", 36 | url: "https://gemiapp.com", 37 | imageWidth: 600, 38 | imageHeight: 400, 39 | }); 40 | const isSet = Cookie.setIfAbsent("test", Math.random().toString(), { 41 | path: "/", 42 | maxAge: 3600, 43 | }); 44 | 45 | console.log({ isSet }); 46 | }, 47 | { 48 | "/": this.view("Home", () => { 49 | Meta.title("GEMI here home page"); 50 | }), 51 | "/about": this.view("About", () => { 52 | // Query.prefetch("/test"); 53 | return { title: "About" }; 54 | }), 55 | "/pricing": this.view("Pricing", (req: HttpRequest) => { 56 | return { title: "Pricing" }; 57 | }), 58 | "/test/:testId": this.view("Test", (req: HttpRequest) => { 59 | const result = { 60 | "en-US": "Hello", 61 | "tr-TR": "Merhaba", 62 | }; 63 | 64 | return { 65 | message: result[req.locale()], 66 | }; 67 | }), 68 | }, 69 | ), 70 | "/auth": AuthViewRouter, 71 | "(app)/": AppRouter, 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /packages/gemi/services/middleware/MiddlewareServiceContainer.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest, Middleware, MiddlewareServiceProvider } from "../../http"; 2 | import type { RouterMiddleware } from "../../http/Router"; 3 | import { isConstructor } from "../../internal/isConstructor"; 4 | import { ServiceContainer } from "../ServiceContainer"; 5 | 6 | function transformMiddleware(input: (string | Function)[]) { 7 | const map = new Map(); 8 | for (const middleware of input) { 9 | if (typeof middleware === "string") { 10 | const [alias, params = ""] = middleware.split(":"); 11 | if (alias.startsWith("-")) { 12 | if (map.has(alias.replace("-", ""))) { 13 | map.delete(alias.replace("-", "")); 14 | } 15 | } else { 16 | map.set(alias, params.split(",").filter(Boolean)); 17 | } 18 | } else { 19 | map.set(middleware, []); 20 | } 21 | } 22 | return map; 23 | } 24 | 25 | export class MiddlewareServiceContainer extends ServiceContainer { 26 | static _name = "MiddlewareServiceContainer"; 27 | 28 | constructor(public service: MiddlewareServiceProvider) { 29 | super(); 30 | } 31 | 32 | public runMiddleware( 33 | middleware: ( 34 | | string 35 | | RouterMiddleware 36 | | (new (req: HttpRequest) => Middleware) 37 | )[], 38 | ) { 39 | const req = new HttpRequest(); 40 | return Array.from(transformMiddleware(middleware).entries()) 41 | .map(([key, params]) => { 42 | if (typeof key === "string") { 43 | const Middleware = this.service.aliases[key]; 44 | if (Middleware) { 45 | const middleware = new Middleware(req); 46 | return () => middleware.run.call(middleware, ...params); 47 | } 48 | } else { 49 | if (isConstructor(key)) { 50 | const middleware = new key(req); 51 | return middleware.run.bind(middleware); 52 | } 53 | return key; 54 | } 55 | }) 56 | .filter(Boolean) 57 | .reduce( 58 | (acc: any, middleware: any) => { 59 | return async () => { 60 | return { 61 | ...(await acc()), 62 | ...(await middleware()), 63 | }; 64 | }; 65 | }, 66 | () => Promise.resolve({}), 67 | )(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /templates/saas-starter/app/views/components/AppSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Calendar, Home, Inbox, Search, Settings } from "lucide-react"; 2 | 3 | import { 4 | Sidebar, 5 | SidebarContent, 6 | SidebarFooter, 7 | SidebarGroup, 8 | SidebarGroupContent, 9 | SidebarGroupLabel, 10 | SidebarMenu, 11 | SidebarMenuButton, 12 | SidebarMenuItem, 13 | } from "./ui/sidebar"; 14 | import { Link, useNavigate, usePost, useUser } from "gemi/client"; 15 | import { Button } from "./ui/button"; 16 | 17 | // Menu items. 18 | const items = [ 19 | { 20 | title: "Home", 21 | url: "/dashboard", 22 | icon: Home, 23 | }, 24 | { 25 | title: "Inbox", 26 | url: "/inbox", 27 | icon: Inbox, 28 | }, 29 | { 30 | title: "Calendar", 31 | url: "#", 32 | icon: Calendar, 33 | }, 34 | { 35 | title: "Search", 36 | url: "#", 37 | icon: Search, 38 | }, 39 | { 40 | title: "Settings", 41 | url: "#", 42 | icon: Settings, 43 | }, 44 | ]; 45 | 46 | export function AppSidebar() { 47 | const { trigger: logout } = usePost("/auth/sign-out"); 48 | const { replace } = useNavigate(); 49 | const { user } = useUser(); 50 | return ( 51 | 52 | 53 | 54 | Application 55 | 56 | 57 | {items.map((item) => ( 58 | 59 | 60 | 61 | 62 | {item.title} 63 | 64 | 65 | 66 | ))} 67 | 68 | 69 | 70 | 71 | 72 | {user?.name} 73 | 84 | 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /packages/gemi/client/index.ts: -------------------------------------------------------------------------------- 1 | export { useQuery } from "./useQuery"; 2 | export type { QueryResult } from "./useQuery"; 3 | export { 4 | useMutation, 5 | useDelete, 6 | usePatch, 7 | usePost, 8 | usePut, 9 | useUpload, 10 | } from "./useMutation"; 11 | export { useMutate } from "./useMutate"; 12 | export { 13 | Form, 14 | FormError, 15 | useMutationStatus, 16 | useFormStatus, 17 | useFormData, 18 | FormFieldContainer, 19 | ValidationErrors, 20 | } from "./Mutation"; 21 | export { QueryManagerProvider } from "./QueryManagerContext"; 22 | export { useParams } from "./useParams"; 23 | export { useLocation } from "./useLocation"; 24 | export { useSearchParams } from "./useSearchParams"; 25 | export { useRoute } from "./useRoute"; 26 | export { useIsNavigationPending } from "./useIsNavigationPending"; 27 | export { useNavigationProgress } from "./useNavigationProgress"; 28 | export { useNavigate } from "./useNavigate"; 29 | export { useBreadcrumbs } from "./useBreadcrumbs"; 30 | export { useRouteTransition } from "./RouteTransitionProvider"; 31 | export { Link } from "./Link"; 32 | export { Redirect } from "./Redirect"; 33 | export { init, create } from "./init"; 34 | export { createRoot } from "./createRoot"; 35 | 36 | export type { RPC, ViewRPC, I18nDictionary } from "./rpc"; 37 | export type { ViewProps, LayoutProps } from "./types"; 38 | export type { CreateI18nDictionary } from "./I18nContext"; 39 | 40 | export { Image } from "./Image"; 41 | export { Head } from "./Head"; 42 | 43 | export { useForgotPassword } from "./auth/useForgotPassword"; 44 | export { useSignIn } from "./auth/useSignIn"; 45 | export { useSignUp } from "./auth/useSignUp"; 46 | export { useSignOut } from "./auth/useSignOut"; 47 | export { useResetPassword } from "./auth/useResetPassword"; 48 | export { useUser } from "./auth/useUser"; 49 | 50 | export { useTranslator } from "./useTranslator"; 51 | export { useLocale } from "./useLocale"; 52 | 53 | // Websocket 54 | export { useSubscription } from "./useSubscription"; 55 | export { useBroadcast } from "./useBroadcast"; 56 | 57 | export { HttpClientContext, HttpClientProvider } from "./HttpClientContext"; 58 | 59 | // Open Graph 60 | export { OpenGraphImage } from "./OpenGraphImage"; 61 | 62 | export { useTheme } from "./ThemeProvider"; 63 | export { useAppIdMissmatch } from "./useAppIdMissmatch"; 64 | -------------------------------------------------------------------------------- /packages/gemi/client/types.ts: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import type { ViewHandler } from "../http"; 3 | import type { Prettify, UnwrapPromise } from "../utils/type"; 4 | import type { ViewRPC } from "./rpc"; 5 | 6 | type ComponentBranch = [string, ComponentBranch[]]; 7 | export type ComponentTree = ComponentBranch[]; 8 | 9 | export type ViewPaths = ViewKeys; 10 | 11 | export type ViewResult = 12 | ViewRPC[T] extends ViewHandler 13 | ? { input: I; output: O; params: P } 14 | : never; 15 | 16 | export type ViewRoute = keyof ViewRPC; 17 | 18 | type ViewKeys = T extends keyof ViewRPC 19 | ? T extends `view:${infer K}` 20 | ? K 21 | : never 22 | : never; 23 | 24 | type LayoutKeys = T extends keyof ViewRPC 25 | ? T extends `layout:${infer K}` 26 | ? K 27 | : never 28 | : never; 29 | 30 | export type ViewProps> = 31 | ViewRPC[`view:${T}`] extends ViewHandler 32 | ? Prettify> 33 | : never; 34 | 35 | export type LayoutProps> = 36 | ViewRPC[`layout:${T}`] extends ViewHandler 37 | ? PropsWithChildren> 38 | : never; 39 | 40 | type UrlParserInternal = string extends T 41 | ? Record 42 | : T extends `${infer _Start}/:${infer Param}*/${infer Rest}` 43 | ? { [K in Param]: string[] } & UrlParserInternal<`/${Rest}`> 44 | : T extends `${infer _Start}/:${infer Param}?/${infer Rest}` 45 | ? { [K in Param]?: string | number } & UrlParserInternal<`/${Rest}`> 46 | : T extends `${infer _Start}/:${infer Param}/${infer Rest}` 47 | ? { [K in Param]: string | number } & UrlParserInternal<`/${Rest}`> 48 | : T extends `${infer _Start}/:${infer Param}*` 49 | ? { [K in Param]: string } 50 | : T extends `${infer _Start}/:${infer Param}?` 51 | ? { [K in Param]?: string | number } 52 | : T extends `${infer _Start}/:${infer Param}` 53 | ? { [K in Param]: string | number } 54 | : Record; 55 | 56 | export type UrlParser = Prettify>; 57 | 58 | type X = UrlParser<"/users/:userId/posts/:postId?">; 59 | -------------------------------------------------------------------------------- /packages/gemi/i18n/I18nServiceContainer.ts: -------------------------------------------------------------------------------- 1 | import type { HttpRequest } from "../http/HttpRequest"; 2 | import { ServiceContainer } from "../services/ServiceContainer"; 3 | import type { I18nServiceProvider } from "./I18nServiceProvider"; 4 | 5 | export class I18nServiceContainer extends ServiceContainer { 6 | static _name = "I18nServiceContainer"; 7 | translations = {}; 8 | isEnabled = false; 9 | constructor(public service: I18nServiceProvider) { 10 | super(); 11 | 12 | const translations = {}; 13 | 14 | for (const [route, dicArray] of Object.entries(this.service.prefetch)) { 15 | for (const locale of this.service.supportedLocales) { 16 | if (!translations[locale]) { 17 | translations[locale] = {}; 18 | } 19 | translations[locale][route] = {}; 20 | for (const dic of dicArray) { 21 | translations[locale][route][dic.name] = {}; 22 | for (const [key, value] of Object.entries(dic.dictionary)) { 23 | this.isEnabled = true; 24 | translations[locale][route][dic.name][key] = value[locale]; 25 | } 26 | } 27 | } 28 | } 29 | this.translations = translations; 30 | } 31 | 32 | detectLocale(req: HttpRequest) { 33 | const fallbackLocale = 34 | this.service.defaultLocale ?? this.service.supportedLocales[0] ?? "en-US"; 35 | const detectedLocale = this.service.detectLocale(req); 36 | if (this.service.supportedLocales.includes(detectedLocale)) { 37 | return detectedLocale; 38 | } 39 | 40 | const previousLocale = req.cookies.get("i18n-locale"); 41 | const locale = 42 | previousLocale ?? (req.headers.get("accept-language") || fallbackLocale); 43 | 44 | const [_locale] = locale.split(","); 45 | 46 | if (this.service.supportedLocales.includes(_locale)) { 47 | return _locale; 48 | } 49 | 50 | if (_locale.length === 2) { 51 | for (const supportedLocale of this.service.supportedLocales) { 52 | if (supportedLocale.startsWith(_locale)) { 53 | return supportedLocale; 54 | } 55 | } 56 | } 57 | 58 | return fallbackLocale; 59 | } 60 | 61 | getPageTranslations(locale: string, scope: string) { 62 | if (this.translations[locale][scope]) { 63 | return this.translations[locale][scope]; 64 | } 65 | return {}; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /templates/saas-starter/app/kernel/providers/AuthenticationServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthenticationServiceProvider, 3 | PrismaAuthenticationAdapter, 4 | } from "gemi/kernel"; 5 | import { WelcomeEmail } from "@/app/email/WelcomeEmail"; 6 | import { Auth } from "gemi/facades"; 7 | import { prisma } from "@/app/database/prisma"; 8 | import { HttpRequest } from "gemi/http"; 9 | import { GoogleOAuthProvider } from "gemi/services"; 10 | 11 | class SignUpRequest extends HttpRequest { 12 | schema = { 13 | name: { 14 | string: "Invalid name", 15 | "min:2": "Name must be at least 3 characters", 16 | }, 17 | email: { 18 | string: "Invalid email", 19 | required: "Email is required", 20 | email: "Invalid email", 21 | }, 22 | password: { 23 | // required: "Password is required", 24 | "min:8": "Password must be at least 8 characters", 25 | }, 26 | }; 27 | } 28 | 29 | export default class extends AuthenticationServiceProvider { 30 | adapter = new PrismaAuthenticationAdapter(prisma); 31 | 32 | oauthProviders = { 33 | google: new GoogleOAuthProvider(), 34 | }; 35 | 36 | // Path to redirect after successful login 37 | redirectPath = "/dashboard"; 38 | signUpRequest = SignUpRequest; 39 | 40 | sessionExpiresInHours = 999; 41 | sessionAbsoluteExpiresInHours = 999; 42 | 43 | // Change this true to only allow verified emails to login 44 | verifyEmail = false; 45 | 46 | async onSignUp(user: any, token: string) { 47 | // This hook will be called when a user signs up 48 | // You can send email verification here 49 | const magicLink = await Auth.createMagicLink(user.email); 50 | if (magicLink) { 51 | WelcomeEmail.send({ 52 | data: { 53 | name: user.name, 54 | magicLink: `${process.env.HOST_NAME}/auth/sign-in/magic-link?token=${magicLink.token}&email=${user.email}`, 55 | pin: magicLink.pin, 56 | }, 57 | to: [user.email], 58 | }); 59 | } 60 | } 61 | 62 | async onForgotPassword(user: any, token: string) { 63 | // This hook will be called when a user requests a password reset 64 | // You can send password reset email here 65 | } 66 | 67 | async onMagicLinkCreated(user: any, args: any) { 68 | // This hook will be called when a magic link is created 69 | // You can send magic link email here 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /templates/saas-starter/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // tailwind.config.js 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: ["./app/views/**/*.{js,ts,jsx,tsx}"], 6 | theme: { 7 | extend: { 8 | fontFamily: { 9 | sans: [ 10 | 'Arial', 11 | 'sans-serif' 12 | ] 13 | }, 14 | borderRadius: { 15 | lg: 'var(--radius)', 16 | md: 'calc(var(--radius) - 2px)', 17 | sm: 'calc(var(--radius) - 4px)' 18 | }, 19 | colors: { 20 | background: 'hsl(var(--background))', 21 | foreground: 'hsl(var(--foreground))', 22 | card: { 23 | DEFAULT: 'hsl(var(--card))', 24 | foreground: 'hsl(var(--card-foreground))' 25 | }, 26 | popover: { 27 | DEFAULT: 'hsl(var(--popover))', 28 | foreground: 'hsl(var(--popover-foreground))' 29 | }, 30 | primary: { 31 | DEFAULT: 'hsl(var(--primary))', 32 | foreground: 'hsl(var(--primary-foreground))' 33 | }, 34 | secondary: { 35 | DEFAULT: 'hsl(var(--secondary))', 36 | foreground: 'hsl(var(--secondary-foreground))' 37 | }, 38 | muted: { 39 | DEFAULT: 'hsl(var(--muted))', 40 | foreground: 'hsl(var(--muted-foreground))' 41 | }, 42 | accent: { 43 | DEFAULT: 'hsl(var(--accent))', 44 | foreground: 'hsl(var(--accent-foreground))' 45 | }, 46 | destructive: { 47 | DEFAULT: 'hsl(var(--destructive))', 48 | foreground: 'hsl(var(--destructive-foreground))' 49 | }, 50 | border: 'hsl(var(--border))', 51 | input: 'hsl(var(--input))', 52 | ring: 'hsl(var(--ring))', 53 | chart: { 54 | '1': 'hsl(var(--chart-1))', 55 | '2': 'hsl(var(--chart-2))', 56 | '3': 'hsl(var(--chart-3))', 57 | '4': 'hsl(var(--chart-4))', 58 | '5': 'hsl(var(--chart-5))' 59 | }, 60 | sidebar: { 61 | DEFAULT: 'hsl(var(--sidebar-background))', 62 | foreground: 'hsl(var(--sidebar-foreground))', 63 | primary: 'hsl(var(--sidebar-primary))', 64 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 65 | accent: 'hsl(var(--sidebar-accent))', 66 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 67 | border: 'hsl(var(--sidebar-border))', 68 | ring: 'hsl(var(--sidebar-ring))' 69 | } 70 | } 71 | } 72 | }, 73 | darkMode: ["class", "class"], 74 | plugins: [require("tailwindcss-animate")], 75 | }; 76 | -------------------------------------------------------------------------------- /packages/gemi/services/index.ts: -------------------------------------------------------------------------------- 1 | // FileStorage 2 | export { FileStorageServiceProvider } from "./file-storage/FileStorageServiceProvider"; 3 | export { FileSystemDriver } from "./file-storage/drivers/FileSystemDriver"; 4 | export { S3Driver } from "./file-storage/drivers/S3Driver"; 5 | export type { 6 | FileMetadata, 7 | PutFileParams, 8 | ReadFileParams, 9 | } from "./file-storage/drivers/types"; 10 | export { FileStorageDriver } from "./file-storage/drivers/FileStorageDriver"; 11 | 12 | // Ratelimiter 13 | export { RateLimiterServiceProvider } from "./rate-limiter/RateLimiterServiceProvider"; 14 | export { InMemoryRateLimiter } from "./rate-limiter/drivers/InMemoryRateLimiterDriver"; 15 | export { RateLimiterDriver } from "./rate-limiter/drivers/RateLimiterDriver"; 16 | 17 | // Email 18 | export { EmailServiceProvider } from "./email/EmailServiceProvider"; 19 | export { EmailDriver } from "./email/drivers/EmailDriver"; 20 | export { ResendDriver } from "./email/drivers/ResendDriver"; 21 | export type { EmailAttachment, SendEmailParams } from "./email/drivers/types"; 22 | 23 | // Broadcasting 24 | 25 | export { BroadcastingServiceProvider } from "./pubsub/BroadcastingServiceProvider"; 26 | 27 | // Router 28 | export { ViewRouterServiceProvider } from "./router/ViewRouterServiceProvider"; 29 | export { ApiRouterServiceProvider } from "./router/ApiRouterServiceProvider"; 30 | 31 | // Logging 32 | 33 | export { LoggingServiceProvider } from "./logging/LoggingServiceProvider"; 34 | export type { LogEntry } from "./logging/types"; 35 | 36 | // Queue 37 | export { QueueServiceProvider } from "./queue/QueueServiceProvider"; 38 | export { Job } from "./queue/Job"; 39 | 40 | // Image optimization 41 | export { ImageOptimizationServiceProvider } from "./image-optimization/ImageOptimizationServiceProvider"; 42 | export type { 43 | FitEnum, 44 | ResizeParameters, 45 | } from "./image-optimization/drivers/types"; 46 | export { ImageOptimizationDriver } from "./image-optimization/drivers/ImageOptimizationDriver"; 47 | export { Sharp } from "./image-optimization/drivers/SharpDriver"; 48 | 49 | // Auth 50 | export { GoogleOAuthProvider } from "../auth/oauth/GoogleOAuthProvider"; 51 | export { XOAuthProvider } from "../auth/oauth/XOAuthProvider"; 52 | export { OAuthProvider } from "../auth/oauth/OAuthProvider"; 53 | 54 | // Cron 55 | export { CronServiceProvider } from "./cron/CronServiceProvider"; 56 | export { CronJob } from "./cron/CronJob"; 57 | -------------------------------------------------------------------------------- /packages/gemi/client/WebsocketContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | type PropsWithChildren, 4 | useEffect, 5 | useRef, 6 | } from "react"; 7 | 8 | type Subscribe = ( 9 | topic: string, 10 | handler: (event: any) => void, 11 | ) => Promise; 12 | 13 | export const WebSocketContext = createContext( 14 | {} as { 15 | broadcast: (topic: string, payload: Record) => void; 16 | subscribe: Subscribe; 17 | unsubscribe: ( 18 | topic: string, 19 | handler: (event: any) => void, 20 | ) => Promise; 21 | }, 22 | ); 23 | 24 | export const WebSocketContextProvider = (props: PropsWithChildren) => { 25 | const wsRef = useRef(null); 26 | 27 | function getWS() { 28 | return new Promise((resolve) => { 29 | if (wsRef.current) { 30 | resolve(wsRef.current); 31 | } else { 32 | const ws = new WebSocket("ws://localhost:5173/"); 33 | ws.onopen = () => { 34 | wsRef.current = ws; 35 | console.log("ws opened"); 36 | ws.addEventListener("close", () => { 37 | console.log("ws closed"); 38 | wsRef.current = null; 39 | }); 40 | resolve(ws); 41 | }; 42 | } 43 | }); 44 | } 45 | 46 | const subscribe = async ( 47 | topic: string, 48 | handler: (event: MessageEvent) => void, 49 | ) => { 50 | const ws = await getWS(); 51 | ws.send(JSON.stringify({ type: "subscribe", topic })); 52 | ws.addEventListener("message", handler); 53 | }; 54 | 55 | const unsubscribe = async ( 56 | topic: string, 57 | handler: (event: MessageEvent) => void, 58 | ) => { 59 | const ws = await getWS(); 60 | ws.send(JSON.stringify({ type: "unsubscribe", topic })); 61 | ws.removeEventListener("message", handler); 62 | }; 63 | 64 | const broadcast = async (topic: string, payload = {}) => { 65 | const ws = await getWS(); 66 | ws.send( 67 | JSON.stringify({ 68 | type: "broadcast", 69 | topic, 70 | payload, 71 | }), 72 | ); 73 | }; 74 | 75 | useEffect(() => { 76 | return () => { 77 | if (wsRef.current) { 78 | wsRef.current.close(); 79 | wsRef.current = null; 80 | } 81 | }; 82 | }, []); 83 | 84 | return ( 85 | 86 | {props.children} 87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /packages/gemi/bin/gemi.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "bun"; 2 | 3 | import path from "node:path"; 4 | import { startDevServer } from "../server/dev"; 5 | import { startProdServer } from "../server/prod"; 6 | import createRollupInput from "./createRollupInput"; 7 | import { build } from "vite"; 8 | 9 | import { program } from "commander"; 10 | import { ApiManifestGenerator } from "./ide/generateApiManifest"; 11 | 12 | program.command("dev").action(async () => { 13 | console.log("Starting dev server..."); 14 | await startDevServer(); 15 | }); 16 | 17 | program.command("build").action(async () => { 18 | process.env.NODE_ENV = "production"; 19 | const input = await createRollupInput(); 20 | const rootDir = path.resolve(process.cwd()); 21 | const appDir = path.join(rootDir, "app"); 22 | 23 | console.log("Building client..."); 24 | 25 | await $`GEMI_INPUT=${JSON.stringify(input)} vite build --outDir dist/client`; 26 | 27 | console.log("Building server..."); 28 | try { 29 | process.env.GEMI_INPUT = JSON.stringify(input); 30 | await build({ 31 | build: { 32 | ssr: true, 33 | outDir: "dist/server", 34 | rollupOptions: { 35 | input: "app/bootstrap.ts", 36 | external: ["bun", "react", "react-dom", "react/jsx-runtime", "gemi"], 37 | }, 38 | }, 39 | resolve: { 40 | alias: { 41 | "@/app": appDir, 42 | }, 43 | }, 44 | }).then(() => { 45 | console.log("Build succeeded"); 46 | }); 47 | } catch (err) { 48 | console.log(err); 49 | } 50 | process.exit(); 51 | }); 52 | 53 | program.command("start").action(async () => { 54 | process.env.NODE_ENV = "production"; 55 | 56 | await $`echo "Starting server..."`; 57 | await startProdServer(); 58 | }); 59 | 60 | program.command("ide:generate-api-manifest").action(async () => { 61 | const parser = new ApiManifestGenerator(); 62 | await parser.run("/app/http/routes/api.ts"); 63 | }); 64 | 65 | program.command("app:component-tree").action(async () => { 66 | const rootDir = path.resolve(process.cwd()); 67 | const { app } = await import(`${rootDir}/app/bootstrap`); 68 | console.log(app.getComponentTree()); 69 | process.exit(); 70 | }); 71 | 72 | program.command("app:route-manifest").action(async () => { 73 | const rootDir = path.resolve(process.cwd()); 74 | const { app } = await import(`${rootDir}/app/bootstrap`); 75 | console.log(app.getRouteManifest()); 76 | process.exit(); 77 | }); 78 | program.parse(); 79 | -------------------------------------------------------------------------------- /packages/gemi/i18n/Dictionary.ts: -------------------------------------------------------------------------------- 1 | import { I18n } from "../facades"; 2 | import { parseTranslation } from "../utils/parseTranslation"; 3 | import type { 4 | ParseTranslationParams, 5 | ParseTranslationParamsServer, 6 | Prettify, 7 | } from "../utils/type"; 8 | 9 | type Translations = Record>; 10 | 11 | export class Dictionary { 12 | constructor( 13 | public name: string, 14 | public dictionary: T, 15 | ) {} 16 | 17 | reference(key: keyof T) { 18 | if (typeof window !== "undefined") { 19 | throw new Error("Cannot use reference in the browser"); 20 | } 21 | if (!this.dictionary?.[key]) { 22 | throw new Error(`Translation not found for ${String(key)}`); 23 | } 24 | return this.dictionary[key]; 25 | } 26 | 27 | render< 28 | K extends keyof T, 29 | U extends keyof T[K], 30 | R = Prettify>, 31 | >( 32 | key: K, 33 | ...args: R extends Record 34 | ? [args?: { locale?: U | (string & {}) }] 35 | : [args: { locale?: U | (string & {}); params: R }] 36 | ) { 37 | const { locale = I18n.locale(), params } = { 38 | params: {}, 39 | ...args[0], 40 | }; 41 | 42 | if (typeof window !== "undefined") { 43 | throw new Error("Cannot use render in the browser"); 44 | } 45 | 46 | if (!this.dictionary?.[key]?.[locale]) { 47 | throw new Error( 48 | `Translation not found for ${String(key)} in ${String(locale)}`, 49 | ); 50 | } 51 | return parseTranslation(this.dictionary[key][locale], params ?? {}); 52 | } 53 | 54 | static create(name: string, translations: T) { 55 | return new Dictionary(name, translations); 56 | } 57 | 58 | static text, U extends keyof T>( 59 | content: T, 60 | ...args: Prettify> extends Record< 61 | string, 62 | never 63 | > 64 | ? [args?: { locale?: U | (string & {}) }] 65 | : [ 66 | args: { 67 | locale?: U | (string & {}); 68 | params: Prettify>; 69 | }, 70 | ] 71 | ) { 72 | const { locale = I18n.locale(), params } = { params: {}, ...args?.[0] }; 73 | 74 | if (!content?.[locale]) { 75 | throw new Error(`Translation not found for ${String(locale)}`); 76 | } 77 | 78 | return parseTranslation(content[locale], params ?? {}); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/gemi/services/router/createFlatViewRoutes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | 3 | import { createFlatViewRoutes } from "./createFlatViewRoutes"; 4 | import { Controller } from "../../http/Controller"; 5 | import { ViewRouter } from "../../http/ViewRouter"; 6 | 7 | class TestController extends Controller { 8 | test() { 9 | return { data: {} }; 10 | } 11 | } 12 | 13 | class FlatRouter extends ViewRouter { 14 | middlewares = ["auth"]; 15 | 16 | routes = { 17 | "/": this.view("Home", [TestController, "test"]).middleware([ 18 | "homeMiddleware", 19 | ]), 20 | "/about": this.view("About", [TestController, "test"]), 21 | "/pricing": this.view("Pricing", [TestController, "test"]), 22 | "/app": this.layout("PrivateLayout", [TestController, "test"], { 23 | "/": this.view("Dashboard", [TestController, "test"]), 24 | "/settings": this.view("Settings", [TestController, "test"]), 25 | }), 26 | }; 27 | } 28 | 29 | describe.only("createFlatViewRoutes()", () => { 30 | test("FlatRouter", () => { 31 | const result = createFlatViewRoutes({ "/": FlatRouter }); 32 | expect(Object.keys(result)).toEqual([ 33 | "/", 34 | "/about", 35 | "/pricing", 36 | "/foo", 37 | "/foo/bar", 38 | "/foo/bar/baz", 39 | "/foo/bar/cux", 40 | "/app", 41 | "/app/settings", 42 | ]); 43 | 44 | expect(result["/"]).toEqual({ 45 | exec: [expect.any(Function)], 46 | middleware: ["auth", "homeMiddleware"], 47 | }); 48 | 49 | expect(result["/about"]).toEqual({ 50 | exec: [expect.any(Function)], 51 | middleware: ["auth"], 52 | }); 53 | 54 | expect(result["/pricing"]).toEqual({ 55 | exec: [expect.any(Function)], 56 | middleware: ["auth"], 57 | }); 58 | 59 | expect(result["/foo"]).toEqual({ 60 | exec: [expect.any(Function)], 61 | middleware: ["auth"], 62 | }); 63 | 64 | expect(result["/foo/bar"]).toEqual({ 65 | exec: [expect.any(Function)], 66 | middleware: ["auth"], 67 | }); 68 | 69 | expect(result["/foo/bar/baz"]).toEqual({ 70 | exec: [expect.any(Function)], 71 | middleware: ["auth"], 72 | }); 73 | 74 | expect(result["/foo/bar/cux"]).toEqual({ 75 | exec: [expect.any(Function)], 76 | middleware: ["auth"], 77 | }); 78 | 79 | expect(result["/app"]).toEqual({ 80 | exec: [expect.any(Function)], 81 | middleware: ["auth"], 82 | }); 83 | 84 | expect(result["/app/settings"]).toEqual({ 85 | exec: [expect.any(Function)], 86 | middleware: ["auth"], 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/gemi/server/styles.tsx: -------------------------------------------------------------------------------- 1 | import { ModuleNode, type ViteDevServer } from "vite"; 2 | 3 | function replaceStrings(text: string, record: Record): string { 4 | const escapedKeys = Object.keys(record) 5 | .sort((a, b) => b.length - a.length) 6 | .map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); 7 | 8 | // Create a single regex with all keys 9 | const regex = new RegExp(escapedKeys.join("|"), "g"); 10 | 11 | return text.replace(regex, (match) => record[match]); 12 | } 13 | 14 | export async function createDevStyles( 15 | appDir: string, 16 | vite: ViteDevServer, 17 | currentViews: string[] = [], 18 | ) { 19 | const views = [ 20 | ...currentViews.map((view) => `${appDir}/views/${view}.tsx`), 21 | `${appDir}/views/RootLayout.tsx`, 22 | ]; 23 | 24 | let modules = new Set(); 25 | for (const view of views) { 26 | const mod = vite.moduleGraph.getModulesByFile(view); 27 | if (mod) { 28 | modules = modules.union(mod); 29 | } 30 | } 31 | 32 | const styles = []; 33 | const cssModules = []; 34 | const cssModuleContent: Record = {}; 35 | for (const mod of modules as any) { 36 | if (mod) { 37 | for (const imported of mod.importedModules) { 38 | if (imported.file.includes("module.css")) { 39 | cssModuleContent[imported.file] = 40 | imported.ssrTransformResult.map.sourcesContent.join(""); 41 | } 42 | if (imported.file.includes(".css")) { 43 | cssModules.push(imported.file); 44 | } 45 | } 46 | } 47 | } 48 | 49 | for (const cssModulePath of cssModules) { 50 | const transform = await vite.transformRequest(cssModulePath + "?direct"); 51 | 52 | const isCssModule = cssModulePath.includes("module.css"); 53 | 54 | let transformedCssModule = ""; 55 | 56 | if (isCssModule) { 57 | transformedCssModule = replaceStrings( 58 | cssModuleContent[cssModulePath], 59 | transform.default, 60 | ); 61 | } 62 | 63 | styles.push({ 64 | isDev: true, 65 | id: cssModulePath, 66 | content: isCssModule ? transformedCssModule : transform.code, 67 | }); 68 | } 69 | 70 | return styles.map((style, i) => { 71 | return ( 72 | 75 | ); 76 | }); 77 | } 78 | 79 | export async function createStyles(styles = []) { 80 | return styles.map((style, i) => { 81 | return ( 82 | 85 | ); 86 | }); 87 | } 88 | --------------------------------------------------------------------------------