├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── README.md ├── bun.lockb ├── package.json ├── packages ├── beth-stack │ ├── .eslintignore │ ├── .npmignore │ ├── README.md │ ├── how-to-render.png │ ├── package.json │ ├── src │ │ ├── cache │ │ │ ├── index.ts │ │ │ ├── new-persist.ts │ │ │ ├── old-persist.ts │ │ │ ├── render.ts │ │ │ └── tests │ │ │ │ ├── new-persist.test.tsx │ │ │ │ ├── old-persist.test.tsx │ │ │ │ ├── render-cache.test.tsx │ │ │ │ └── swr-behavior.test.tsx │ │ ├── cli │ │ │ ├── build.ts │ │ │ └── index.ts │ │ ├── dev │ │ │ └── index.ts │ │ ├── elysia │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── jsx │ │ │ ├── context.tsx │ │ │ ├── error.tsx │ │ │ ├── htmx.d.ts │ │ │ ├── index.ts │ │ │ ├── jsx.d.ts │ │ │ ├── register.ts │ │ │ ├── render.ts │ │ │ ├── suspense.tsx │ │ │ ├── tests │ │ │ │ ├── context.test.tsx │ │ │ │ ├── error.test.tsx │ │ │ │ └── render.test.tsx │ │ │ └── utils.ts │ │ ├── shared │ │ │ └── global.ts │ │ └── turso │ │ │ ├── index.ts │ │ │ ├── test.ts │ │ │ └── types.ts │ ├── tsconfig.eslint.json │ └── tsconfig.json └── create-beth-app │ ├── .eslintignore │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ ├── asciiArt.ts │ ├── colors.ts │ ├── commander.ts │ ├── index.ts │ └── utils │ │ ├── logger.ts │ │ └── spinner.ts │ ├── tsconfig.eslint.json │ └── tsconfig.json ├── prettier.config.mjs ├── reset.d.ts ├── todo.md ├── tsconfig.json ├── turbo.json └── www └── package.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | plugins: ["isaacscript", "import"], 6 | extends: [ 7 | "plugin:@typescript-eslint/recommended-type-checked", 8 | "plugin:@typescript-eslint/stylistic-type-checked", 9 | "plugin:prettier/recommended", 10 | ], 11 | parserOptions: { 12 | ecmaVersion: "latest", 13 | sourceType: "module", 14 | tsconfigRootDir: __dirname, 15 | project: [ 16 | "./tsconfig.json", 17 | "./cli/tsconfig.eslint.json", // separate eslint config for the CLI since we want to lint and typecheck differently due to template files 18 | "./upgrade/tsconfig.json", 19 | "./www/tsconfig.json", 20 | ], 21 | }, 22 | overrides: [ 23 | // Template files don't have reliable type information 24 | { 25 | files: ["./cli/template/**/*.{ts,tsx}"], 26 | extends: ["plugin:@typescript-eslint/disable-type-checked"], 27 | }, 28 | ], 29 | rules: { 30 | // These off/not-configured-the-way-we-want lint rules we like & opt into 31 | "@typescript-eslint/no-explicit-any": "error", 32 | "@typescript-eslint/no-unused-vars": [ 33 | "error", 34 | { argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" }, 35 | ], 36 | "@typescript-eslint/consistent-type-imports": [ 37 | "error", 38 | { prefer: "type-imports", fixStyle: "inline-type-imports" }, 39 | ], 40 | "import/consistent-type-specifier-style": ["error", "prefer-inline"], 41 | 42 | // For educational purposes we format our comments/jsdoc nicely 43 | "isaacscript/complete-sentences-jsdoc": "warn", 44 | "isaacscript/format-jsdoc-comments": "warn", 45 | 46 | // These lint rules don't make sense for us but are enabled in the preset configs 47 | "@typescript-eslint/no-confusing-void-expression": "off", 48 | "@typescript-eslint/restrict-template-expressions": "off", 49 | 50 | // This rule doesn't seem to be working properly 51 | "@typescript-eslint/prefer-nullish-coalescing": "off", 52 | }, 53 | }; 54 | 55 | module.exports = config; 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | 15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 | 17 | # Runtime data 18 | 19 | pids 20 | _.pid 21 | _.seed 22 | \*.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | 30 | coverage 31 | \*.lcov 32 | 33 | # nyc test coverage 34 | 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | 43 | bower_components 44 | 45 | # node-waf configuration 46 | 47 | .lock-wscript 48 | 49 | # Compiled binary addons (https://nodejs.org/api/addons.html) 50 | 51 | build/Release 52 | 53 | # Dependency directories 54 | 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | 60 | web_modules/ 61 | 62 | # TypeScript cache 63 | 64 | \*.tsbuildinfo 65 | 66 | # Optional npm cache directory 67 | 68 | .npm 69 | 70 | # Optional eslint cache 71 | 72 | .eslintcache 73 | 74 | # Optional stylelint cache 75 | 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | 87 | .node_repl_history 88 | 89 | # Output of 'npm pack' 90 | 91 | \*.tgz 92 | 93 | # Yarn Integrity file 94 | 95 | .yarn-integrity 96 | 97 | # dotenv environment variable files 98 | 99 | .env 100 | .env.development.local 101 | .env.test.local 102 | .env.production.local 103 | .env.local 104 | 105 | # parcel-bundler cache (https://parceljs.org/) 106 | 107 | .cache 108 | .parcel-cache 109 | 110 | # Next.js build output 111 | 112 | .next 113 | out 114 | 115 | # Nuxt.js build / generate output 116 | 117 | .nuxt 118 | dist 119 | 120 | # Gatsby files 121 | 122 | .cache/ 123 | 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | 126 | # https://nextjs.org/blog/next-9-1#public-directory-support 127 | 128 | # public 129 | 130 | # vuepress build output 131 | 132 | .vuepress/dist 133 | 134 | # vuepress v2.x temp and cache directory 135 | 136 | .temp 137 | .cache 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.\* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | tsconfig.tsbuildinfo 175 | beth-cache.sqlite 176 | 177 | .turbo -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 Ethan Niser 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The BETH Stack 2 | 3 | _An opinionated hypermedia-driven architecture for building web apps_ 4 | 5 | ## [Bun](https://bun.sh/), [Elysia](https://elysiajs.com/), [Turso](https://turso.tech/beth), [HTMX](https://htmx.org/) 6 | 7 | Also: [Lucia](https://lucia-auth.com/), [UnoCSS](https://unocss.dev/), [drizzle](https://orm.drizzle.team/), [hyperscript](https://hyperscript.org/) and [fly.io](https://fly.io/) 8 | 9 | Want to discuss more? Join Ethan's Discord: https://discord.gg/Z3yUtMfkwa 10 | 11 | --- 12 | 13 | _Looking for the code from the first video (todo app)?_ \ 14 | [its on the 'PREVIOUS_REPO_ARCHIVE' branch](https://github.com/ethanniser/the-beth-stack/tree/PREVIOUS_REPO_ARCHIVE) 15 | 16 | _Or the code for the full tutorial (b2b ticket app)?_ \ 17 | [its in a different repo](https://github.com/ethanniser/beth-b2b-saas) 18 | 19 | --- 20 | 21 | # This Repo: 22 | 23 | ## `packages/beth-stack`: Contains the `beth-stack` npm package 24 | 25 | - Handles custom jsx runtime + cache 26 | - Elysia Plugin 27 | - Script + Server for hot-reload 28 | - Turso api wrapper 29 | 30 | ## `packages/create-beth-app`: Contains the `create-beth-app` npm package 31 | 32 | - Scaffolds a new BETH app 33 | - very much WIP 34 | 35 | ## `www`: Contains the documentation website 36 | 37 | - TODO 38 | 39 | # Contributions Welcome 40 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanniser/the-beth-stack/e525b5512955e6beefcef5d78697636699cd1ac6/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beth-stack/root", 3 | "dependencies": { 4 | "@changesets/changelog-github": "^0.4.8", 5 | "@changesets/cli": "^2.26.1", 6 | "@ianvs/prettier-plugin-sort-imports": "^4.0.2", 7 | "@manypkg/cli": "^0.20.0", 8 | "@total-typescript/ts-reset": "^0.5.1", 9 | "@types/eslint": "^8.44.2", 10 | "@typescript-eslint/eslint-plugin": "^6.7.0", 11 | "@typescript-eslint/parser": "^6.7.0", 12 | "eslint": "^8.49.0", 13 | "eslint-config-prettier": "^9.0.0", 14 | "eslint-config-turbo": "^1.10.14", 15 | "eslint-plugin-import": "^2.28.1", 16 | "eslint-plugin-isaacscript": "^3.5.5", 17 | "eslint-plugin-prettier": "^5.0.0", 18 | "prettier": "^3.0.3", 19 | "turbo": "^1.10.1", 20 | "typescript": "^5.1.6" 21 | }, 22 | "private": true, 23 | "scripts": { 24 | "build:all": "turbo run build", 25 | "build": "turbo run build --filter \"./packages/*\"", 26 | "clean": "turbo run clean && git clean -xdf node_modules", 27 | "format:check": "prettier --check .", 28 | "format": "prettier --write . --list-different", 29 | "test": "turbo run test", 30 | "test:watch": "turbo run test:watch", 31 | "typecheck": "turbo run typecheck", 32 | "reset-cache": "rm -rf /home/whatplan/.bun/install/cache && rm -rf node_modules && bun i", 33 | "lint": "turbo lint && manypkg check", 34 | "lint:fix": "turbo lint:fix && manypkg fix", 35 | "check": "turbo lint typecheck format:check && manypkg check", 36 | "release": "changeset version" 37 | }, 38 | "type": "module", 39 | "workspaces": [ 40 | "packages/*", 41 | "www" 42 | ], 43 | "devDependencies": { 44 | "@types/shelljs": "^0.8.15" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/beth-stack/.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/beth-stack/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !src/**/* 4 | !dist/**/* 5 | !package.json 6 | !LICENSE 7 | !README.md 8 | !tsconfig.json 9 | -------------------------------------------------------------------------------- /packages/beth-stack/README.md: -------------------------------------------------------------------------------- 1 | FORK OF [kitajs/html](https://github.com/kitajs/html) 2 | GO GIVE IT A STAR AND USE ITS TS PLUGIN (its really good) 3 | 4 | adds: 5 | 6 | - async components 7 | - 'cache' deduplication per render 8 | - `Suspense` http streaming 9 | - (in development) `persistedCache` to either memory of sqlite json 10 | - can be interval revalidated or manually revalidated by tag 11 | -------------------------------------------------------------------------------- /packages/beth-stack/how-to-render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanniser/the-beth-stack/e525b5512955e6beefcef5d78697636699cd1ac6/packages/beth-stack/how-to-render.png -------------------------------------------------------------------------------- /packages/beth-stack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beth-stack", 3 | "version": "0.0.34", 4 | "type": "module", 5 | "bin": "./dist/cli/index.js", 6 | "exports": { 7 | ".": "./src/index.ts", 8 | "./jsx": "./src/jsx/index.ts", 9 | "./jsx/register": "./src/jsx/register.ts", 10 | "./cache": "./src/cache/index.ts", 11 | "./elysia": "./src/elysia/index.ts", 12 | "./dev": "./src/dev/index.ts", 13 | "./turso": "./src/turso/index.ts" 14 | }, 15 | "scripts": { 16 | "typecheck": "bunx --bun tsc", 17 | "test": "bun test", 18 | "test:watch": "bun test --watch", 19 | "build": "bun run ./src/cli/build.ts", 20 | "clean": "rm -rf dist .turbo node_modules", 21 | "lint": "eslint . --report-unused-disable-directives", 22 | "lint:fix": "pnpm lint --fix", 23 | "release": "changeset version" 24 | }, 25 | "devDependencies": { 26 | "bun-types": "latest", 27 | "csstype": "^3.1.2", 28 | "elysia": "0.7.0-beta.1" 29 | }, 30 | "peerDependencies": { 31 | "typescript": "^5.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/index.ts: -------------------------------------------------------------------------------- 1 | export { cache } from "./render"; 2 | // export { 3 | // persistedCache, 4 | // revalidateTag, 5 | // setGlobalPersistCacheConfig, 6 | // } from "./persist"; 7 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/new-persist.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, unlinkSync } from "node:fs"; 2 | import { Database } from "bun:sqlite"; 3 | import { BETH_GLOBAL_PERSISTED_CACHE } from "../shared/global"; 4 | import { cache } from "./render"; 5 | 6 | type Brand = K & { __brand: T }; 7 | type FunctionKey = Brand; 8 | type ArgKey = Brand; 9 | 10 | type DeepPartial = { 11 | [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; 12 | }; 13 | 14 | export type CacheOptions = { 15 | persist?: "memory" | "json"; 16 | revalidate?: number; 17 | tags?: string[]; 18 | seedImmediately?: 19 | | boolean 20 | | { 21 | initialArgs: any[]; 22 | } 23 | | { 24 | multipleInitialArgs: any[][]; 25 | }; 26 | }; 27 | 28 | export type GlobalCacheConfig = { 29 | log: "debug" | "major" | "none"; 30 | defaultCacheOptions: Required; 31 | errorHandling: Required; 32 | returnStaleWhileRevalidating: boolean; 33 | }; 34 | 35 | type ErrorHandlingConfig = { 36 | duringRevalidation?: "return-stale" | "rethrow" | "rerun on next call"; 37 | duringRevalidationWhileUnseeded?: "rerun on next call" | "rethrow"; 38 | duringImmediateSeed?: "rerun on next call" | "rethrow"; 39 | }; 40 | 41 | const startingConfig: GlobalCacheConfig = { 42 | log: "major", 43 | defaultCacheOptions: { 44 | persist: "json", 45 | revalidate: Infinity, 46 | tags: [], 47 | seedImmediately: true, 48 | }, 49 | errorHandling: { 50 | duringRevalidation: "return-stale", 51 | duringRevalidationWhileUnseeded: "rerun on next call", 52 | duringImmediateSeed: "rerun on next call", 53 | }, 54 | returnStaleWhileRevalidating: true, 55 | }; 56 | 57 | export declare function persistedCache Promise>( 58 | callBack: T, 59 | key: string, 60 | options?: CacheOptions, 61 | ): T; 62 | 63 | // returns promise that resolves when all data with the tag have completed revalidation 64 | export declare function revalidateTag(tag: string): Promise; 65 | 66 | // if null, will use default config 67 | export declare function setGlobalPersistCacheConfig( 68 | config: DeepPartial | null, 69 | ): void; 70 | 71 | type StoredCache = { 72 | get: (key: string) => any; 73 | set: (key: string, value: any) => void; 74 | }; 75 | 76 | class BethMemoryCache implements StoredCache { 77 | private cache: Map; 78 | constructor() { 79 | this.cache = new Map(); 80 | } 81 | public get(key: string) { 82 | const result = this.cache.get(key); 83 | if (result) { 84 | return result; 85 | } else { 86 | throw new Error( 87 | `No entry found in memory cache when one was expected: ${key}`, 88 | ); 89 | } 90 | } 91 | public set(key: string, value: any) { 92 | this.cache.set(key, value); 93 | } 94 | } 95 | class BethJsonCache implements StoredCache { 96 | private db: Database; 97 | constructor() { 98 | if (existsSync("beth-cache.sqlite")) { 99 | unlinkSync("beth-cache.sqlite"); 100 | } 101 | 102 | this.db = new Database("beth-cache.sqlite"); 103 | this.db.exec( 104 | "CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL)", 105 | ); 106 | } 107 | public get(key: string) { 108 | const result = this.db 109 | .query("SELECT value FROM cache WHERE key = ?") 110 | .get(key) as { value: string } | undefined; 111 | if (result) { 112 | return JSON.parse(result.value); 113 | } else { 114 | throw new Error( 115 | `No entry found in json cache when one was expected: ${key}`, 116 | ); 117 | } 118 | } 119 | public set(key: string, value: any) { 120 | this.db 121 | .query( 122 | ` 123 | INSERT INTO cache (key, value) 124 | VALUES (?, ?) 125 | ON CONFLICT (key) DO UPDATE SET value = excluded.value; 126 | `, 127 | ) 128 | .run(key, JSON.stringify(value)); 129 | } 130 | } 131 | 132 | class InvariantError extends Error { 133 | constructor(message: string) { 134 | super( 135 | `${message} - THIS SHOULD NEVER HAPPEN - PLEASE OPEN AN ISSUE ethanniser/beth-stack`, 136 | ); 137 | } 138 | } 139 | 140 | export class BethPersistedCache { 141 | private config: GlobalCacheConfig = startingConfig; 142 | private primaryMap: Map< 143 | FunctionKey, 144 | { 145 | callBack: () => Promise; 146 | tags: string[]; 147 | location: "memory" | "json"; 148 | argsMap: Map< 149 | any[], 150 | { 151 | argsKey: ArgKey; 152 | status: 153 | | "pending" 154 | | "unseeded" 155 | | "seeded" 156 | | "unseeded-error" 157 | | "seeded-error"; 158 | } 159 | >; 160 | } 161 | > = new Map(); 162 | private pendingMap: Map> = new Map(); 163 | private erroredMap: Map = new Map(); 164 | private inMemoryDataCache: BethMemoryCache = new BethMemoryCache(); 165 | private jsonDataCache: BethJsonCache = new BethJsonCache(); 166 | private intervals: Set = new Set(); 167 | private keys: Set = new Set(); 168 | 169 | constructor() {} 170 | 171 | public setConfig(config: Partial): void {} 172 | 173 | public clearAllIntervals(): void { 174 | this.intervals.forEach((interval) => clearInterval(interval)); 175 | } 176 | public purgeAllCachedData(): void { 177 | this.primaryMap = new Map(); 178 | this.erroredMap = new Map(); 179 | this.inMemoryDataCache = new BethMemoryCache(); 180 | this.jsonDataCache = new BethJsonCache(); 181 | this.primaryMap.forEach((fnData, key) => { 182 | fnData.argsMap.forEach((fnData, key) => { 183 | fnData.status = "unseeded"; 184 | }); 185 | }); 186 | } 187 | 188 | public initializeEntry( 189 | callBack: () => Promise, 190 | key: FunctionKey, 191 | options?: CacheOptions, 192 | ): void {} 193 | public getCachedValue(key: string, ...args: any[]): any {} 194 | 195 | public async revalidateTag(tag: string): Promise {} 196 | 197 | private logDebug(): void {} 198 | 199 | private log(): void {} 200 | 201 | private rerunCallBack(key: FunctionKey) {} 202 | 203 | private setInterval(key: FunctionKey, revalidate: number) {} 204 | } 205 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/old-persist.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, unlinkSync } from "node:fs"; 2 | import { Database } from "bun:sqlite"; 3 | import { BETH_GLOBAL_PERSISTED_CACHE } from "../shared/global"; 4 | import { cache } from "./render"; 5 | 6 | export type CacheOptions = { 7 | persist?: "memory" | "json"; 8 | revalidate?: number; 9 | tags?: string[]; 10 | seedImmediately?: 11 | | boolean 12 | | { 13 | initialArgs: any[] | any[][]; 14 | }; 15 | }; 16 | 17 | export type GlobalCacheConfig = { 18 | log: boolean; 19 | defaultCacheOptions: Required; 20 | returnStaleWhileRevalidate: boolean; 21 | onRevalidateErrorReturnStale: boolean; 22 | rethrowOnUnseededError: boolean; 23 | }; 24 | 25 | export function persistedCache Promise>( 26 | callBack: T, 27 | key: string, 28 | options?: CacheOptions, 29 | ): T { 30 | const filledOptions = { 31 | ...BETH_GLOBAL_PERSISTED_CACHE.getDefaultOptions(), 32 | ...options, 33 | }; 34 | 35 | if (globalThis.RENDER_COUNT <= 1) { 36 | BETH_GLOBAL_PERSISTED_CACHE.seed({ 37 | callBack, 38 | key, 39 | options: filledOptions, 40 | }); 41 | } 42 | return cache(() => 43 | BETH_GLOBAL_PERSISTED_CACHE.getCachedValue(key, filledOptions.persist), 44 | ) as T; 45 | } 46 | 47 | // returns promise that resolves when all data with the tag have completed revalidation 48 | export async function revalidateTag(tag: string): Promise { 49 | return BETH_GLOBAL_PERSISTED_CACHE.revalidateTag(tag); 50 | } 51 | 52 | export function setGlobalPersistCacheConfig( 53 | config: Partial, 54 | ) { 55 | BETH_GLOBAL_PERSISTED_CACHE.setConfig(config); 56 | } 57 | 58 | class CacheNotFound extends Error { 59 | constructor( 60 | message: string, 61 | readonly location: "memory" | "json", 62 | ) { 63 | super(message); 64 | } 65 | } 66 | 67 | export class BethPersistCache { 68 | private callBackMap: Map< 69 | string, 70 | { 71 | callBack: () => Promise; 72 | tags: string[]; 73 | location: "memory" | "json"; 74 | } 75 | >; 76 | private pendingMap: Map>; 77 | private inMemoryDataCache: Map; 78 | private jsonDataCache: Database; 79 | private intervals: Set; 80 | private keys: Set; 81 | private inInitialLoad: Set; 82 | private config: GlobalCacheConfig; 83 | private neverSeeded: Set; 84 | private toReThrow: Map; 85 | 86 | constructor() { 87 | this.callBackMap = new Map(); 88 | this.inMemoryDataCache = new Map(); 89 | this.intervals = new Set(); 90 | this.pendingMap = new Map(); 91 | this.keys = new Set(); 92 | this.inInitialLoad = new Set(); 93 | this.neverSeeded = new Set(); 94 | this.toReThrow = new Map(); 95 | this.config = { 96 | log: false, 97 | defaultCacheOptions: { 98 | persist: "json", 99 | revalidate: Infinity, 100 | tags: [], 101 | seedImmediately: true, 102 | }, 103 | returnStaleWhileRevalidate: true, 104 | onRevalidateErrorReturnStale: true, 105 | rethrowOnUnseededError: false, 106 | }; 107 | 108 | if (existsSync("beth-cache.sqlite")) unlinkSync("beth-cache.sqlite"); 109 | this.jsonDataCache = new Database("beth-cache.sqlite", { 110 | readwrite: true, 111 | create: true, 112 | }); 113 | this.jsonDataCache.run(` 114 | CREATE TABLE IF NOT EXISTS cache ( 115 | key TEXT PRIMARY KEY, 116 | value TEXT NOT NULL 117 | ); 118 | `); 119 | } 120 | 121 | public setConfig(config: Partial) { 122 | this.config.defaultCacheOptions = { 123 | ...this.config.defaultCacheOptions, 124 | ...config.defaultCacheOptions, 125 | }; 126 | 127 | this.config.log = config.log ?? this.config.log; 128 | this.config.returnStaleWhileRevalidate = 129 | config.returnStaleWhileRevalidate ?? 130 | this.config.returnStaleWhileRevalidate; 131 | 132 | this.config.onRevalidateErrorReturnStale = 133 | config.onRevalidateErrorReturnStale ?? 134 | this.config.onRevalidateErrorReturnStale; 135 | } 136 | 137 | private setJsonCache(key: string, value: any) { 138 | this.jsonDataCache.run( 139 | ` 140 | INSERT INTO cache (key, value) 141 | VALUES (?, ?) 142 | ON CONFLICT (key) DO UPDATE SET value = excluded.value; 143 | `, 144 | [key, JSON.stringify(value)], 145 | ); 146 | } 147 | 148 | private getJsonCache(key: string) { 149 | const result = this.jsonDataCache 150 | .query("SELECT value FROM cache WHERE key = ?") 151 | .get(key) as { value: string } | undefined; 152 | if (!result) { 153 | throw new CacheNotFound( 154 | `No entry found in json cache when one was expected: ${key}`, 155 | "json", 156 | ); 157 | } 158 | return JSON.parse(result.value); 159 | } 160 | 161 | public seed({ 162 | key, 163 | callBack, 164 | options, 165 | }: { 166 | callBack: () => Promise; 167 | key: string; 168 | options?: CacheOptions; 169 | }) { 170 | const { 171 | persist: location, 172 | revalidate, 173 | tags, 174 | } = { 175 | ...this.config.defaultCacheOptions, 176 | ...options, 177 | }; 178 | 179 | if (this.keys.has(key)) { 180 | throw new Error( 181 | `Persistant Cache Key already exists: ${key} - these much be unqiue across your entire app`, 182 | ); 183 | } else { 184 | this.keys.add(key); 185 | } 186 | 187 | this.callBackMap.set(key, { 188 | callBack, 189 | tags, 190 | location, 191 | }); 192 | 193 | if (this.config.log) { 194 | console.log("Initial Callback run to seed cache: ", key); 195 | } 196 | 197 | const promise = callBack(); 198 | this.inInitialLoad.add(key); 199 | this.pendingMap.set(key, promise); 200 | 201 | promise 202 | .then((value) => { 203 | if (this.config.log) console.log(`Seeding ${location} Cache:`, key); 204 | if (location === "memory") { 205 | this.inMemoryDataCache.set(key, value); 206 | } else if (location === "json") { 207 | this.setJsonCache(key, value); 208 | } 209 | this.inInitialLoad.delete(key); 210 | this.pendingMap.delete(key); 211 | if (revalidate > 0) { 212 | this.setInterval(key, revalidate); 213 | } 214 | }) 215 | .catch((e) => { 216 | this.inInitialLoad.delete(key); 217 | this.pendingMap.delete(key); 218 | this.neverSeeded.add(key); 219 | if (this.config.log) console.log(`Initial Callback Errored:`, key); 220 | }); 221 | } 222 | 223 | private rerunCallBack(key: string) { 224 | const pending = this.pendingMap.get(key); 225 | if (pending) { 226 | if (this.config.log) console.log("PENDING CACHE HIT:", key); 227 | return pending; 228 | } 229 | 230 | if (this.config.log) console.log("Rerunning callback:", key); 231 | const result = this.callBackMap.get(key); 232 | if (!result) { 233 | throw new Error("No callback found for key: " + key); 234 | } 235 | const { callBack, location } = result; 236 | const callBackPromise = callBack(); 237 | this.pendingMap.set(key, callBackPromise); 238 | callBackPromise 239 | .then((value) => { 240 | if (this.config.log) 241 | console.log(`Callback complete, setting ${location} cache:`, key); 242 | if (location === "memory") { 243 | this.inMemoryDataCache.set(key, value); 244 | } else if (location === "json") { 245 | this.setJsonCache(key, value); 246 | } 247 | this.toReThrow.delete(key); 248 | this.pendingMap.delete(key); 249 | }) 250 | .catch((e) => { 251 | this.pendingMap.delete(key); 252 | if (this.config.log) console.log(`Rerunning callback Errored:`, key); 253 | if (this.config.onRevalidateErrorReturnStale) { 254 | if (this.config.log) 255 | console.log(`Returning stale data dispite error:`, key); 256 | return this.getCachedValue(key, location); 257 | } else { 258 | if (this.neverSeeded.has(key)) { 259 | if (this.config.log) 260 | console.log( 261 | "Never seeded revalidation errored, remaining never seeded:", 262 | key, 263 | ); 264 | } else { 265 | console.log("Revalidating Errored, storing to rethrow:", key); 266 | this.toReThrow.set(key, e); 267 | } 268 | } 269 | }); 270 | return callBackPromise; 271 | } 272 | 273 | private setInterval(key: string, revalidate: number) { 274 | if (revalidate === Infinity) { 275 | if (this.config.log) console.log("No revalidate interval for:", key); 276 | return; 277 | } 278 | const interval = setInterval(() => { 279 | if (this.config.log) 280 | console.log(`Cache Revalidating (on ${revalidate}s interval):`, key); 281 | this.rerunCallBack(key); 282 | }, revalidate * 1000); 283 | if (this.config.log) 284 | console.log("Setting Revalidate Interval:", key, revalidate); 285 | this.intervals.add(interval); 286 | } 287 | 288 | private getMemoryCache(key: string) { 289 | const cacheResult = this.inMemoryDataCache.get(key); 290 | if (cacheResult) { 291 | return cacheResult; 292 | } else { 293 | throw new CacheNotFound( 294 | `No entry found in memory cache when one was expected: ${key}`, 295 | "memory", 296 | ); 297 | } 298 | } 299 | 300 | public async revalidateTag(tag: string): Promise { 301 | if (this.config.log) console.log("Revalidating tag:", tag); 302 | const revalidatePromises: Promise[] = []; 303 | this.callBackMap.forEach((value, key) => { 304 | if (value.tags.includes(tag)) { 305 | const done = this.rerunCallBack(key); 306 | revalidatePromises.push(done); 307 | } 308 | }); 309 | return Promise.allSettled(revalidatePromises).then(() => void 0); 310 | } 311 | 312 | public getCachedValue(key: string, cache: "memory" | "json") { 313 | if (this.toReThrow.has(key)) { 314 | const error = this.toReThrow.get(key); 315 | this.toReThrow.delete(key); 316 | if (this.config.log) 317 | console.log( 318 | "Rethrowing Error from last revalidation (this not the default and enabled by config):", 319 | key, 320 | ); 321 | throw error; 322 | } 323 | try { 324 | // if SWR is turned off, and the revalidation is in progress, return the pending promise 325 | // even if SWR is on, during the initial load, we have nothing else to return 326 | const pending = this.pendingMap.get(key); 327 | const inInitialLoad = this.inInitialLoad.has(key); 328 | const SWR = this.config.returnStaleWhileRevalidate; 329 | const neverSeeded = this.neverSeeded.has(key); 330 | 331 | // console.log("DEBUG", { pending, inInitialLoad, SWR }); 332 | 333 | if (neverSeeded) { 334 | if (this.config.log) 335 | console.log("Never Seeded - Rerunning Callback:", key); 336 | const result = this.rerunCallBack(key); 337 | 338 | result.then(() => { 339 | this.neverSeeded.delete(key); 340 | }); 341 | return result; 342 | } 343 | 344 | if (pending && inInitialLoad) { 345 | // if we are in the initial load, we have nothing else to return except the pending promise 346 | if (this.config.log) console.log("Hit Initial Load Pending:", key); 347 | return pending; 348 | } else if (pending && !SWR) { 349 | // if revalidation is in progress, and SWR is turned off, return the pending promise 350 | if (this.config.log) console.log("Pending Cache HIT:", key); 351 | return pending; 352 | } else { 353 | // at this point either we are either just at a standard cache hit 354 | // or a stale hit (if SWR is turned on) 355 | // so we can return the cached value, and log based off pending (if it exists its a stale hit) 356 | if (cache === "memory") { 357 | if (this.config.log) { 358 | if (pending) { 359 | console.log(`Memory Cache STALE HIT:`, key); 360 | } else { 361 | console.log(`Memory Cache HIT:`, key); 362 | } 363 | } 364 | return this.getMemoryCache(key); 365 | } else if (cache === "json") { 366 | if (this.config.log) { 367 | if (pending) { 368 | console.log(`JSON Cache STALE HIT:`, key); 369 | } else { 370 | console.log(`JSON Cache HIT:`, key); 371 | } 372 | } 373 | return this.getJsonCache(key); 374 | } 375 | } 376 | } catch (e) { 377 | if (e instanceof CacheNotFound) { 378 | return this.rerunCallBack(key); 379 | } else { 380 | throw e; 381 | } 382 | } 383 | } 384 | 385 | public getDefaultOptions() { 386 | return this.config.defaultCacheOptions; 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/render.ts: -------------------------------------------------------------------------------- 1 | import { Children } from "../jsx"; 2 | import { BETH_GLOBAL_RENDER_CACHE } from "../shared/global"; 3 | 4 | export function cache any>(fn: T): T { 5 | return ((...args: unknown[]) => { 6 | const cachedResults = BETH_GLOBAL_RENDER_CACHE.dedupeCache.get(fn); 7 | 8 | if (cachedResults) { 9 | for (let [keyArgs, cachedValue] of cachedResults.entries()) { 10 | if (Bun.deepEquals(args, keyArgs, true)) { 11 | if (cachedValue.type === "error") { 12 | console.log("throwing cached error"); 13 | throw cachedValue.error; 14 | } else { 15 | console.log("returning cached value"); 16 | return cachedValue.value; 17 | } 18 | } 19 | } 20 | } else { 21 | const newCache = new Map(); 22 | 23 | BETH_GLOBAL_RENDER_CACHE.dedupeCache.set(fn, newCache); 24 | 25 | try { 26 | const functionResult = fn(...args); 27 | newCache.set(args, { type: "result", value: functionResult }); 28 | return functionResult; 29 | } catch (error) { 30 | newCache.set(args, { type: "error", error }); 31 | throw error; 32 | } 33 | } 34 | }) as T; 35 | } 36 | 37 | type CachedValue = 38 | | { type: "result"; value: T } 39 | | { type: "error"; error: any }; 40 | 41 | export class BethRenderCache { 42 | public dedupeCache: WeakMap, CachedValue>>; 43 | public streamController: ReadableStreamDefaultController | undefined; 44 | public counter: number; 45 | private suspenseMap: Map; 46 | public sentFirstChunk: boolean; 47 | 48 | constructor() { 49 | this.dedupeCache = new WeakMap(); 50 | this.streamController = undefined; 51 | this.counter = 1; 52 | this.suspenseMap = new Map(); 53 | this.sentFirstChunk = false; 54 | } 55 | 56 | public reset() { 57 | this.dedupeCache = new WeakMap(); 58 | this.streamController = undefined; 59 | this.counter = 1; 60 | this.suspenseMap = new Map(); 61 | this.sentFirstChunk = false; 62 | } 63 | 64 | public registerChild(child: Children[]): number { 65 | const id = this.counter++; 66 | this.suspenseMap.set(child, id); 67 | return id; 68 | } 69 | 70 | public dismissChild(child: Children[]): number | undefined { 71 | const id = this.suspenseMap.get(child); 72 | if (id) { 73 | this.suspenseMap.delete(child); 74 | } 75 | return id; 76 | } 77 | 78 | public closeNow() { 79 | this.streamController?.close(); 80 | this.reset(); 81 | } 82 | 83 | public checkIfEndAndClose() { 84 | if (this.suspenseMap.size === 0) { 85 | this.closeNow(); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/tests/new-persist.test.tsx: -------------------------------------------------------------------------------- 1 | import "../../shared/global"; 2 | import { beforeEach, describe, expect, test } from "bun:test"; 3 | import { 4 | persistedCache, 5 | revalidateTag, 6 | setGlobalPersistCacheConfig, 7 | } from "../new-persist"; 8 | import "../../jsx/register"; 9 | import { renderToString } from "../../jsx/render"; 10 | 11 | beforeEach(() => { 12 | setGlobalPersistCacheConfig(null); 13 | BETH_GLOBAL_PERSISTED_CACHE.clearAllIntervals(); 14 | BETH_GLOBAL_PERSISTED_CACHE.purgeAllCachedData(); 15 | }); 16 | 17 | let cacheKey = 0; 18 | function getCacheKey() { 19 | return (cacheKey++).toString(); 20 | } 21 | 22 | describe("basic operations", () => { 23 | test("throws on duplicate key", () => { 24 | let getCount = async () => 1; 25 | const _ = persistedCache(getCount, "cache key"); 26 | let error; 27 | try { 28 | persistedCache(getCount, "cache key"); 29 | } catch (e) { 30 | error = e; 31 | } 32 | expect(error).toBe(Error); 33 | }); 34 | 35 | test("dedupes like normal 'cache'", async () => { 36 | let count = 0; 37 | let getCount = async () => ++count; 38 | let cachedGetCount = persistedCache(getCount, getCacheKey()); 39 | 40 | const Component = async () => { 41 | const count = await cachedGetCount(); 42 | return
{count}
; 43 | }; 44 | 45 | const html = await renderToString(() => ( 46 |
47 | 48 | 49 | 50 |
51 | )); 52 | 53 | expect(html).toBe( 54 | ` 55 |
56 |
1
57 |
1
58 |
1
59 |
60 | `.replace(/\s+/g, ""), 61 | ); 62 | }); 63 | 64 | test("holds value between renders", async () => { 65 | let count = 0; 66 | let getCount = async () => ++count; 67 | let cachedGetCount = persistedCache(getCount, getCacheKey()); 68 | 69 | const Component = async () => { 70 | const count = await cachedGetCount(); 71 | return
{count}
; 72 | }; 73 | 74 | const html = await renderToString(() => ( 75 |
76 | 77 | 78 | 79 |
80 | )); 81 | 82 | expect(html).toBe( 83 | ` 84 |
85 |
1
86 |
1
87 |
1
88 |
89 | `.replace(/\s+/g, ""), 90 | ); 91 | 92 | const html2 = await renderToString(() => ( 93 |
94 | 95 | 96 | 97 |
98 | )); 99 | 100 | expect(html2).toBe( 101 | ` 102 |
103 |
1
104 |
1
105 |
1
106 |
107 | `.replace(/\s+/g, ""), 108 | ); 109 | }); 110 | }); 111 | 112 | describe("immediate seeding", () => { 113 | test("by default the cache function is run immediately", async () => { 114 | let count = 0; 115 | let getCount = async () => ++count; 116 | let cachedGetCount = persistedCache(getCount, getCacheKey()); 117 | 118 | expect(count).toBe(1); 119 | 120 | const Component = async () => { 121 | const count = await cachedGetCount(); 122 | return
{count}
; 123 | }; 124 | 125 | const html = await renderToString(() => ( 126 |
127 | 128 | 129 | 130 |
131 | )); 132 | 133 | expect(html).toBe( 134 | ` 135 |
136 |
1
137 |
1
138 |
1
139 |
140 | `.replace(/\s+/g, ""), 141 | ); 142 | }); 143 | test("can be disabled to not", async () => { 144 | let count = 0; 145 | let getCount = async () => ++count; 146 | let cachedGetCount = persistedCache(getCount, getCacheKey(), { 147 | seedImmediately: false, 148 | }); 149 | 150 | expect(count).toBe(0); 151 | 152 | const Component = async () => { 153 | const count = await cachedGetCount(); 154 | return
{count}
; 155 | }; 156 | 157 | const html = await renderToString(() => ( 158 |
159 | 160 | 161 | 162 |
163 | )); 164 | 165 | expect(html).toBe( 166 | ` 167 |
168 |
1
169 |
1
170 |
1
171 |
172 | `.replace(/\s+/g, ""), 173 | ); 174 | }); 175 | }); 176 | 177 | describe("interval revalidation", () => { 178 | test("by default interval is off", async () => { 179 | let count = 0; 180 | let getCount = async () => ++count; 181 | let cachedGetCount = persistedCache(getCount, getCacheKey()); 182 | 183 | expect(count).toBe(1); 184 | 185 | const Component = async () => { 186 | const count = await cachedGetCount(); 187 | return
{count}
; 188 | }; 189 | 190 | const html = await renderToString(() => ( 191 |
192 | 193 | 194 | 195 |
196 | )); 197 | 198 | expect(html).toBe( 199 | ` 200 |
201 |
1
202 |
1
203 |
1
204 |
205 | `.replace(/\s+/g, ""), 206 | ); 207 | 208 | await new Promise((resolve) => 209 | setTimeout(async () => { 210 | const html2 = await renderToString(() => ( 211 | <> 212 | 213 | 214 | 215 | )); 216 | 217 | expect(html2).toBe(`
1
1
`); 218 | 219 | resolve(void 0); 220 | }, 1100), 221 | ); 222 | }); 223 | test("interval reruns callback", async () => { 224 | let count = 0; 225 | const getCount = async () => ++count; 226 | const cachedGetCount = persistedCache(getCount, getCacheKey(), { 227 | revalidate: 1, 228 | }); 229 | 230 | const Component = async () => { 231 | const count = await cachedGetCount(); 232 | return
{count}
; 233 | }; 234 | 235 | const html = await renderToString(() => ( 236 | <> 237 | 238 | 239 | 240 | )); 241 | 242 | expect(html).toBe(`
1
1
`); 243 | 244 | count++; 245 | 246 | // should the be same right away 247 | 248 | const html2 = await renderToString(() => ( 249 | <> 250 | 251 | 252 | 253 | )); 254 | 255 | expect(html2).toBe(`
1
1
`); 256 | 257 | // and the same until a second has passed 258 | 259 | await new Promise((resolve) => 260 | setTimeout(async () => { 261 | const html3 = await renderToString(() => ( 262 | <> 263 | 264 | 265 | 266 | )); 267 | 268 | expect(html3).toBe(`
1
1
`); 269 | 270 | resolve(void 0); 271 | }, 500), 272 | ); 273 | 274 | // but after a second it should be different 275 | 276 | await new Promise((resolve) => 277 | setTimeout(async () => { 278 | const html3 = await renderToString(() => ( 279 | <> 280 | 281 | 282 | 283 | )); 284 | 285 | expect(html3).toBe(`
3
3
`); 286 | 287 | resolve(void 0); 288 | }, 1100), 289 | ); 290 | }); 291 | test("interval with Infinity or 0 is ignored", async () => { 292 | let count = 0; 293 | const getCount = async () => ++count; 294 | 295 | // Infinity revalidation interval 296 | const cachedGetCountInfinity = persistedCache(getCount, getCacheKey(), { 297 | revalidate: Infinity, 298 | }); 299 | 300 | // 0 revalidation interval 301 | const cachedGetCountZero = persistedCache(getCount, getCacheKey(), { 302 | revalidate: 0, 303 | }); 304 | 305 | const ComponentInfinity = async () => { 306 | const count = await cachedGetCountInfinity(); 307 | return
{count}
; 308 | }; 309 | 310 | const ComponentZero = async () => { 311 | const count = await cachedGetCountZero(); 312 | return
{count}
; 313 | }; 314 | 315 | const htmlInfinity = await renderToString(() => ); 316 | const htmlZero = await renderToString(() => ); 317 | 318 | expect(htmlInfinity).toBe(`
1
`); 319 | expect(htmlZero).toBe(`
2
`); 320 | 321 | // Increment count and wait for a short period 322 | count++; 323 | 324 | await new Promise((resolve) => setTimeout(resolve, 1100)); 325 | 326 | const htmlInfinityAfterDelay = await renderToString(() => ( 327 | 328 | )); 329 | const htmlZeroAfterDelay = await renderToString(() => ); 330 | 331 | // Even after the delay, the rendered values should not change as the revalidation interval is Infinity or 0. 332 | expect(htmlInfinityAfterDelay).toBe(`
1
`); 333 | expect(htmlZeroAfterDelay).toBe(`
2
`); 334 | }); 335 | }); 336 | 337 | // TODO 338 | describe("manual revalidation", () => { 339 | test("by default no tags are applied", async () => { 340 | let count = 0; 341 | let getCount = async () => ++count; 342 | let cachedGetCount = persistedCache(getCount, getCacheKey()); 343 | 344 | expect(count).toBe(1); 345 | 346 | const Component = async () => { 347 | const count = await cachedGetCount(); 348 | return
{count}
; 349 | }; 350 | 351 | const html = await renderToString(() => ( 352 |
353 | 354 | 355 | 356 |
357 | )); 358 | 359 | expect(html).toBe( 360 | ` 361 |
362 |
1
363 |
1
364 |
1
365 |
366 | `.replace(/\s+/g, ""), 367 | ); 368 | 369 | await revalidateTag("tag"); 370 | 371 | const html2 = await renderToString(() => ( 372 | <> 373 | 374 | 375 | 376 | )); 377 | 378 | expect(html2).toBe(`
1
1
`); 379 | }); 380 | test("tags can be revalidated", async () => { 381 | let count = 0; 382 | let getCount = async () => ++count; 383 | let cachedGetCount = persistedCache(getCount, getCacheKey(), { 384 | tags: ["tag"], 385 | }); 386 | 387 | expect(count).toBe(1); 388 | 389 | const Component = async () => { 390 | const count = await cachedGetCount(); 391 | return
{count}
; 392 | }; 393 | 394 | const html = await renderToString(() => ( 395 |
396 | 397 | 398 | 399 |
400 | )); 401 | 402 | expect(html).toBe( 403 | ` 404 |
405 |
1
406 |
1
407 |
1
408 |
409 | `.replace(/\s+/g, ""), 410 | ); 411 | 412 | await revalidateTag("tag"); 413 | 414 | const html2 = await renderToString(() => ( 415 | <> 416 | 417 | 418 | 419 | )); 420 | 421 | expect(html2).toBe(`
2
2
`); 422 | }); 423 | test("custom tags can be set by default", async () => { 424 | setGlobalPersistCacheConfig({ 425 | defaultCacheOptions: { 426 | tags: ["tag"], 427 | }, 428 | }); 429 | 430 | let count = 0; 431 | let getCount = async () => ++count; 432 | let cachedGetCount = persistedCache(getCount, getCacheKey()); 433 | 434 | expect(count).toBe(1); 435 | 436 | const Component = async () => { 437 | const count = await cachedGetCount(); 438 | return
{count}
; 439 | }; 440 | 441 | const html = await renderToString(() => ( 442 |
443 | 444 | 445 | 446 |
447 | )); 448 | 449 | expect(html).toBe( 450 | ` 451 |
452 |
1
453 |
1
454 |
1
455 |
456 | `.replace(/\s+/g, ""), 457 | ); 458 | 459 | await revalidateTag("tag"); 460 | 461 | const html2 = await renderToString(() => ( 462 | <> 463 | 464 | 465 | 466 | )); 467 | 468 | expect(html2).toBe(`
2
2
`); 469 | }); 470 | test("tags stay independant", async () => { 471 | let count1 = 0; 472 | let getCount1 = async () => ++count1; 473 | let cachedGetCount1 = persistedCache(getCount1, getCacheKey(), { 474 | tags: ["tag1"], 475 | }); 476 | 477 | let count2 = 0; 478 | let getCount2 = async () => ++count2; 479 | let cachedGetCount2 = persistedCache(getCount2, getCacheKey(), { 480 | tags: ["tag2"], 481 | }); 482 | 483 | const Component = async () => { 484 | const count1 = await cachedGetCount1(); 485 | const count2 = await cachedGetCount2(); 486 | return ( 487 |
488 | {count1} - {count2} 489 |
490 | ); 491 | }; 492 | 493 | const html = await renderToString(() => ( 494 |
495 | 496 | 497 | 498 |
499 | )); 500 | 501 | expect(html).toBe( 502 | ` 503 |
504 |
1 - 1
505 |
1 - 1
506 |
1 - 1
507 |
508 | `.replace(/\s+/g, ""), 509 | ); 510 | 511 | await revalidateTag("tag1"); 512 | 513 | const html2 = await renderToString(() => ( 514 | <> 515 | 516 | 517 | 518 | )); 519 | 520 | expect(html2).toBe(`
2 - 1
2 - 1
`); 521 | 522 | await revalidateTag("tag2"); 523 | 524 | const html3 = await renderToString(() => ( 525 | <> 526 | 527 | 528 | 529 | )); 530 | 531 | expect(html3).toBe(`
2 - 2
2 - 2
`); 532 | }); 533 | test("two entries with shared tag both revalidate", async () => { 534 | let count1 = 0; 535 | let getCount1 = async () => ++count1; 536 | let cachedGetCount1 = persistedCache(getCount1, getCacheKey(), { 537 | tags: ["tag"], 538 | }); 539 | 540 | let count2 = 0; 541 | let getCount2 = async () => ++count2; 542 | let cachedGetCount2 = persistedCache(getCount2, getCacheKey(), { 543 | tags: ["tag"], 544 | }); 545 | 546 | const Component = async () => { 547 | const count1 = await cachedGetCount1(); 548 | const count2 = await cachedGetCount2(); 549 | return ( 550 |
551 | {count1} - {count2} 552 |
553 | ); 554 | }; 555 | 556 | const html = await renderToString(() => ( 557 |
558 | 559 | 560 | 561 |
562 | )); 563 | 564 | expect(html).toBe( 565 | ` 566 |
567 |
1 - 1
568 |
1 - 1
569 |
1 - 1
570 |
571 | `.replace(/\s+/g, ""), 572 | ); 573 | 574 | await revalidateTag("tag"); 575 | 576 | const html2 = await renderToString(() => ( 577 | <> 578 | 579 | 580 | 581 | )); 582 | 583 | expect(html2).toBe(`
2 - 2
2 - 2
`); 584 | }); 585 | test("revalidateTag returns promise that resolves when revalidation is complete", async () => {}); 586 | }); 587 | 588 | describe("pending behavior (swr)", () => { 589 | test("by default SWR is on", async () => { 590 | let count = 0; 591 | let getCount = async () => 592 | new Promise((resolve) => setTimeout(() => resolve(++count), 200)); 593 | let cachedGetCount = persistedCache(getCount, getCacheKey(), { 594 | revalidate: 1, 595 | }); 596 | 597 | expect(count).toBe(1); 598 | 599 | const Component = async () => { 600 | const count = await cachedGetCount(); 601 | return
{count}
; 602 | }; 603 | 604 | await new Promise((resolve) => setTimeout(resolve, 1050)); 605 | 606 | // should hit during the pending revlidation 607 | // but still get the stale value 608 | 609 | const html = await renderToString(() => ( 610 |
611 | 612 | 613 | 614 |
615 | )); 616 | 617 | expect(html).toBe( 618 | ` 619 |
620 |
1
621 |
1
622 |
1
623 |
624 | `.replace(/\s+/g, ""), 625 | ); 626 | }); 627 | test("but can be disabled to return pending value", async () => { 628 | setGlobalPersistCacheConfig({ 629 | returnStaleWhileRevalidating: false, 630 | }); 631 | let count = 0; 632 | let getCount = async () => 633 | new Promise((resolve) => setTimeout(() => resolve(++count), 200)); 634 | let cachedGetCount = persistedCache(getCount, getCacheKey(), { 635 | revalidate: 1, 636 | }); 637 | 638 | expect(count).toBe(1); 639 | 640 | const Component = async () => { 641 | const count = await cachedGetCount(); 642 | return
{count}
; 643 | }; 644 | 645 | await new Promise((resolve) => setTimeout(resolve, 1050)); 646 | 647 | // should hit during the pending revlidation 648 | // and should recieve the pending value 649 | 650 | const html = await renderToString(() => ( 651 |
652 | 653 | 654 | 655 |
656 | )); 657 | 658 | expect(html).toBe( 659 | ` 660 |
661 |
2
662 |
2
663 |
2
664 |
665 | `.replace(/\s+/g, ""), 666 | ); 667 | }); 668 | test("the pending promise can reject", async () => { 669 | setGlobalPersistCacheConfig({ 670 | returnStaleWhileRevalidating: false, 671 | }); 672 | let count = 0; 673 | let getCount = async () => 674 | new Promise((resolve, reject) => 675 | setTimeout(() => { 676 | if (++count === 2) { 677 | reject(new Error("error")); 678 | } 679 | resolve(count); 680 | }, 200), 681 | ); 682 | let cachedGetCount = persistedCache(getCount, getCacheKey(), { 683 | revalidate: 1, 684 | }); 685 | 686 | expect(count).toBe(1); 687 | 688 | const Component = async () => { 689 | const count = await cachedGetCount(); 690 | return
{count}
; 691 | }; 692 | 693 | await new Promise((resolve) => setTimeout(resolve, 1050)); 694 | 695 | // should hit during the pending revlidation 696 | // and should recieve the pending value (which rejects) 697 | 698 | const html = renderToString(() => ( 699 |
700 | 701 | 702 | 703 |
704 | )); 705 | 706 | expect(html).rejects.toBe(Error); 707 | }); 708 | }); 709 | 710 | describe("error in immediate seed", () => { 711 | test("by default it will remain unseeded and be rerun", async () => { 712 | let count = 0; 713 | let getCount = async () => { 714 | if (count++ === 1) { 715 | throw new Error("error"); 716 | } 717 | return count; 718 | }; 719 | let cachedGetCount = persistedCache(getCount, getCacheKey()); 720 | 721 | expect(count).toBe(1); 722 | 723 | const Component = async () => { 724 | const count = await cachedGetCount(); 725 | return
{count}
; 726 | }; 727 | 728 | const html = await renderToString(() => ( 729 |
730 | 731 | 732 | 733 |
734 | )); 735 | 736 | expect(html).toBe( 737 | ` 738 |
739 |
2
740 |
2
741 |
2
742 |
743 | `.replace(/\s+/g, ""), 744 | ); 745 | }); 746 | test("can be disabled to rethrow", async () => { 747 | setGlobalPersistCacheConfig({ 748 | errorHandling: { 749 | duringImmediateSeed: "rethrow", 750 | }, 751 | }); 752 | 753 | let count = 0; 754 | let getCount = async () => { 755 | if (count++ === 1) { 756 | throw new Error("error"); 757 | } 758 | return count; 759 | }; 760 | let cachedGetCount = persistedCache(getCount, getCacheKey(), {}); 761 | 762 | expect(count).toBe(1); 763 | 764 | const Component = async () => { 765 | const count = await cachedGetCount(); 766 | return
{count}
; 767 | }; 768 | 769 | const html = () => 770 | renderToString(() => ( 771 |
772 | 773 | 774 | 775 |
776 | )); 777 | 778 | expect(html).toThrow(); 779 | 780 | // after rethrowing the error, the function should be rerun 781 | 782 | const html2 = await renderToString(() => ( 783 |
784 | 785 | 786 | 787 |
788 | )); 789 | 790 | expect(html2).toBe( 791 | ` 792 |
793 |
2
794 |
2
795 |
2
796 |
797 | `.replace(/\s+/g, ""), 798 | ); 799 | }); 800 | }); 801 | 802 | // TODO 803 | describe("error in unseeded revalidation", () => { 804 | test("by default it will remain unseeded and be rerun", async () => {}); 805 | test("can be disabled to rethrow", async () => {}); 806 | }); 807 | 808 | // TODO 809 | describe("error in seeded revalidation", () => { 810 | test("by default it will return last valid data", async () => {}); 811 | test("can be disabled to rethrow", async () => {}); 812 | test("can be disabled to rerun", async () => {}); 813 | }); 814 | 815 | // TODO 816 | describe("argumentitive functions", () => { 817 | test("works with functions with arguments", async () => {}); 818 | test("different sets of arguments are cached seperately", async () => {}); 819 | test("different sets of arguments are compared with deepStrictEqual", async () => {}); 820 | test("revalidating reruns with all stored sets of arguments", async () => {}); 821 | test("can be seeded immediately with arguments", async () => {}); 822 | test("can be seeded immediately with multiple sets of arguments", async () => {}); 823 | }); 824 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/tests/old-persist.test.tsx: -------------------------------------------------------------------------------- 1 | import "../../shared/global"; 2 | import { describe, expect, test } from "bun:test"; 3 | import { 4 | persistedCache, 5 | revalidateTag, 6 | setGlobalPersistCacheConfig, 7 | } from "../old-persist"; 8 | import "../../jsx/register"; 9 | import { renderToString } from "../../jsx/render"; 10 | 11 | setGlobalPersistCacheConfig({ 12 | // log: true, 13 | }); 14 | 15 | test("static json cache", async () => { 16 | let count = 0; 17 | const getCount = async () => ++count; 18 | const cachedGetCount = persistedCache(getCount, "getCount1"); 19 | 20 | const Component = async () => { 21 | const data = await cachedGetCount(); 22 | return

number: {data}

; 23 | }; 24 | 25 | const html = await renderToString(() => ( 26 | <> 27 | 28 | 29 | 30 | )); 31 | 32 | expect(html).toBe(`

number: 1

number: 1

`); 33 | 34 | // This should result in no 'cache hit' log, because the render cache is never reset from the previous render 35 | const html2 = await ( 36 | <> 37 | 38 | 39 | 40 | ); 41 | 42 | expect(html2).toBe(`

number: 1

number: 1

`); 43 | 44 | // even in a new render we get the same results 45 | const Test = () => ; 46 | 47 | const html3 = await renderToString(() => ( 48 | <> 49 | 50 | 51 | 52 | )); 53 | 54 | expect(html3).toBe(`

number: 1

number: 1

`); 55 | }); 56 | 57 | test("static memory cache", async () => { 58 | let count = 0; 59 | const getCount = async () => ++count; 60 | const cachedGetCount = persistedCache(getCount, "getCount2", { 61 | persist: "memory", 62 | }); 63 | 64 | const Component = async () => { 65 | const data = await cachedGetCount(); 66 | return

number: {data}

; 67 | }; 68 | 69 | const html = await renderToString(() => ( 70 | <> 71 | 72 | 73 | 74 | )); 75 | 76 | expect(html).toBe(`

number: 1

number: 1

`); 77 | 78 | // even in a new render we get the same results 79 | const Test = () => ; 80 | 81 | const html3 = await renderToString(() => ( 82 | <> 83 | 84 | 85 | 86 | )); 87 | 88 | expect(html3).toBe(`

number: 1

number: 1

`); 89 | }); 90 | 91 | test("json cache revalidate interval", async () => { 92 | let count = 0; 93 | const getCount = async () => ++count; 94 | const cachedGetCount = persistedCache(getCount, "getCount3", { 95 | revalidate: 1, 96 | }); 97 | 98 | const Component = async () => { 99 | const data = await cachedGetCount(); 100 | return

number: {data}

; 101 | }; 102 | 103 | const html = await renderToString(() => ( 104 | <> 105 | 106 | 107 | 108 | )); 109 | 110 | expect(html).toBe(`

number: 1

number: 1

`); 111 | 112 | count++; 113 | 114 | // should the be same right away 115 | 116 | const html2 = await renderToString(() => ( 117 | <> 118 | 119 | 120 | 121 | )); 122 | 123 | expect(html2).toBe(`

number: 1

number: 1

`); 124 | 125 | // and the same until a second has passed 126 | 127 | await new Promise((resolve) => 128 | setTimeout(async () => { 129 | const html3 = await renderToString(() => ( 130 | <> 131 | 132 | 133 | 134 | )); 135 | 136 | expect(html3).toBe(`

number: 1

number: 1

`); 137 | 138 | resolve(void 0); 139 | }, 500), 140 | ); 141 | 142 | // but after a second it should be different 143 | 144 | await new Promise((resolve) => 145 | setTimeout(async () => { 146 | const html3 = await renderToString(() => ( 147 | <> 148 | 149 | 150 | 151 | )); 152 | 153 | expect(html3).toBe(`

number: 3

number: 3

`); 154 | 155 | resolve(void 0); 156 | }, 1100), 157 | ); 158 | }); 159 | 160 | test("memory cache revalidate interval", async () => { 161 | let count = 0; 162 | const getCount = async () => ++count; 163 | const cachedGetCount = persistedCache(getCount, "getCount4", { 164 | persist: "memory", 165 | revalidate: 1, 166 | }); 167 | 168 | const Component = async () => { 169 | const data = await cachedGetCount(); 170 | return

number: {data}

; 171 | }; 172 | 173 | const html = await renderToString(() => ( 174 | <> 175 | 176 | 177 | 178 | )); 179 | 180 | expect(html).toBe(`

number: 1

number: 1

`); 181 | 182 | count++; 183 | 184 | // should the be same right away 185 | 186 | const html2 = await renderToString(() => ( 187 | <> 188 | 189 | 190 | 191 | )); 192 | 193 | expect(html2).toBe(`

number: 1

number: 1

`); 194 | 195 | // and the same until a second has passed 196 | 197 | await new Promise((resolve) => 198 | setTimeout(async () => { 199 | const html3 = await renderToString(() => ( 200 | <> 201 | 202 | 203 | 204 | )); 205 | 206 | expect(html3).toBe(`

number: 1

number: 1

`); 207 | 208 | resolve(void 0); 209 | }, 500), 210 | ); 211 | 212 | // but after a second it should be different 213 | 214 | await new Promise((resolve) => 215 | setTimeout(async () => { 216 | const html3 = await renderToString(() => ( 217 | <> 218 | 219 | 220 | 221 | )); 222 | 223 | expect(html3).toBe(`

number: 3

number: 3

`); 224 | 225 | resolve(void 0); 226 | }, 1100), 227 | ); 228 | }); 229 | 230 | test("json cache revalidate tag", async () => { 231 | let count = 0; 232 | const getCount = async () => ++count; 233 | const cachedGetCount = persistedCache(getCount, "getCount5", { 234 | tags: ["tag1"], 235 | }); 236 | 237 | const Component = async () => { 238 | const data = await cachedGetCount(); 239 | return

number: {data}

; 240 | }; 241 | 242 | // initial cache miss 243 | const html = await renderToString(() => ( 244 | <> 245 | 246 | 247 | 248 | )); 249 | 250 | expect(html).toBe(`

number: 1

number: 1

`); 251 | 252 | count++; 253 | 254 | // should the be same right away 255 | 256 | // hit cache 257 | const html2 = await renderToString(() => ( 258 | <> 259 | 260 | 261 | 262 | )); 263 | 264 | expect(html2).toBe(`

number: 1

number: 1

`); 265 | 266 | // by default swr is off, so must wait for revalidate to complete 267 | await revalidateTag("tag1"); 268 | 269 | // now should be different 270 | 271 | const html3 = await renderToString(() => ( 272 | <> 273 | 274 | 275 | 276 | )); 277 | 278 | expect(html3).toBe(`

number: 3

number: 3

`); 279 | }); 280 | 281 | test("memory cache revalidate tag", async () => { 282 | let count = 0; 283 | const getCount = async () => ++count; 284 | const cachedGetCount = persistedCache(getCount, "getCount6", { 285 | tags: ["tag1"], 286 | persist: "memory", 287 | }); 288 | 289 | const Component = async () => { 290 | const data = await cachedGetCount(); 291 | return

number: {data}

; 292 | }; 293 | 294 | const html = await renderToString(() => ( 295 | <> 296 | 297 | 298 | 299 | )); 300 | 301 | expect(html).toBe(`

number: 1

number: 1

`); 302 | 303 | count++; 304 | 305 | // should the be same right away 306 | 307 | const html2 = await renderToString(() => ( 308 | <> 309 | 310 | 311 | 312 | )); 313 | 314 | expect(html2).toBe(`

number: 1

number: 1

`); 315 | 316 | await revalidateTag("tag1"); 317 | 318 | // now should be different 319 | 320 | const html3 = await renderToString(() => ( 321 | <> 322 | 323 | 324 | 325 | )); 326 | 327 | expect(html3).toBe(`

number: 3

number: 3

`); 328 | }); 329 | 330 | test("complex object storage to memory", async () => { 331 | const getData = async () => ({ 332 | a: 1, 333 | b: 2, 334 | c: { 335 | d: 3, 336 | e: [4, 5, 6], 337 | }, 338 | }); 339 | 340 | const cachedGetData = persistedCache(getData, "getData1", { 341 | persist: "memory", 342 | }); 343 | 344 | const data = await cachedGetData(); 345 | 346 | expect(data).toStrictEqual({ 347 | a: 1, 348 | b: 2, 349 | c: { 350 | d: 3, 351 | e: [4, 5, 6], 352 | }, 353 | }); 354 | }); 355 | test("complex object storage to json", async () => { 356 | const getData = async () => ({ 357 | a: 1, 358 | b: 2, 359 | c: { 360 | d: 3, 361 | e: [4, 5, 6], 362 | }, 363 | }); 364 | 365 | const cachedGetData = persistedCache(getData, "getData2", { 366 | persist: "json", 367 | }); 368 | 369 | const data = await cachedGetData(); 370 | 371 | expect(data).toStrictEqual({ 372 | a: 1, 373 | b: 2, 374 | c: { 375 | d: 3, 376 | e: [4, 5, 6], 377 | }, 378 | }); 379 | }); 380 | 381 | describe("errors", () => { 382 | setGlobalPersistCacheConfig({ 383 | log: true, 384 | }); 385 | 386 | test("throw in inital callback", async () => { 387 | // should set to neverSeeded 388 | // will rerun the callback on the next call 389 | // if throw again, still neverSeeded and will rerun 390 | let count = 0; 391 | const getCount = async () => { 392 | count++; 393 | if (count < 3) { 394 | throw count.toString(); 395 | } 396 | return count; 397 | }; 398 | const cachedGetCount = persistedCache(getCount, "throw1"); 399 | 400 | const Component = async () => { 401 | const data = await cachedGetCount(); 402 | return

number: {data}

; 403 | }; 404 | 405 | const html = () => 406 | renderToString(() => ( 407 | <> 408 | 409 | 410 | 411 | )); 412 | 413 | expect(html).toThrow("1"); 414 | 415 | const html2 = () => 416 | renderToString(() => ( 417 | <> 418 | 419 | 420 | 421 | )); 422 | 423 | expect(html2).toThrow("2"); 424 | 425 | const html3 = await renderToString(() => ( 426 | <> 427 | 428 | 429 | 430 | )); 431 | 432 | expect(html3).toBe(`

number: 3

number: 3

`); 433 | }); 434 | 435 | test("throw in revalidate was seeded (on reval error return stale: true)", async () => { 436 | setGlobalPersistCacheConfig({ 437 | onRevalidateErrorReturnStale: true, 438 | }); 439 | let count = 0; 440 | const getCount = async () => { 441 | count++; 442 | if (count === 2) { 443 | throw count.toString(); 444 | } 445 | return count; 446 | }; 447 | const cachedGetCount = persistedCache(getCount, "throw2", { 448 | tags: ["tag1"], 449 | }); 450 | 451 | // seeds ok 452 | 453 | const Component = async () => { 454 | const data = await cachedGetCount(); 455 | return

number: {data}

; 456 | }; 457 | 458 | // hit cache 459 | 460 | const html = await renderToString(() => ( 461 | <> 462 | 463 | 464 | 465 | )); 466 | 467 | expect(html).toBe("

number: 1

number: 1

"); 468 | 469 | await revalidateTag("tag1"); 470 | // ^^ THIS SHOULD LEAD TO THE CALLBACK RUNNING AGAIN AND THROWING 471 | // but the old data is returned 472 | 473 | const html2 = await renderToString(() => ( 474 | <> 475 | 476 | 477 | 478 | )); 479 | 480 | expect(html2).toBe("

number: 1

number: 1

"); 481 | 482 | await revalidateTag("tag1"); 483 | 484 | const html3 = await renderToString(() => ( 485 | <> 486 | 487 | 488 | 489 | )); 490 | 491 | expect(html3).toBe(`

number: 3

number: 3

`); 492 | }); 493 | test("throw in revalidate was seeded (on reval error return stale: false)", async () => { 494 | setGlobalPersistCacheConfig({ 495 | onRevalidateErrorReturnStale: false, 496 | }); 497 | 498 | let count = 0; 499 | const getCount = async () => { 500 | count++; 501 | console.log("getCount", count); 502 | if (count === 2) { 503 | throw count.toString(); 504 | } 505 | return count; 506 | }; 507 | const cachedGetCount = persistedCache(getCount, "throw3", { 508 | tags: ["tag9"], 509 | }); 510 | 511 | const Component = async () => { 512 | const data = await cachedGetCount(); 513 | return

number: {data}

; 514 | }; 515 | 516 | const html = await renderToString(() => ( 517 | <> 518 | 519 | 520 | 521 | )); 522 | 523 | expect(html).toBe("

number: 1

number: 1

"); 524 | 525 | await revalidateTag("tag9"); 526 | // ^^ THIS SHOULD LEAD TO THE CALLBACK RUNNING AGAIN AND THROWING 527 | // but bc of config option, the error is caught and stored 528 | // to be rethrown on the next call 529 | 530 | const html2 = () => 531 | renderToString(() => ( 532 | <> 533 | 534 | 535 | 536 | )); 537 | 538 | expect(html2).toThrow("2"); 539 | 540 | await revalidateTag("tag9"); 541 | 542 | const html3 = await renderToString(() => ( 543 | <> 544 | 545 | 546 | 547 | )); 548 | 549 | expect(html3).toBe(`

number: 3

number: 3

`); 550 | }); 551 | test("throw in revalidate was never seeded (on reval error return stale: false) 2", async () => { 552 | setGlobalPersistCacheConfig({ 553 | onRevalidateErrorReturnStale: false, 554 | }); 555 | 556 | let count = 0; 557 | const getCount = async () => { 558 | count++; 559 | console.log("getCount", count); 560 | if (count !== 3) { 561 | throw count.toString(); 562 | } 563 | return count; 564 | }; 565 | // throws so neverSeeded 566 | const cachedGetCount = persistedCache(getCount, "throw4", { 567 | tags: ["tag2"], 568 | }); 569 | 570 | const Component = async () => { 571 | const data = await cachedGetCount(); 572 | return

number: {data}

; 573 | }; 574 | 575 | // also throws so still neverSeeded 576 | await revalidateTag("tag2"); 577 | 578 | await revalidateTag("tag2"); 579 | // this should fill the cache and clear the stored error 580 | 581 | const html3 = await renderToString(() => ( 582 | <> 583 | 584 | 585 | 586 | )); 587 | 588 | expect(html3).toBe(`

number: 3

number: 3

`); 589 | }); 590 | test("throw in revalidate was never seeded (on reval error return stale: true) zz", async () => { 591 | setGlobalPersistCacheConfig({ 592 | onRevalidateErrorReturnStale: true, 593 | }); 594 | let count = 0; 595 | const getCount = async () => { 596 | count++; 597 | console.log("getCount", count); 598 | if (count !== 4) { 599 | throw count.toString(); 600 | } 601 | return count; 602 | }; 603 | 604 | console.log("first time"); 605 | 606 | // runs first time, throws so neverSeeded 607 | const cachedGetCount = persistedCache(getCount, "throw5", { 608 | tags: ["tag4"], 609 | }); 610 | 611 | const Component = async () => { 612 | const data = await cachedGetCount(); 613 | return

number: {data}

; 614 | }; 615 | // console.log("---- second time"); 616 | // // runs second time, throws so neverSeeded 617 | const html = renderToString(() => ( 618 | <> 619 | 620 | 621 | 622 | )); 623 | 624 | // expect(html).rejects.toBe("1"); 625 | 626 | // console.log("----- third time"); 627 | // runs third time, throws so neverSeeded 628 | // await revalidateTag("tag4"); 629 | // ^^ THIS SHOULD LEAD TO THE CALLBACK RUNNING AGAIN AND THROWING 630 | // which should keep it 'neverSeeded' 631 | 632 | // runs fourth time, WORKS 633 | console.log("forth time"); 634 | // const html2 = await renderToString(() => ( 635 | // <> 636 | // 637 | // 638 | // 639 | // )); 640 | 641 | // expect(html2).toBe(`

number: 4

number: 4

`); 642 | }); 643 | // test("throw in revalidate was never seeded (on reval error return stale: false)", async () => { 644 | // setGlobalPersistCacheConfig({ 645 | // onRevalidateErrorReturnStale: false, 646 | // }); 647 | // let count = 0; 648 | // const getCount = async () => { 649 | // count++; 650 | // console.log("getCount", count); 651 | // if (count !== 4) { 652 | // throw count.toString(); 653 | // } 654 | // return count; 655 | // }; 656 | // // runs first time, throws so neverSeeded 657 | // const cachedGetCount = persistedCache(getCount, "throw6", { 658 | // tags: ["tag5"], 659 | // }); 660 | 661 | // const Component = async () => { 662 | // const data = await cachedGetCount(); 663 | // return

number: {data}

; 664 | // }; 665 | 666 | // // runs second time, throws so neverSeeded 667 | // const html = renderToString(() => ( 668 | // <> 669 | // 670 | // 671 | // 672 | // )); 673 | 674 | // expect(html).rejects.toBe("2"); 675 | 676 | // console.log("test"); 677 | 678 | // // runs third time, throws so neverSeeded 679 | // await revalidateTag("tag5"); 680 | // // ^^ THIS SHOULD LEAD TO THE CALLBACK RUNNING AGAIN AND THROWING 681 | // // which should keep it 'neverSeeded' 682 | // console.log("test2"); 683 | 684 | // // runs fourth time, WORKS 685 | // const html2 = await renderToString(() => ( 686 | // <> 687 | // 688 | // 689 | // 690 | // )); 691 | 692 | // console.log("test3"); 693 | 694 | // expect(html2).toBe(`

number: 4

number: 4

`); 695 | // }); 696 | }); 697 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/tests/render-cache.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import Html, { PropsWithChildren } from "../../jsx"; 3 | import { renderToString } from "../../jsx/render"; 4 | import { cache } from "../render"; 5 | 6 | describe("async components", () => { 7 | test("basic", async () => { 8 | const Component = async () =>

hi

; 9 | 10 | const html = await (); 11 | 12 | expect(html).toBe(`

hi

`); 13 | }); 14 | 15 | test("with props", async () => { 16 | const Component = async ({ name }: { name: string }) =>

hi {name}

; 17 | 18 | const html = await (); 19 | 20 | expect(html).toBe(`

hi world

`); 21 | }); 22 | 23 | test("with text children", async () => { 24 | const Component = async ({ children }: { children: string }) => ( 25 |

hi {children}

26 | ); 27 | 28 | const html = await (world); 29 | 30 | expect(html).toBe(`

hi world

`); 31 | }); 32 | 33 | test("with async children", async () => { 34 | const Component = async ({ children }: PropsWithChildren) => ( 35 |
{children}
36 | ); 37 | 38 | const Child = async () =>

test

; 39 | 40 | const html = await ( 41 | 42 | 43 | 44 | 45 | ); 46 | 47 | expect(html).toBe(`

test

test

`); 48 | }); 49 | 50 | test("data fetching", async () => { 51 | let data = 0; 52 | const getMockData = async () => ++data; 53 | 54 | const Component = async () => { 55 | const data = await getMockData(); 56 | return

number: {data}

; 57 | }; 58 | 59 | const html = await (); 60 | 61 | expect(html).toBe(`

number: 1

`); 62 | }); 63 | 64 | test("data fetching in multiple components", async () => { 65 | let data = 0; 66 | const getMockData = async () => ++data; 67 | 68 | const Component = async () => { 69 | const data = await getMockData(); 70 | return

number: {data}

; 71 | }; 72 | 73 | const html = await ( 74 | <> 75 | 76 | 77 | 78 | ); 79 | 80 | expect(html).toBe(`

number: 1

number: 2

`); 81 | }); 82 | 83 | test("cache dedupes data fetching", async () => { 84 | let data = 0; 85 | const getMockData = async () => ++data; 86 | const cachedGetMockData = cache(getMockData); 87 | 88 | const Component = async () => { 89 | const data = await cachedGetMockData(); 90 | return

number: {data}

; 91 | }; 92 | 93 | const html = await renderToString(() => ( 94 | <> 95 | 96 | 97 | 98 | )); 99 | 100 | expect(html).toBe(`

number: 1

number: 1

`); 101 | }); 102 | 103 | test("cache resets each render", async () => { 104 | let data = 0; 105 | const getMockData = async () => ++data; 106 | const cachedGetMockData = cache(getMockData); 107 | 108 | const Component = async () => { 109 | const data = await cachedGetMockData(); 110 | return

number: {data}

; 111 | }; 112 | 113 | const html = await renderToString(() => ( 114 | <> 115 | 116 | 117 | 118 | )); 119 | 120 | expect(html).toBe(`

number: 1

number: 1

`); 121 | 122 | data++; 123 | 124 | // render doesnt resets after finished 125 | 126 | const html2 = await ( 127 | <> 128 | 129 | 130 | 131 | ); 132 | 133 | expect(html2).toBe(`

number: 1

number: 1

`); 134 | 135 | // lazy evaluation means we get the new data (because it doesnt go off until inside renderToString) 136 | const Test = () => ; 137 | 138 | const html3 = await renderToString(() => ( 139 | <> 140 | 141 | 142 | 143 | )); 144 | 145 | expect(html3).toBe(`

number: 3

number: 3

`); 146 | }); 147 | }); 148 | 149 | test("if a function throws, the same error is rethrown", async () => { 150 | let first = true; 151 | const throws = cache(() => { 152 | if (first) { 153 | first = false; 154 | throw new Error("test"); 155 | } 156 | }); 157 | 158 | const Component = async () => { 159 | throws(); 160 | return

hi

; 161 | }; 162 | 163 | const html1 = () => ; 164 | expect(html1).toThrow(); 165 | const html2 = () => ; 166 | expect(html2).toThrow(); 167 | const html3 = () => ; 168 | expect(html3).toThrow(); 169 | }); 170 | 171 | test("if a function throws, the same error is rethrown", async () => { 172 | let count = 0; 173 | const throws = cache(() => { 174 | count++; 175 | throw new Error(count.toString()); 176 | }); 177 | 178 | const Component = async () => { 179 | throws(); 180 | return

hi

; 181 | }; 182 | 183 | const html1 = () => ; 184 | expect(html1).toThrow("1"); 185 | const html2 = () => ; 186 | expect(html2).toThrow("1"); 187 | const html3 = () => ; 188 | expect(html3).toThrow("1"); 189 | }); 190 | 191 | test("if a promise rejects, the same rejected promise is returned", async () => { 192 | let count = 0; 193 | const rejects = cache(async () => { 194 | count++; 195 | throw new Error(count.toString()); 196 | }); 197 | 198 | const Component = async () => { 199 | await rejects(); 200 | return

hi

; 201 | }; 202 | 203 | const html1 = () => ; 204 | expect(html1).toThrow("1"); 205 | 206 | const html2 = () => ; 207 | expect(html2).toThrow("1"); 208 | 209 | const html3 = () => ; 210 | expect(html3).toThrow("1"); 211 | }); 212 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cache/tests/swr-behavior.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { persistedCache, revalidateTag, setGlobalPersistCacheConfig } from ".."; 3 | import { renderToString } from "../../jsx"; 4 | import "../../jsx/register"; 5 | 6 | describe("SWR OFF (not default)", () => { 7 | const setup = () => 8 | setGlobalPersistCacheConfig({ 9 | returnStaleWhileRevalidate: false, 10 | }); 11 | test("request during interval revalidation", async () => { 12 | setup(); 13 | let count = 0; 14 | const getCount = async () => 15 | new Promise((resolve) => setTimeout(() => resolve(++count), 100)); 16 | const cachedGetCount = persistedCache(getCount, "getCount7", { 17 | revalidate: 1, 18 | }); 19 | 20 | const Component = async () => { 21 | const data = await cachedGetCount(); 22 | return

number: {data}

; 23 | }; 24 | 25 | const html = await renderToString(() => ( 26 | <> 27 | 28 | 29 | 30 | )); 31 | 32 | expect(html).toBe(`

number: 1

number: 1

`); 33 | 34 | // cache request goes off during revalidation 35 | // should result in 'pending cache hit' log + updated data 36 | 37 | await new Promise((resolve) => 38 | setTimeout(async () => { 39 | const html3 = await renderToString(() => ( 40 | <> 41 | 42 | 43 | 44 | )); 45 | 46 | expect(html3).toBe(`

number: 2

number: 2

`); 47 | 48 | resolve(void 0); 49 | }, 1050), 50 | ); 51 | }); 52 | 53 | test("request during tag revalidation", async () => { 54 | setup(); 55 | let count = 0; 56 | const getCount = async () => 57 | new Promise((resolve) => setTimeout(() => resolve(++count), 100)); 58 | const cachedGetCount = persistedCache(getCount, "getCount8", { 59 | tags: ["tag1"], 60 | }); 61 | 62 | const Component = async () => { 63 | const data = await cachedGetCount(); 64 | return

number: {data}

; 65 | }; 66 | 67 | const html = await renderToString(() => ( 68 | <> 69 | 70 | 71 | 72 | )); 73 | 74 | expect(html).toBe(`

number: 1

number: 1

`); 75 | 76 | setTimeout(() => { 77 | count++; 78 | revalidateTag("tag1"); 79 | }, 1000); 80 | 81 | // cache request goes off during revalidation 82 | // should result in 'pending cache hit' log + updated data 83 | 84 | await new Promise((resolve) => 85 | setTimeout(async () => { 86 | const html3 = await renderToString(() => ( 87 | <> 88 | 89 | 90 | 91 | )); 92 | 93 | expect(html3).toBe(`

number: 3

number: 3

`); 94 | 95 | resolve(void 0); 96 | }, 1010), 97 | ); 98 | }); 99 | 100 | test("interval during tag revalidation", async () => { 101 | setup(); 102 | let count = 0; 103 | const getCount = async () => 104 | new Promise((resolve) => setTimeout(() => resolve(++count), 300)); 105 | const cachedGetCount = persistedCache(getCount, "getCount9", { 106 | tags: ["tag1"], 107 | revalidate: 1, 108 | }); 109 | 110 | const Component = async () => { 111 | const data = await cachedGetCount(); 112 | return

number: {data}

; 113 | }; 114 | 115 | const html = await renderToString(() => ( 116 | <> 117 | 118 | 119 | 120 | )); 121 | 122 | expect(html).toBe(`

number: 1

number: 1

`); 123 | 124 | setTimeout(() => { 125 | count++; 126 | revalidateTag("tag1"); 127 | }, 900); 128 | 129 | // should see pending cache hit for interval revalidation 130 | 131 | // cache request goes off during revalidation 132 | // should result in 2nd 'pending cache hit' log + updated data 133 | 134 | await new Promise((resolve) => 135 | setTimeout(async () => { 136 | const html3 = await renderToString(() => ( 137 | <> 138 | 139 | 140 | 141 | )); 142 | 143 | expect(html3).toBe(`

number: 3

number: 3

`); 144 | 145 | resolve(void 0); 146 | }, 1100), 147 | ); 148 | }); 149 | 150 | test("interval during tag revalidation", async () => { 151 | setup(); 152 | let count = 0; 153 | const getCount = async () => 154 | new Promise((resolve) => setTimeout(() => resolve(++count), 300)); 155 | const cachedGetCount = persistedCache(getCount, "getCount10", { 156 | tags: ["tag1"], 157 | revalidate: 1, 158 | }); 159 | 160 | const Component = async () => { 161 | const data = await cachedGetCount(); 162 | return

number: {data}

; 163 | }; 164 | 165 | const html = await renderToString(() => ( 166 | <> 167 | 168 | 169 | 170 | )); 171 | 172 | expect(html).toBe(`

number: 1

number: 1

`); 173 | 174 | setTimeout(() => { 175 | count++; 176 | revalidateTag("tag1"); 177 | }, 1100); 178 | 179 | // should see pending cache hit for tag revalidation 180 | 181 | // cache request goes off during revalidation 182 | // should result in 2nd 'pending cache hit' log + updated data 183 | 184 | await new Promise((resolve) => 185 | setTimeout(async () => { 186 | const html3 = await renderToString(() => ( 187 | <> 188 | 189 | 190 | 191 | )); 192 | 193 | expect(html3).toBe(`

number: 3

number: 3

`); 194 | 195 | resolve(void 0); 196 | }, 1150), 197 | ); 198 | }); 199 | }); 200 | 201 | describe("SWR ON (default)", () => { 202 | const setup = () => 203 | setGlobalPersistCacheConfig({ 204 | returnStaleWhileRevalidate: true, 205 | }); 206 | 207 | test("request during interval revalidation", async () => { 208 | setup(); 209 | let count = 0; 210 | const getCount = async () => 211 | new Promise((resolve) => setTimeout(() => resolve(++count), 100)); 212 | const cachedGetCount = persistedCache(getCount, "1getCount7", { 213 | revalidate: 1, 214 | }); 215 | 216 | const Component = async () => { 217 | const data = await cachedGetCount(); 218 | return

number: {data}

; 219 | }; 220 | 221 | const html = await renderToString(() => ( 222 | <> 223 | 224 | 225 | 226 | )); 227 | 228 | expect(html).toBe(`

number: 1

number: 1

`); 229 | 230 | // cache request goes off during revalidation 231 | // should result in 'STALE cache hit' log + old data 232 | 233 | await new Promise((resolve) => 234 | setTimeout(async () => { 235 | const html3 = await renderToString(() => ( 236 | <> 237 | 238 | 239 | 240 | )); 241 | 242 | expect(html3).toBe(`

number: 1

number: 1

`); 243 | 244 | resolve(void 0); 245 | }, 1010), 246 | ); 247 | }); 248 | 249 | test("request during tag revalidation", async () => { 250 | setup(); 251 | let count = 0; 252 | const getCount = async () => 253 | new Promise((resolve) => setTimeout(() => resolve(++count), 100)); 254 | const cachedGetCount = persistedCache(getCount, "1getCount8", { 255 | tags: ["tag1"], 256 | }); 257 | 258 | const Component = async () => { 259 | const data = await cachedGetCount(); 260 | return

number: {data}

; 261 | }; 262 | 263 | const html = await renderToString(() => ( 264 | <> 265 | 266 | 267 | 268 | )); 269 | 270 | expect(html).toBe(`

number: 1

number: 1

`); 271 | 272 | setTimeout(() => { 273 | count++; 274 | revalidateTag("tag1"); 275 | }, 1000); 276 | 277 | // cache request goes off during revalidation 278 | // should result in 'STALE cache hit' log + old data 279 | 280 | await new Promise((resolve) => 281 | setTimeout(async () => { 282 | const html3 = await renderToString(() => ( 283 | <> 284 | 285 | 286 | 287 | )); 288 | 289 | expect(html3).toBe(`

number: 1

number: 1

`); 290 | 291 | resolve(void 0); 292 | }, 1010), 293 | ); 294 | }); 295 | 296 | test("interval during tag revalidation", async () => { 297 | setup(); 298 | let count = 0; 299 | const getCount = async () => 300 | new Promise((resolve) => setTimeout(() => resolve(++count), 300)); 301 | const cachedGetCount = persistedCache(getCount, "1getCount9", { 302 | tags: ["tag1"], 303 | revalidate: 1, 304 | }); 305 | 306 | const Component = async () => { 307 | const data = await cachedGetCount(); 308 | return

number: {data}

; 309 | }; 310 | 311 | const html = await renderToString(() => ( 312 | <> 313 | 314 | 315 | 316 | )); 317 | 318 | expect(html).toBe(`

number: 1

number: 1

`); 319 | 320 | setTimeout(() => { 321 | count++; 322 | revalidateTag("tag1"); 323 | }, 900); 324 | 325 | // should see pending cache hit for interval revalidation 326 | 327 | // cache request goes off during revalidation 328 | // should result in 'STALE cache hit' log + old data 329 | 330 | await new Promise((resolve) => 331 | setTimeout(async () => { 332 | const html3 = await renderToString(() => ( 333 | <> 334 | 335 | 336 | 337 | )); 338 | 339 | expect(html3).toBe(`

number: 1

number: 1

`); 340 | 341 | resolve(void 0); 342 | }, 1100), 343 | ); 344 | }); 345 | 346 | test("interval during tag revalidation", async () => { 347 | setup(); 348 | let count = 0; 349 | const getCount = async () => 350 | new Promise((resolve) => setTimeout(() => resolve(++count), 300)); 351 | const cachedGetCount = persistedCache(getCount, "1getCount10", { 352 | tags: ["tag1"], 353 | revalidate: 1, 354 | }); 355 | 356 | const Component = async () => { 357 | const data = await cachedGetCount(); 358 | return

number: {data}

; 359 | }; 360 | 361 | const html = await renderToString(() => ( 362 | <> 363 | 364 | 365 | 366 | )); 367 | 368 | expect(html).toBe(`

number: 1

number: 1

`); 369 | 370 | setTimeout(() => { 371 | count++; 372 | revalidateTag("tag1"); 373 | }, 1100); 374 | 375 | // should see pending cache hit for tag revalidation 376 | 377 | // cache request goes off during revalidation 378 | // should result in 'STALE cache hit' log + old data 379 | 380 | await new Promise((resolve) => 381 | setTimeout(async () => { 382 | const html3 = await renderToString(() => ( 383 | <> 384 | 385 | 386 | 387 | )); 388 | 389 | expect(html3).toBe(`

number: 1

number: 1

`); 390 | 391 | resolve(void 0); 392 | }, 1150), 393 | ); 394 | }); 395 | }); 396 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cli/build.ts: -------------------------------------------------------------------------------- 1 | const logs = await Bun.build({ 2 | entrypoints: ["./src/cli/index.ts"], 3 | outdir: "./dist/cli", 4 | target: "bun", 5 | // splitting: true, 6 | // minify: true, 7 | // external: ["elysia"], 8 | }); 9 | 10 | if (!logs.success) { 11 | console.log(logs.logs); 12 | process.exit(1); 13 | } 14 | -------------------------------------------------------------------------------- /packages/beth-stack/src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import { Elysia } from "elysia"; 3 | import { type ElysiaWS } from "elysia/ws"; 4 | 5 | let wsConnections = new Set>(); 6 | 7 | function dispatch() { 8 | wsConnections.forEach((connection) => { 9 | // console.log("sending refresh"); 10 | connection.send("refresh"); 11 | }); 12 | } 13 | 14 | const port = process.argv[2] || 3001; 15 | 16 | const app = new Elysia() 17 | .ws("/ws", { 18 | open(ws) { 19 | // console.log("open"); 20 | wsConnections.add(ws); 21 | }, 22 | close(ws) { 23 | // console.log("close"); 24 | wsConnections.delete(ws); 25 | }, 26 | // message(ws, message) { 27 | // console.log("message", message); 28 | // }, 29 | }) 30 | .get("/restart", () => { 31 | // console.log("recieved restart"); 32 | dispatch(); 33 | }) 34 | .listen(port); 35 | 36 | console.log( 37 | `🦊 Livereload running ${app.server?.hostname}:${app.server?.port}`, 38 | ); 39 | -------------------------------------------------------------------------------- /packages/beth-stack/src/dev/index.ts: -------------------------------------------------------------------------------- 1 | export function liveReloadScript({ 2 | debounceTime = 100, 3 | url = "ws://localhost:3001/ws", 4 | }: { 5 | url?: string; 6 | debounceTime?: number; 7 | } = {}): string { 8 | return ` 9 | let reloadTimeout; 10 | (function () { 11 | let socket = new WebSocket(\"${url}\"); 12 | 13 | socket.onopen = function(e) { 14 | console.log("connected") 15 | }; 16 | 17 | 18 | socket.onmessage = function(event) { 19 | console.log("event", event.data) 20 | // Clear any existing reload timeout 21 | clearTimeout(reloadTimeout); 22 | 23 | // Set a new reload timeout 24 | reloadTimeout = setTimeout(() => { 25 | location.reload(); 26 | }, ${debounceTime}); // 50ms debounce time 27 | }; 28 | 29 | socket.onclose = function(event) { 30 | console.log("closed"); 31 | }; 32 | 33 | socket.onerror = function(error) { 34 | console.log("error: " + error.message); 35 | }; 36 | })(); 37 | `; 38 | } 39 | -------------------------------------------------------------------------------- /packages/beth-stack/src/elysia/index.ts: -------------------------------------------------------------------------------- 1 | import "../jsx/register"; 2 | import { type Elysia } from "elysia"; 3 | import { setGlobalPersistCacheConfig } from "../cache"; 4 | import { GlobalCacheConfig } from "../cache/old-persist"; 5 | import { renderToStreamResponse, renderToStringResponse } from "../jsx"; 6 | import { BETH_GLOBAL_RENDER_CACHE } from "../shared/global"; 7 | 8 | type BethPluginOptions = GlobalCacheConfig; 9 | 10 | export function bethStack(options: Partial = {}) { 11 | // setGlobalPersistCacheConfig(options); 12 | 13 | async function html JSX.Element>( 14 | lazyHtml: T, 15 | ): Promise { 16 | return renderToStringResponse(lazyHtml); 17 | } 18 | 19 | function htmlStream JSX.Element>(lazyHtml: T): Response { 20 | return renderToStreamResponse(lazyHtml); 21 | } 22 | 23 | return function bethPlugin(app: Elysia) { 24 | return app.decorate("html", html).decorate("htmlStream", htmlStream); 25 | // ! FIX WHEN ELYSIA IS FIXED 26 | // .onRequest(() => { 27 | // BETH_GLOBAL_RENDER_CACHE.reset(); 28 | // // elysia is weird idk 29 | // return void 0; 30 | // }) 31 | // .onResponse(() => { 32 | // BETH_GLOBAL_RENDER_CACHE.reset(); 33 | // // elysia is weird idk 34 | // return void 0; 35 | // }); 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /packages/beth-stack/src/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanniser/the-beth-stack/e525b5512955e6beefcef5d78697636699cd1ac6/packages/beth-stack/src/index.ts -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/context.tsx: -------------------------------------------------------------------------------- 1 | import type { Children } from "."; 2 | 3 | class GlobalContextContext { 4 | private stack = new Array(); 5 | private map = new Map(); 6 | 7 | public set(context: Context, value: T) { 8 | this.map.set(context.id, value); 9 | } 10 | 11 | public get(context: Context): T | null { 12 | return this.map.get(context.id) ?? null; 13 | } 14 | 15 | public push(id: symbol) { 16 | this.stack.push(id); 17 | } 18 | 19 | public pop() { 20 | const id = this.stack.pop(); 21 | if (!id) { 22 | throw new Error("Context stack is empty"); 23 | } 24 | this.map.delete(id); 25 | } 26 | } 27 | 28 | export const GLOBAL_CONTEXT_CONTEXT = new GlobalContextContext(); 29 | 30 | class Context { 31 | readonly id = Symbol("context"); 32 | 33 | public Provider = ({ 34 | children, 35 | value, 36 | }: { 37 | children: (value: T) => Children; 38 | value: T; 39 | }) => { 40 | GLOBAL_CONTEXT_CONTEXT.push(this.id); 41 | GLOBAL_CONTEXT_CONTEXT.set(this, value); 42 | 43 | const [fn] = children as unknown as [(value: T) => Children]; 44 | 45 | const result = fn(value); 46 | 47 | GLOBAL_CONTEXT_CONTEXT.pop(); 48 | 49 | return <>{result}; 50 | }; 51 | } 52 | 53 | export function defineContext(): Context { 54 | return new Context(); 55 | } 56 | 57 | export function consumeContext(context: Context): T | null { 58 | return GLOBAL_CONTEXT_CONTEXT.get(context); 59 | } 60 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/error.tsx: -------------------------------------------------------------------------------- 1 | export async function ErrorBoundary({ 2 | fallback, 3 | children, 4 | }: { 5 | fallback: JSX.Element; 6 | children: JSX.Element | JSX.Element[]; 7 | }): Promise { 8 | if (!Array.isArray(children)) 9 | throw new Error("children isnt array (shouldnt be possible)"); 10 | 11 | return Promise.all(children) 12 | .then((children) => <>{children}) 13 | .catch(() => fallback); 14 | } 15 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/htmx.d.ts: -------------------------------------------------------------------------------- 1 | // This file is a result from https://github.com/Desdaemon/typed-htmx and https://htmx.org 2 | // Missing something? Please submit a issue report or a PR: 3 | // https://github.com/kitajs/html 4 | 5 | declare namespace JSX { 6 | interface HtmlTag extends Htmx.Attributes {} 7 | } 8 | 9 | declare namespace Htmx { 10 | /** 11 | * Either `true`, `false`, `"true"` or `"false"`. 12 | */ 13 | type BoolStr = boolean | "true" | "false"; 14 | type AnyStr = string & {}; 15 | type HxSwap = 16 | | "innerHTML" 17 | | "outerHTML" 18 | | "beforebegin" 19 | | "afterbegin" 20 | | "beforeend" 21 | | "afterend" 22 | | "delete" 23 | | "none" 24 | | "morph" 25 | | "morphdom"; 26 | 27 | /** 28 | * Either `this` which refers to the element itself, or a modifier followed by a CSS selector, e.g. `closest form`. 29 | */ 30 | type HxTarget = "this" | "closest " | "find " | "next " | "previous "; 31 | 32 | /** 33 | * A CSS selector, followed by one of these sync strategies, e.g. `form:abort`. 34 | */ 35 | type HxSync = 36 | | ":drop" 37 | | ":abort" 38 | | ":replace" 39 | | ":queue" 40 | | ":queue first" 41 | | ":queue last" 42 | | ":queue all"; 43 | 44 | /** 45 | * Evaluate the values given, you can prefix the values with javascript: or js:. 46 | */ 47 | type HxHeaders = AnyStr | "javascript:" | "js:"; 48 | 49 | /** 50 | * An event followed by one of these modifiers, e.g. `click once`. 51 | */ 52 | type HxTriggerModifier = 53 | | " once" 54 | | " changed" 55 | | " delay:" 56 | | " throttle:" 57 | | " from:" 58 | | " target:" 59 | | " consume" 60 | | " queue:first" 61 | | " queue:last" 62 | | " queue:all" 63 | | " queue:none"; 64 | 65 | /** 66 | * An extensible directory of htmx extensions. 67 | * 68 | * ## Declaring a new extension 69 | * 70 | * ```tsx 71 | * declare global { 72 | * namespace JSX { 73 | * interface HtmxExtensions { 74 | * myExtension: "my-extension"; 75 | * } 76 | * interface HtmlTag { 77 | * ["my-extension-attr"]?: string 78 | * // Add any other attributes your extension uses here 79 | * } 80 | * } 81 | * } 82 | * 83 | *
84 | * Hello 85 | *
86 | * ``` 87 | */ 88 | interface Extensions { 89 | /** 90 | * Includes the commonly-used `X-Requested-With` header that identifies ajax requests in many backend frameworks. 91 | * 92 | * CDN: https://unpkg.com/htmx.org/dist/ext/ajax-header.js 93 | * @see https://htmx.org/extensions/ajax-header/ 94 | */ 95 | ajaxHeaders: "ajax-headers"; 96 | 97 | /** 98 | * Server-Sent Events. 99 | * 100 | * CDN: https://unpkg.com/htmx.org/dist/ext/sse.js 101 | * @see https://htmx.org/extensions/server-sent-events/ 102 | */ 103 | serverSentEvents: "sse"; 104 | 105 | /** 106 | * WebSockets support. 107 | * 108 | * CDN: https://unpkg.com/htmx.org/dist/ext/ws.js 109 | * @see https://htmx.org/extensions/web-sockets/ 110 | */ 111 | ws: "ws"; 112 | 113 | /** 114 | * Class utilities. 115 | * 116 | * CDN: https://unpkg.com/htmx.org/dist/ext/class-tools.js 117 | * @see https://htmx.org/extensions/class-tools/ 118 | */ 119 | classTools: "class-tools"; 120 | 121 | /** 122 | * Tool for debugging htmx requests. 123 | * 124 | * CDN: https://unpkg.com/htmx.org/dist/ext/debug.js 125 | * @see https://htmx.org/extensions/debug/ 126 | */ 127 | debug: "debug"; 128 | 129 | /** 130 | * Disable elements during requests. 131 | * 132 | * CDN: https://unpkg.com/htmx.org/dist/ext/disable-element.js 133 | * @see https://htmx.org/extensions/disable-element/ 134 | */ 135 | disableElement: "disable-element"; 136 | 137 | /** 138 | * Includes a JSON serialized version of the triggering event, if any. 139 | * 140 | * CDN: https://unpkg.com/htmx.org/dist/ext/event-header.js 141 | * @see https://htmx.org/extensions/event-header/ 142 | */ 143 | eventHeader: "event-header"; 144 | 145 | /** 146 | * Support for adding tags to ``. 147 | * 148 | * CDN: https://unpkg.com/htmx.org/dist/ext/head-support.js 149 | * @see https://htmx.org/extensions/head-support/ 150 | */ 151 | headSupport: "head-support"; 152 | 153 | /** 154 | * Support for [Idiomorph](https://github.com/bigskysoftware/idiomorph), an alternative swapping mechanism for htmx. 155 | * 156 | * CDN: https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js 157 | * @see https://github.com/bigskysoftware/idiomorph#htmx 158 | */ 159 | idiomorph: "morph"; 160 | 161 | /** 162 | * Use JSON encoding in the body of requests, rather than the default `x-www-form-urlencoded`. 163 | * 164 | * CDN: https://unpkg.com/htmx.org/dist/ext/json-enc.js 165 | * @see https://htmx.org/extensions/json-enc/ 166 | */ 167 | jsonEncode: "json-enc"; 168 | 169 | /** 170 | * Support for inflight loading states. 171 | * 172 | * CDN: https://unpkg.com/htmx.org/dist/ext/loading-states.js 173 | * @see https://htmx.org/extensions/loading-states/ 174 | */ 175 | loadingStates: "loading-states"; 176 | 177 | /** 178 | * Support for [morphdom](https://github.com/patrick-steele-idem/morphdom), 179 | * an alternative swapping mechanism for htmx. 180 | * 181 | * CDN: https://unpkg.com/htmx.org/dist/ext/morphdom-swap.js 182 | * @see https://htmx.org/extensions/morphdom-swap/ 183 | */ 184 | morphdom: "morphdom"; 185 | } 186 | 187 | /** 188 | * Definitions for htmx attributes up to 1.9.3. 189 | */ 190 | interface Attributes { 191 | /** 192 | * Issues a `GET` to the specified URL. 193 | * @see https://htmx.org/attributes/hx-get/ 194 | */ 195 | ["hx-get"]?: string; 196 | 197 | /** 198 | * Issues a `POST` to the specified URL. 199 | * @see https://htmx.org/attributes/hx-post/ 200 | */ 201 | ["hx-post"]?: string; 202 | 203 | /** 204 | * Issues a `PUT` to the specified URL. 205 | * @see https://htmx.org/attributes/hx-put/ 206 | */ 207 | ["hx-put"]?: string; 208 | 209 | /** 210 | * Issues a `DELETE` to the specified URL. 211 | * @see https://htmx.org/attributes/hx-delete/ 212 | */ 213 | ["hx-delete"]?: string; 214 | 215 | /** 216 | * Issues a `PATCH` to the specified URL. 217 | * @see https://htmx.org/attributes/hx-patch/ 218 | */ 219 | ["hx-patch"]?: string; 220 | 221 | /** 222 | * Add or remove [progressive enhancement] for links and forms. 223 | * @see https://htmx.org/attributes/hx-boost/ 224 | * 225 | * [progressive enhancement]: https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement 226 | */ 227 | ["hx-boost"]?: BoolStr; 228 | 229 | /** 230 | * Handle any event with a script inline. 231 | * @see https://htmx.org/attributes/hx-on/ 232 | * @remarks Event listeners on htmx-specific events need to be specified with a spread attribute, and 233 | * are otherwise not supported in vanilla JSX. 234 | * ```jsx 235 | *
236 | * ``` 237 | * @since 1.9.3 238 | */ 239 | [`hx-on:`]?: string; 240 | 241 | /** 242 | * Handle any event with a script inline. Each listener is specified on a separate line. 243 | * @see https://htmx.org/attributes/hx-on/ 244 | * @remarks Superseded by `hx-on:$event`, unless IE11 support is required. 245 | * @since 1.9.0 246 | */ 247 | ["hx-on"]?: string; 248 | 249 | /** 250 | * Pushes the URL into the browser location bar, creating a new history entry. 251 | * @see https://htmx.org/attributes/hx-push-url/ 252 | */ 253 | ["hx-push-url"]?: BoolStr | AnyStr; 254 | 255 | /** 256 | * Select content to swap in from a response. 257 | * @see https://htmx.org/attributes/hx-select/ 258 | */ 259 | ["hx-select"]?: string; 260 | 261 | /** 262 | * Select content to swap in from a response, out of band (somewhere other than the target). 263 | * @see https://htmx.org/attributes/hx-select-oob/ 264 | */ 265 | ["hx-select-oob"]?: string; 266 | 267 | /** 268 | * Controls how content is swapped in (`outerHTML`, `beforeend`, `afterend`, …). 269 | * @see https://htmx.org/attributes/hx-swap/ 270 | * @see {@linkcode InsertPosition} which is used in [{@linkcode Element.insertAdjacentHTML}](https://developer.mozilla.org/docs/Web/API/Element/insertAdjacentHTML) 271 | * @remarks 272 | * - `morph` swaps are part of the {@linkcode Extensions.idiomorph idiomorph} extension. 273 | * - `morphdom` swaps are part of the {@linkcode Extensions.morphdom morphdom} extension. 274 | */ 275 | ["hx-swap"]?: HxSwap | AnyStr; 276 | 277 | /** 278 | * Marks content in a response to be out of band (should swap in somewhere other than the target). 279 | * @see https://htmx.org/attributes/hx-swap-oob/ 280 | */ 281 | ["hx-swap-oob"]?: "true" | HxSwap | AnyStr; 282 | 283 | /** 284 | * Specifies the target element to be swapped. 285 | * @see https://htmx.org/attributes/hx-target/ 286 | */ 287 | ["hx-target"]?: HxTarget | AnyStr; 288 | 289 | /** 290 | * Specifies the event that triggers the request. 291 | * @see https://htmx.org/attributes/hx-trigger/ 292 | */ 293 | ["hx-trigger"]?: "every " | HxTriggerModifier | AnyStr; 294 | 295 | /** 296 | * Adds values to the parameters to submit with the request (JSON-formatted). 297 | * @see https://htmx.org/attributes/hx-vals/ 298 | */ 299 | ["hx-vals"]?: HxHeaders; 300 | 301 | /** 302 | * Shows a `confirm()` dialog before issuing a request. 303 | * @see https://htmx.org/attributes/hx-confirm/ 304 | */ 305 | ["hx-confirm"]?: string; 306 | 307 | /** 308 | * Disables htmx processing for the given node and any children nodes. 309 | * @see https://htmx.org/attributes/hx-disable/ 310 | */ 311 | ["hx-disable"]?: boolean; 312 | 313 | /** 314 | * Control and disable automatic attribute inheritance for child nodes. 315 | * @see https://htmx.org/attributes/hx-disinherit/ 316 | */ 317 | ["hx-disinherit"]?: "*" | AnyStr; 318 | 319 | /** 320 | * Changes the request encoding type. 321 | * @see https://htmx.org/attributes/hx-encoding/ 322 | */ 323 | ["hx-encoding"]?: "multipart/form-data"; 324 | 325 | /** 326 | * Extensions to use for this element. 327 | * @see https://htmx.org/attributes/hx-ext/ 328 | * @see {@linkcode Extensions} for how to declare extensions in JSX. 329 | */ 330 | ["hx-ext"]?: Htmx.Extensions[keyof Htmx.Extensions] | "ignore:" | AnyStr; 331 | 332 | /** 333 | * Adds to the headers that will be submitted with the request. 334 | * @see https://htmx.org/attributes/hx-headers/ 335 | */ 336 | ["hx-headers"]?: HxHeaders | AnyStr; 337 | 338 | /** 339 | * Prevent sensitive data being saved to the history cache. 340 | * @see https://htmx.org/attributes/hx-history/ 341 | */ 342 | ["hx-history"]?: "false"; 343 | 344 | /** 345 | * The element to snapshot and restore during history navigation. 346 | * @see https://htmx.org/attributes/hx-history-elt/ 347 | */ 348 | ["hx-history-elt"]?: boolean; 349 | 350 | /** 351 | * Include additional data in requests. 352 | * @see https://htmx.org/attributes/hx-include/ 353 | */ 354 | ["hx-include"]?: string; 355 | 356 | /** 357 | * The element to put the `htmx-request` class on during the request. 358 | * @see https://htmx.org/attributes/hx-indicator/ 359 | */ 360 | ["hx-indicator"]?: string; 361 | 362 | /** 363 | * Filters the parameters that will be submitted with a request. 364 | * @see https://htmx.org/attributes/hx-params/ 365 | */ 366 | ["hx-params"]?: "*" | "none" | "not " | AnyStr; 367 | 368 | /** 369 | * Specifies elements to keep unchanged between requests. 370 | * @see https://htmx.org/attributes/hx-preserve/ 371 | * @remarks `true` is only observed by the `head-support` extension, 372 | * where it prevents an element from being removed from the ``. 373 | */ 374 | ["hx-preserve"]?: boolean | "true"; 375 | 376 | /** 377 | * Shows a `prompt()` before submitting a request. 378 | * @see https://htmx.org/attributes/hx-prompt/ 379 | */ 380 | ["hx-prompt"]?: string; 381 | 382 | /** 383 | * Replace the URL in the browser location bar. 384 | * @see https://htmx.org/attributes/hx-replace-url/ 385 | */ 386 | ["hx-replace-url"]?: BoolStr | AnyStr; 387 | 388 | /** 389 | * Configures various aspects of the request. 390 | * @see https://htmx.org/attributes/hx-request/ 391 | */ 392 | ["hx-request"]?: 393 | | `"timeout": ` 394 | | `"credentials": ` 395 | | `"noHeaders": ` 396 | | HxHeaders; 397 | 398 | /** 399 | * Control how requests made by different elements are synchronized. 400 | * @see https://htmx.org/attributes/hx-sync/ 401 | */ 402 | ["hx-sync"]?: HxSync; 403 | 404 | /** 405 | * Force elements to validate themselves before a request. 406 | * @see https://htmx.org/attributes/hx-validate/ 407 | */ 408 | ["hx-validate"]?: boolean; 409 | 410 | /** 411 | * Adds values dynamically to the parameters to submit with the request. 412 | * @deprecated superseded by `hx-vals` 413 | */ 414 | ["hx-vars"]?: AnyStr; 415 | 416 | /** 417 | * The URL of the SSE server. 418 | * @see https://htmx.org/extensions/server-sent-events/ 419 | */ 420 | ["sse-connect"]?: string; 421 | 422 | /** 423 | * The name of the message to swap into the DOM. 424 | * @see https://htmx.org/extensions/server-sent-events/ 425 | */ 426 | ["sse-swap"]?: string; 427 | 428 | /** 429 | * A URL to establish a WebSocket connection against. 430 | * @see https://htmx.org/extensions/web-sockets/ 431 | */ 432 | ["ws-connect"]?: string; 433 | 434 | /** 435 | * Sends a message to the nearest websocket based on the trigger value for the element. 436 | * @see https://htmx.org/extensions/web-sockets/ 437 | */ 438 | ["ws-send"]?: boolean; 439 | 440 | /** 441 | * Apply class transitions on this element. 442 | * @see https://htmx.org/extensions/class-tools/ 443 | */ 444 | ["classes"]?: `add ` | `remove ` | `toggle ` | AnyStr; 445 | 446 | /** 447 | * The element or elements to disable during requests. 448 | * Accepts CSS selectors. 449 | * @see https://htmx.org/extensions/disable-element/ 450 | */ 451 | ["hx-disable-element"]?: "self" | AnyStr; 452 | 453 | /** 454 | * The strategy for merging new head content. 455 | * @see https://htmx.org/extensions/head-support/ 456 | */ 457 | ["hx-head"]?: "merge" | "append" | "re-eval"; 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * FORK OF [kitajs/html](https://github.com/kitajs/html) 3 | */ 4 | /// 5 | 6 | import { BETH_GLOBAL_RENDER_CACHE } from "../shared/global"; 7 | import { ErrorBoundary } from "./error"; 8 | import { Suspense } from "./suspense"; 9 | import { 10 | attributesToString, 11 | contentsToString, 12 | deepFlatten, 13 | isVoidElement, 14 | } from "./utils"; 15 | 16 | type Children = 17 | | number 18 | | string 19 | | Promise 20 | | boolean 21 | | null 22 | | undefined 23 | | Children[]; 24 | 25 | type PropsWithChildren = { 26 | children?: Children; 27 | } & T; 28 | 29 | type Component = (props: PropsWithChildren) => JSX.Element; 30 | type AsyncComponent = (props: PropsWithChildren) => Promise; 31 | type SyncComponent = (props: PropsWithChildren) => string; 32 | 33 | const Fragment: unique symbol = Symbol.for("beth-stack-fragment"); 34 | 35 | async function createElement( 36 | name: string | Component | typeof Fragment, 37 | attributes: PropsWithChildren | null, 38 | ...children: Children[] 39 | ): Promise { 40 | children = deepFlatten(children); 41 | 42 | const hasAnyPromiseChildren = children.reduce( 43 | (acc, child) => acc || child instanceof Promise, 44 | false, 45 | ); 46 | const hasAnyUnresolvedPromiseChildren = children.reduce( 47 | (acc, child) => acc || Bun.peek.status(child) !== "fulfilled", 48 | false, 49 | ); 50 | 51 | const insideStreamCall = 52 | BETH_GLOBAL_RENDER_CACHE.streamController !== undefined; 53 | 54 | if ( 55 | name === Suspense && 56 | hasAnyUnresolvedPromiseChildren && 57 | insideStreamCall 58 | ) { 59 | const id = BETH_GLOBAL_RENDER_CACHE.registerChild(children); 60 | 61 | if (attributes !== null && "fallback" in attributes) { 62 | attributes.fallback = ` 63 |
64 | ${await attributes.fallback} 65 |
66 | `; 67 | } 68 | } else if (name !== ErrorBoundary) { 69 | if (hasAnyPromiseChildren) { 70 | // Converts children to a string if they are promises. 71 | children = await Promise.all(children); 72 | } 73 | } 74 | 75 | // Adds the children to the attributes if it is not present. 76 | if (attributes === null) { 77 | attributes = { children }; 78 | } 79 | 80 | // Calls the element creator function if the name is a function 81 | if (typeof name === "function") { 82 | // In case the children attributes is not present, add it as a property. 83 | if (attributes.children === undefined) { 84 | attributes.children = children; 85 | } 86 | 87 | return name(attributes); 88 | } 89 | 90 | if (name === Fragment) { 91 | return contentsToString(children); 92 | } 93 | 94 | // Switches the tag name when this custom `tag` is present. 95 | if (name === "tag" && "of" in attributes) { 96 | name = String(attributes.of); 97 | delete attributes.of; 98 | } 99 | 100 | if (children.length === 0 && isVoidElement(name)) { 101 | return "<" + name + attributesToString(attributes) + "/>"; 102 | } 103 | 104 | return ( 105 | "<" + 106 | name + 107 | attributesToString(attributes) + 108 | ">" + 109 | contentsToString( 110 | children, 111 | "safe" in attributes && 112 | typeof attributes.safe === "boolean" && 113 | attributes.safe, 114 | ) + 115 | "" 118 | ); 119 | } 120 | 121 | function compile< 122 | P extends { [K in keyof P]: K extends "children" ? Children : string }, 123 | >( 124 | cleanComponent: SyncComponent

, 125 | strict: boolean = true, 126 | separator: string = "/*\x00*/", 127 | ): SyncComponent

{ 128 | const properties = new Set(); 129 | 130 | const html = cleanComponent( 131 | // @ts-expect-error - this proxy will meet the props with children requirements. 132 | new Proxy( 133 | {}, 134 | { 135 | get(_, name) { 136 | // Adds the property to the set of known properties. 137 | properties.add(name); 138 | 139 | const isChildren = name === "children"; 140 | let access = `args[${separator}\`${name.toString()}\`${separator}]`; 141 | 142 | // Adds support to render multiple children 143 | if (isChildren) { 144 | access = `Array.isArray(${access}) ? ${access}.join(${separator}\`\`${separator}) : ${access}`; 145 | } 146 | 147 | // Uses ` to avoid content being escaped. 148 | return `\`${separator} + (${access} || ${ 149 | strict && !isChildren 150 | ? `throwPropertyNotFound(${separator}\`${name.toString()}\`${separator})` 151 | : `${separator}\`\`${separator}` 152 | }) + ${separator}\``; 153 | }, 154 | }, 155 | ), 156 | ); 157 | 158 | const sepLength = separator.length; 159 | const length = html.length; 160 | 161 | // Adds the throwPropertyNotFound function if strict 162 | let body = ""; 163 | let nextStart = 0; 164 | let index = 0; 165 | 166 | // Escapes every ` without separator 167 | for (; index < length; index++) { 168 | // Escapes the backtick character because it will be used to wrap the string 169 | // in a template literal. 170 | if ( 171 | html[index] === "`" && 172 | html.slice(index - sepLength, index) !== separator && 173 | html.slice(index + 1, index + sepLength + 1) !== separator 174 | ) { 175 | body += html.slice(nextStart, index) + "\\`"; 176 | nextStart = index + 1; 177 | continue; 178 | } 179 | 180 | // Escapes the backslash character because it will be used to escape the 181 | // backtick character. 182 | if (html[index] === "\\") { 183 | body += html.slice(nextStart, index) + "\\\\"; 184 | nextStart = index + 1; 185 | continue; 186 | } 187 | } 188 | 189 | // Adds the remaining string 190 | body += html.slice(nextStart); 191 | 192 | if (strict) { 193 | // eslint-disable-next-line no-new-func 194 | return Function( 195 | "args", 196 | // Checks for args presence 197 | 'if (args === undefined) { throw new Error("The arguments object was not provided.") };\n' + 198 | // Function to throw when a property is not found 199 | 'function throwPropertyNotFound(name) { throw new Error("Property " + name + " was not provided.") };\n' + 200 | // Concatenates the body 201 | `return \`${body}\``, 202 | ) as SyncComponent

; 203 | } 204 | 205 | // eslint-disable-next-line no-new-func 206 | return Function( 207 | "args", 208 | // Adds a empty args object when it is not present 209 | "if (args === undefined) { args = Object.create(null) };\n" + 210 | `return \`${body}\``, 211 | ) as SyncComponent

; 212 | } 213 | 214 | export default { 215 | createElement, 216 | Fragment, 217 | compile, 218 | }; 219 | 220 | export { 221 | Children, 222 | PropsWithChildren, 223 | Component, 224 | AsyncComponent, 225 | SyncComponent, 226 | }; 227 | 228 | export { Suspense } from "./suspense"; 229 | export { ErrorBoundary } from "./error"; 230 | export { 231 | renderToStream, 232 | renderToStreamResponse, 233 | renderToString, 234 | renderToStringResponse, 235 | } from "./render"; 236 | export { defineContext, consumeContext } from "./context"; 237 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/jsx.d.ts: -------------------------------------------------------------------------------- 1 | // This file is a result from many sources, including: RFCs, typescript dom lib, w3schools, and others. 2 | // Possibly there are many tags/attributes missing, but it is a good start. 3 | // Missing something? Please submit a issue report or a PR: 4 | // https://github.com/kitajs/html 5 | 6 | declare namespace JSX { 7 | type Element = Promise; 8 | 9 | /** 10 | * The index signature was removed to enable closed typing for style 11 | * using CSSType. You're able to use type assertion or module augmentation 12 | * to add properties or an index signature of your own. 13 | * 14 | * For examples and more information, visit: 15 | * https://github.com/frenic/csstype#what-should-i-do-when-i-get-type-errors 16 | */ 17 | // why does it remove the import string????? 18 | // prettier-ignore 19 | type CSSProperties = import("csstype").Properties; 20 | 21 | interface HtmlTag extends ElementChildrenAttribute, IntrinsicAttributes { 22 | accesskey?: undefined | string; 23 | class?: undefined | string; 24 | contenteditable?: undefined | string; 25 | dir?: undefined | string; 26 | hidden?: undefined | string | boolean; 27 | id?: undefined | string; 28 | role?: undefined | string; 29 | lang?: undefined | string; 30 | draggable?: undefined | string | boolean; 31 | spellcheck?: undefined | string | boolean; 32 | tabindex?: undefined | number | string; 33 | title?: undefined | string; 34 | translate?: undefined | string | boolean; 35 | 36 | /** 37 | * A css style attribute which also supports a `csstype` object. 38 | */ 39 | style?: undefined | string | CSSProperties; 40 | 41 | /** 42 | * Tells if any inner html should be escaped. 43 | * 44 | * **Warning: This also escapes inner jsx tags. You should only use this in the inner tag.** 45 | * 46 | * @example 47 | * ```tsx 48 | *

{' 23 | `; 24 | 25 | function isNotFulfilled(child: Promise) { 26 | return Bun.peek.status(child) !== "fulfilled"; 27 | } 28 | 29 | export async function Suspense({ 30 | fallback, 31 | children, 32 | }: { 33 | fallback: JSX.Element; 34 | children: JSX.Element | JSX.Element[]; 35 | }): Promise { 36 | if (!Array.isArray(children)) 37 | throw new Error("children isnt array (shouldnt be possible)"); 38 | 39 | const hasAnyUnresolvedPromiseChildren = children.some(isNotFulfilled); 40 | 41 | if (!hasAnyUnresolvedPromiseChildren) { 42 | return children.join(""); 43 | } 44 | 45 | const suspended = Promise.all(children); 46 | suspended.then((childrenContent) => { 47 | setTimeout(() => { 48 | const id = BETH_GLOBAL_RENDER_CACHE.dismissChild(children); 49 | if (!id) { 50 | BETH_GLOBAL_RENDER_CACHE.streamController?.error( 51 | "Suspense children not found", 52 | ); 53 | throw new Error("Suspense children not found"); 54 | } 55 | const content = childrenContent.join(""); 56 | 57 | let withScript = ` 58 | 61 | 64 | `; 65 | 66 | if (!BETH_GLOBAL_RENDER_CACHE.sentFirstChunk) { 67 | withScript = swapScript + withScript; 68 | BETH_GLOBAL_RENDER_CACHE.sentFirstChunk = true; 69 | } 70 | 71 | BETH_GLOBAL_RENDER_CACHE.streamController?.enqueue(withScript); 72 | 73 | BETH_GLOBAL_RENDER_CACHE.checkIfEndAndClose(); 74 | }, 0); 75 | }); 76 | return fallback; 77 | } 78 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/tests/context.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { consumeContext, defineContext } from "../context"; 3 | import "../register"; 4 | 5 | const MyContext = defineContext(); 6 | const NestedContext = defineContext(); 7 | 8 | function ConsumerComponent() { 9 | const value = consumeContext(MyContext); 10 | return
{value}
; 11 | } 12 | 13 | test("provides and consumes context", async () => { 14 | const html = await ( 15 | 16 | {() => } 17 | 18 | ); 19 | 20 | expect(html).toBe(`
42
`); 21 | }); 22 | 23 | test("returns null if context not provided", async () => { 24 | const value = consumeContext(MyContext); 25 | expect(value).toBe(null); 26 | }); 27 | 28 | function OuterConsumer() { 29 | const value = consumeContext(MyContext); 30 | return
{value}
; 31 | } 32 | 33 | function InnerConsumer() { 34 | const value = consumeContext(NestedContext); 35 | return
{value}
; 36 | } 37 | 38 | function DualConsumer() { 39 | const outerValue = consumeContext(MyContext); 40 | const innerValue = consumeContext(NestedContext); 41 | return ( 42 |
43 | {outerValue}-{innerValue} 44 |
45 | ); 46 | } 47 | 48 | test("provides and consumes nested context", async () => { 49 | const html = await ( 50 | 51 | {() => ( 52 | 53 | {() => } 54 | 55 | )} 56 | 57 | ); 58 | 59 | expect(html).toBe(`
42-nested
`); 60 | }); 61 | 62 | test("outer context is available to inner consumer, but not vice versa", async () => { 63 | const html1 = await ( 64 | 65 | {() => } 66 | 67 | ); 68 | 69 | expect(html1).toBe(`
`); 70 | 71 | const html2 = await ( 72 | 73 | {() => } 74 | 75 | ); 76 | 77 | expect(html2).toBe(`
`); 78 | }); 79 | 80 | test("context values are only accessible below their providers", async () => { 81 | const outerValueBefore = consumeContext(MyContext); 82 | const innerValueBefore = consumeContext(NestedContext); 83 | 84 | expect(outerValueBefore).toBe(null); 85 | expect(innerValueBefore).toBe(null); 86 | 87 | const html = await ( 88 | 89 | {() => ( 90 | 91 | {() => } 92 | 93 | )} 94 | 95 | ); 96 | 97 | const outerValueAfter = consumeContext(MyContext); 98 | const innerValueAfter = consumeContext(NestedContext); 99 | 100 | expect(outerValueAfter).toBe(null); 101 | expect(innerValueAfter).toBe(null); 102 | expect(html).toBe(`
42-nested
`); 103 | }); 104 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/tests/error.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import "../register"; 3 | import { ErrorBoundary } from "../error"; 4 | import { Suspense } from "../suspense"; 5 | 6 | async function Reject() { 7 | const data = await Promise.reject("error"); 8 | 9 | return
loaded in: {data}ms
; 10 | } 11 | 12 | test("catches errors", async () => { 13 | const html = await ( 14 | error

}> 15 | 16 |
17 | ); 18 | 19 | expect(html).toBe(`

error

`); 20 | }); 21 | 22 | test("catches errors in suspense", async () => { 23 | const html = await ( 24 | error

}> 25 | loading...
}> 26 | 27 | 28 | 29 | ); 30 | 31 | expect(html).toBe(`

error

`); 32 | 33 | const html2 = await ( 34 | loading...
}> 35 | error

}> 36 | 37 |
38 | 39 | ); 40 | 41 | expect(html2).toBe(`

error

`); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/tests/render.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import "../register"; 3 | import { 4 | renderToStream, 5 | renderToStreamResponse, 6 | renderToString, 7 | renderToStringResponse, 8 | } from "../render"; 9 | import { Suspense, swapScript } from "../suspense"; 10 | 11 | function wait(ms: number): Promise { 12 | return new Promise((resolve) => 13 | setTimeout(() => { 14 | resolve(ms); 15 | }, ms), 16 | ); 17 | } 18 | 19 | async function Wait({ ms }: { ms: number }) { 20 | const data = await wait(ms); 21 | 22 | return
loaded in: {data}ms
; 23 | } 24 | 25 | test("multiple children", async () => { 26 | const html = await ( 27 |
28 | 29 | 30 | 31 |
32 | ); 33 | 34 | expect(html).toBe( 35 | `
loaded in: 20ms
loaded in: 30ms
loaded in: 10ms
`, 36 | ); 37 | }); 38 | 39 | test("multiple children - nested", async () => { 40 | const html = await ( 41 |
42 | 43 | 44 |
45 | 46 | 47 |
48 |
49 | ); 50 | 51 | expect(html).toBe( 52 | `
loaded in: 20ms
loaded in: 30ms
loaded in: 10ms
loaded in: 20ms
`, 53 | ); 54 | }); 55 | 56 | test("array children 1", async () => { 57 | const html = await ( 58 |
59 |
60 | {[1, 2, 3].map((i) => ( 61 | 62 | ))} 63 |
64 |
65 | ); 66 | 67 | expect(html).toBe( 68 | `
loaded in: 10ms
loaded in: 20ms
loaded in: 30ms
`, 69 | ); 70 | }); 71 | 72 | test("array children - nested", async () => { 73 | const html = await ( 74 |
75 | {[1, 2, 3].map((_) => [1, 2, 3].map((i) => ))} 76 |
77 | {[1, 2, 3].map((i) => ( 78 | 79 | ))} 80 |
81 |
82 | ); 83 | 84 | expect(html.replace(/[\s\n\t\r]+/g, "")).toBe( 85 | ` 86 |
87 |
loaded in: 10ms
88 |
loaded in: 20ms
89 |
loaded in: 30ms
90 |
loaded in: 10ms
91 |
loaded in: 20ms
92 |
loaded in: 30ms
93 |
loaded in: 10ms
94 |
loaded in: 20ms
95 |
loaded in: 30ms
96 |
97 |
loaded in: 10ms
98 |
loaded in: 20ms
99 |
loaded in: 30ms
100 |
101 |
102 | `.replace(/[\s\n\t\r]+/g, ""), 103 | ); 104 | }); 105 | 106 | test("no render func, resolves immediately", async () => { 107 | const html = await ( 108 | loading...

}> 109 |

hi

110 |
111 | ); 112 | 113 | expect(html).toBe(`

hi

`); 114 | }); 115 | 116 | test("no render func, doesn't resolve immediately", async () => { 117 | const html = await ( 118 | loading...

}> 119 | 120 |
121 | ); 122 | 123 | expect(html).toBe(`
loaded in: 100ms
`); 124 | }); 125 | 126 | test("no render func, doesn't resolve immediately - multiple children", async () => { 127 | const html = await ( 128 | loading...

}> 129 | 130 | 131 | 132 |
133 | ); 134 | 135 | expect(html).toBe( 136 | `
loaded in: 50ms
loaded in: 40ms
loaded in: 10ms
`, 137 | ); 138 | }); 139 | 140 | test("renderToString, resolves immediately", async () => { 141 | const html = await renderToString(() => ( 142 | loading...

}> 143 |

hi

144 |
145 | )); 146 | 147 | expect(html).toBe(`

hi

`); 148 | }); 149 | 150 | test("renderToString, resolves immediately - mutiple children", async () => { 151 | const html = await renderToString(() => ( 152 | loading...

}> 153 |

hi

154 |

bye

155 |
156 | )); 157 | 158 | expect(html).toBe(`

hi

bye

`); 159 | }); 160 | 161 | test("renderToString, doesn't resolve immediately", async () => { 162 | const html = await renderToString(() => ( 163 | loading...

}> 164 | 165 |
166 | )); 167 | 168 | expect(html).toBe(`
loaded in: 100ms
`); 169 | }); 170 | 171 | test("renderToString, doesn't resolve immediately - multiple children", async () => { 172 | const html = await renderToString(() => ( 173 | loading...

}> 174 | 175 | 176 |
177 | )); 178 | 179 | expect(html).toBe(`
loaded in: 100ms
loaded in: 50ms
`); 180 | }); 181 | 182 | test("renderToStream, resolves immediately", async () => { 183 | const res = renderToStreamResponse(() => ( 184 | loading...

}> 185 |

hi

186 |
187 | )); 188 | 189 | const html = await res.text(); 190 | 191 | expect(html).toBe(`

hi

`); 192 | }); 193 | 194 | test("renderToStream, doesn't resolve immediately", async () => { 195 | console.log("renderToStream, doesn't resolve immediately"); 196 | const stream = renderToStream(() => ( 197 | loading...

}> 198 | 199 |
200 | )); 201 | 202 | const expectedChunks = [ 203 | ` 204 |
205 |

loading...

206 |
207 | `, 208 | swapScript + 209 | ` 210 | 213 | 216 | `, 217 | ]; 218 | 219 | let index = 0; 220 | 221 | for await (const chunk of stream) { 222 | expect(chunk.replace(/[\s\n\t\r]+/g, "")).toBe( 223 | expectedChunks[index]!.replace(/[\s\n\t\r]+/g, ""), 224 | ); 225 | index++; 226 | } 227 | 228 | expect(index).toBe(expectedChunks.length); 229 | }); 230 | 231 | test("renderTo*Response adds html headers", async () => { 232 | const res = renderToStreamResponse(() => ( 233 | loading...

}> 234 |

hi

235 |
236 | )); 237 | 238 | expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); 239 | 240 | const res2 = await renderToStringResponse(() => ( 241 | loading...

}> 242 |

hi

243 |
244 | )); 245 | 246 | expect(res2.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); 247 | }); 248 | -------------------------------------------------------------------------------- /packages/beth-stack/src/jsx/utils.ts: -------------------------------------------------------------------------------- 1 | ; 2 | 3 | /** 4 | * FORK OF [kitajs/html](https://github.com/kitajs/html) 5 | */ 6 | 7 | import type { Children } from "."; 8 | 9 | 10 | ; 11 | 12 | 13 | 14 | 15 | 16 | const CAMEL_REGEX = /[a-z][A-Z]/; 17 | 18 | export function isUpper(input: string, index: number): boolean { 19 | const code = input.charCodeAt(index); 20 | return code >= 65 /* A */ && code <= 90; /* Z */ 21 | } 22 | 23 | export function isVoidElement(tag: string): boolean { 24 | // Ordered by most common to least common. 25 | return ( 26 | tag === "meta" || 27 | tag === "link" || 28 | tag === "img" || 29 | tag === "br" || 30 | tag === "input" || 31 | tag === "hr" || 32 | tag === "area" || 33 | tag === "base" || 34 | tag === "col" || 35 | tag === "command" || 36 | tag === "embed" || 37 | tag === "keygen" || 38 | tag === "param" || 39 | tag === "source" || 40 | tag === "track" || 41 | tag === "wbr" 42 | ); 43 | } 44 | 45 | export function styleToString(style: object | string): string { 46 | // Faster escaping process that only looks for the " character. 47 | // As we use the " character to wrap the style string, we need to escape it. 48 | if (typeof style === "string") { 49 | let end = style.indexOf('"'); 50 | 51 | // This is a optimization to avoid having to look twice for the " character. 52 | // And make the loop already start in the middle 53 | if (end === -1) { 54 | return style; 55 | } 56 | 57 | const length = style.length; 58 | 59 | let escaped = ""; 60 | let start = 0; 61 | 62 | // Faster than using regex 63 | // https://jsperf.app/kakihu 64 | for (; end < length; end++) { 65 | if (style[end] === '"') { 66 | escaped += style.slice(start, end) + """; 67 | start = end + 1; 68 | } 69 | } 70 | 71 | // Appends the remaining string. 72 | escaped += style.slice(start, end); 73 | 74 | return escaped; 75 | } 76 | 77 | const keys = Object.keys(style); 78 | const length = keys.length; 79 | 80 | let key; 81 | let value; 82 | let index = 0; 83 | let result = ""; 84 | 85 | for (; index < length; index++) { 86 | key = keys[index]; 87 | // @ts-expect-error - this indexing is safe. 88 | value = style[key]; 89 | 90 | if (value === null || value === undefined) { 91 | continue; 92 | } 93 | 94 | // @ts-expect-error - this indexing is safe. 95 | result += toKebabCase(key) + ":"; 96 | 97 | // Only needs escaping when the value is a string. 98 | if (typeof value !== "string") { 99 | result += value.toString() + ";"; 100 | continue; 101 | } 102 | 103 | let end = value.indexOf('"'); 104 | 105 | // This is a optimization to avoid having to look twice for the " character. 106 | // And make the loop already start in the middle 107 | if (end === -1) { 108 | result += value + ";"; 109 | continue; 110 | } 111 | 112 | const length = value.length; 113 | let start = 0; 114 | 115 | // Faster than using regex 116 | // https://jsperf.app/kakihu 117 | for (; end < length; end++) { 118 | if (value[end] === '"') { 119 | result += value.slice(start, end) + """; 120 | start = end + 1; 121 | } 122 | } 123 | 124 | // Appends the remaining string. 125 | result += value.slice(start, end) + ";"; 126 | } 127 | 128 | return result; 129 | } 130 | 131 | export function attributesToString(attributes: object): string { 132 | if (!attributes) { 133 | return ""; 134 | } 135 | 136 | const keys = Object.keys(attributes); 137 | const length = keys.length; 138 | 139 | let key, value, type; 140 | let result = ""; 141 | let index = 0; 142 | 143 | for (; index < length; index++) { 144 | key = keys[index]; 145 | 146 | // Skips all @kitajs/html specific attributes. 147 | if (key === "children" || key === "safe") { 148 | continue; 149 | } 150 | 151 | // @ts-expect-error - this indexing is safe. 152 | value = attributes[key]; 153 | 154 | // React className compatibility. 155 | if (key === "className") { 156 | // @ts-expect-error - both were provided, so use the class attribute. 157 | if (attributes.class !== undefined) { 158 | continue; 159 | } 160 | 161 | key = "class"; 162 | } 163 | 164 | if (key === "style") { 165 | result += ' style="' + styleToString(value) + '"'; 166 | continue; 167 | } 168 | 169 | type = typeof value; 170 | 171 | if (type === "boolean") { 172 | // Only add the attribute if the value is true. 173 | if (value) { 174 | result += " " + key; 175 | } 176 | 177 | continue; 178 | } 179 | 180 | if (value === null || value === undefined) { 181 | continue; 182 | } 183 | 184 | result += " " + key; 185 | 186 | if (type !== "string") { 187 | // Non objects are 188 | if (type !== "object") { 189 | result += '="' + value.toString() + '"'; 190 | continue; 191 | 192 | // Dates are always safe 193 | } else if (value instanceof Date) { 194 | result += '="' + value.toISOString() + '"'; 195 | continue; 196 | } 197 | 198 | // The object may have a overridden toString method. 199 | // Which results in a non escaped string. 200 | value = value.toString(); 201 | } 202 | 203 | let end = value.indexOf('"'); 204 | 205 | // This is a optimization to avoid having to look twice for the " character. 206 | // And make the loop already start in the middle 207 | if (end === -1) { 208 | result += '="' + value + '"'; 209 | continue; 210 | } 211 | 212 | result += '="'; 213 | 214 | const length = value.length; 215 | let start = 0; 216 | 217 | // Faster than using regex 218 | // https://jsperf.app/kakihu 219 | for (; end < length; end++) { 220 | if (value[end] === '"') { 221 | result += value.slice(start, end) + """; 222 | start = end + 1; 223 | } 224 | } 225 | 226 | // Appends the remaining string. 227 | result += value.slice(start, end) + '"'; 228 | } 229 | 230 | return result; 231 | } 232 | 233 | export function toKebabCase(camel: string): string { 234 | // This is a optimization to avoid the whole conversion process when the 235 | // string does not contain any uppercase characters. 236 | if (!CAMEL_REGEX.test(camel)) { 237 | return camel; 238 | } 239 | 240 | const length = camel.length; 241 | 242 | let start = 0; 243 | let end = 0; 244 | let kebab = ""; 245 | let prev = true; 246 | let curr = isUpper(camel, 0); 247 | let next; 248 | 249 | for (; end < length; end++) { 250 | next = isUpper(camel, end + 1); 251 | 252 | // detects the start of a new camel case word and avoid lowercasing abbreviations. 253 | if (!prev && curr && !next) { 254 | // @ts-expect-error - this indexing is safe. 255 | kebab += camel.slice(start, end) + "-" + camel[end].toLowerCase(); 256 | start = end + 1; 257 | } 258 | 259 | prev = curr; 260 | curr = next; 261 | } 262 | 263 | // Appends the remaining string. 264 | kebab += camel.slice(start, end); 265 | 266 | return kebab; 267 | } 268 | 269 | export function contentsToString( 270 | contents: Children[], 271 | escape?: boolean, 272 | ): string { 273 | const length = contents.length; 274 | 275 | if (length === 0) { 276 | return ""; 277 | } 278 | 279 | let result = ""; 280 | let content; 281 | let index = 0; 282 | 283 | for (; index < length; index++) { 284 | content = contents[index]; 285 | 286 | // Ignores non 0 falsy values 287 | if (!content && content !== 0) { 288 | continue; 289 | } 290 | 291 | if (Array.isArray(content)) { 292 | result += contentsToString(content, escape); 293 | } else if (escape === true) { 294 | result += Bun.escapeHTML(content); 295 | } else { 296 | result += content; 297 | } 298 | } 299 | 300 | return result; 301 | } 302 | 303 | export function deepFlatten(arr: (T | T[])[]): T[] { 304 | return arr.reduce((acc, val) => { 305 | return Array.isArray(val) ? acc.concat(deepFlatten(val)) : acc.concat(val); 306 | }, []); 307 | } -------------------------------------------------------------------------------- /packages/beth-stack/src/shared/global.ts: -------------------------------------------------------------------------------- 1 | import { BethPersistedCache } from "../cache/new-persist"; 2 | import { BethRenderCache } from "../cache/render"; 3 | 4 | declare global { 5 | var BETH_GLOBAL_RENDER_CACHE: BethRenderCache; 6 | var BETH_GLOBAL_PERSISTED_CACHE: BethPersistedCache; 7 | } 8 | 9 | globalThis.BETH_GLOBAL_RENDER_CACHE ??= new BethRenderCache(); 10 | globalThis.BETH_GLOBAL_PERSISTED_CACHE ??= new BethPersistedCache(); 11 | 12 | export const BETH_GLOBAL_RENDER_CACHE = globalThis.BETH_GLOBAL_RENDER_CACHE; 13 | export const BETH_GLOBAL_PERSISTED_CACHE = 14 | globalThis.BETH_GLOBAL_PERSISTED_CACHE; 15 | 16 | declare global { 17 | var RENDER_COUNT: number; 18 | } 19 | 20 | globalThis.RENDER_COUNT ??= 0; 21 | globalThis.RENDER_COUNT++; 22 | -------------------------------------------------------------------------------- /packages/beth-stack/src/turso/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthenticationAPI, 3 | DatabaseAPI, 4 | DatabaseInstanceAPI, 5 | GroupAPI, 6 | LocationAPI, 7 | LogicalDatabaseAPI, 8 | OrganizationAPI, 9 | } from "./types"; 10 | 11 | export class TursoClient { 12 | private BASE_URL = "https://api.turso.tech"; 13 | constructor(private API_TOKEN: string) {} 14 | 15 | private async fetch(path: string, options?: RequestInit): Promise { 16 | const res = await fetch(`${this.BASE_URL}${path}`, { 17 | ...options, 18 | headers: { 19 | Authorization: `Bearer ${this.API_TOKEN}`, 20 | ...options?.headers, 21 | }, 22 | }); 23 | if (!res.ok) { 24 | throw new Error(`Error fetching ${path}: ${res.statusText}`); 25 | } 26 | return res.json(); 27 | } 28 | 29 | public databases: DatabaseAPI = { 30 | create: ({ name, location, image, group }) => 31 | this.fetch("/v1/databases", { 32 | method: "POST", 33 | body: JSON.stringify({ 34 | name, 35 | location, 36 | image, 37 | group, 38 | }), 39 | }), 40 | }; 41 | 42 | public authentication: AuthenticationAPI = { 43 | listTokens: () => this.fetch("/v1/auth/api-tokens"), 44 | mintToken: (token_name: string) => 45 | this.fetch(`/v1/auth/api-tokens/${token_name}`, { 46 | method: "POST", 47 | }), 48 | revokeToken: (token_name: string) => 49 | this.fetch(`/v1/auth/api-tokens/${token_name}`, { 50 | method: "DELETE", 51 | }), 52 | validateToken: () => this.fetch(`/v1/auth/validate`), 53 | }; 54 | 55 | public organization: OrganizationAPI = { 56 | list: () => this.fetch("/v1/organizations"), 57 | listMembers: (org_slug: string) => 58 | this.fetch(`/v1/organizations/${org_slug}/members`), 59 | }; 60 | 61 | public locations: LocationAPI = { 62 | list: () => this.fetch("/v1/locations"), 63 | }; 64 | 65 | public logicalDatabases: LogicalDatabaseAPI = { 66 | getAll: (org_slug: string) => 67 | this.fetch(`/v1/organizations/${org_slug}/databases`), 68 | getByName: (org_slug: string, db_name: string) => 69 | this.fetch(`/v1/organizations/${org_slug}/databases/${db_name}`), 70 | create: (org_slug: string, name: string, image: "latest" | "canary") => 71 | this.fetch(`/v1/organizations/${org_slug}/databases`, { 72 | method: "POST", 73 | body: JSON.stringify({ 74 | name, 75 | image, 76 | }), 77 | }), 78 | updateAll: (org_slug: string, db_name: string) => 79 | this.fetch(`/v1/organizations/${org_slug}/databases/${db_name}/update`, { 80 | method: "POST", 81 | }), 82 | destroy: (org_slug: string, db_name: string) => 83 | this.fetch(`/v1/organizations/${org_slug}/databases/${db_name}`, { 84 | method: "DELETE", 85 | }), 86 | mintAuthToken: ( 87 | org_slug: string, 88 | db_name: string, 89 | expiration?: string, 90 | authorization?: "read-only" | "full-access", 91 | ) => { 92 | const params = new URLSearchParams(); 93 | if (expiration) { 94 | params.set("expiration", expiration); 95 | } 96 | if (authorization) { 97 | params.set("authorization", authorization); 98 | } 99 | return this.fetch( 100 | `/v1/organizations/${org_slug}/databases/${db_name}/auth/tokens?${params.toString()}`, 101 | { 102 | method: "POST", 103 | }, 104 | ); 105 | }, 106 | invalidateAllAuthTokens: (org_slug: string, db_name: string) => 107 | this.fetch( 108 | `/v1/organizations/${org_slug}/databases/${db_name}/auth/rotate`, 109 | { 110 | method: "POST", 111 | }, 112 | ), 113 | getCurrentMonthUsage: (org_slug: string, db_name: string) => 114 | this.fetch(`/v1/organizations/${org_slug}/databases/${db_name}/usage`), 115 | }; 116 | 117 | public databaseInstances: DatabaseInstanceAPI = { 118 | getAll: (org_slug: string, db_name: string) => 119 | this.fetch( 120 | `/v1/organizations/${org_slug}/databases/${db_name}/instances`, 121 | ), 122 | get: (org_slug: string, db_name: string, instance_name: string) => 123 | this.fetch( 124 | `/v1/organizations/${org_slug}/databases/${db_name}/instances/${instance_name}`, 125 | ), 126 | create: ( 127 | org_slug: string, 128 | db_name: string, 129 | location: string, 130 | image?: "latest" | "canary", 131 | ) => 132 | this.fetch( 133 | `/v1/organizations/${org_slug}/databases/${db_name}/instances`, 134 | { 135 | method: "POST", 136 | body: JSON.stringify({ 137 | location, 138 | image, 139 | }), 140 | }, 141 | ), 142 | destroy: (org_slug: string, db_name: string, instance_name: string) => 143 | this.fetch( 144 | `/v1/organizations/${org_slug}/databases/${db_name}/instances/${instance_name}`, 145 | { 146 | method: "DELETE", 147 | }, 148 | ), 149 | }; 150 | 151 | public groups: GroupAPI = { 152 | getAll: () => this.fetch("/v1/groups"), 153 | create: (name: string, location: string) => 154 | this.fetch("/v1/groups", { 155 | method: "POST", 156 | body: JSON.stringify({ 157 | name, 158 | location, 159 | }), 160 | }), 161 | get: (group: string) => this.fetch(`/v1/groups/${group}`), 162 | delete: (group: string) => 163 | this.fetch(`/v1/groups/${group}`, { 164 | method: "DELETE", 165 | }), 166 | addLocation: (group: string, location: string) => 167 | this.fetch(`/v1/groups/${group}/locations/${location}`, { 168 | method: "POST", 169 | }), 170 | removeLocation: (group: string, location: string) => 171 | this.fetch(`/v1/groups/${group}/locations/${location}`, { 172 | method: "DELETE", 173 | }), 174 | }; 175 | } 176 | -------------------------------------------------------------------------------- /packages/beth-stack/src/turso/test.ts: -------------------------------------------------------------------------------- 1 | import { TursoClient } from "."; 2 | 3 | const client = new TursoClient(process.env.TURSO_API_TOKEN!); 4 | 5 | const { locations } = await client.locations.list(); 6 | 7 | console.log(locations); 8 | -------------------------------------------------------------------------------- /packages/beth-stack/src/turso/types.ts: -------------------------------------------------------------------------------- 1 | export type DatabaseAPI = { 2 | create({ 3 | name, 4 | location, 5 | image, 6 | group, 7 | }: { 8 | name: string; 9 | location?: string; 10 | image?: "latest" | "canary"; 11 | group?: string; 12 | seed?: { 13 | type: "database"; 14 | name: string; 15 | }; 16 | }): Promise<{ 17 | database: LogicalDatabase; 18 | }>; 19 | }; 20 | 21 | export type GroupAPI = { 22 | getAll(): Promise<{ 23 | groups: Group[]; 24 | }>; 25 | create( 26 | name: string, 27 | location: string, 28 | ): Promise<{ 29 | group: Group; 30 | }>; 31 | get(group: string): Promise<{ 32 | group: Group; 33 | }>; 34 | delete(group: string): Promise<{ 35 | group: Group; 36 | }>; 37 | addLocation( 38 | group: string, 39 | location: string, 40 | ): Promise<{ 41 | group: Group; 42 | }>; 43 | removeLocation( 44 | group: string, 45 | location: string, 46 | ): Promise<{ 47 | group: Group; 48 | }>; 49 | }; 50 | 51 | type Group = { 52 | locations: string[]; 53 | name: string; 54 | primary: string; 55 | }; 56 | 57 | export type AuthenticationAPI = { 58 | listTokens(): Promise<{ 59 | tokens: PlatformApiToken[]; 60 | }>; 61 | mintToken(token_name: string): Promise; 62 | revokeToken(token_name: string): Promise<{ token: string }>; 63 | validateToken(): Promise<{ exp: number }>; 64 | }; 65 | 66 | export type OrganizationAPI = { 67 | list(): Promise<{ 68 | organizations: Organization[]; 69 | }>; 70 | listMembers(org_slug: string): Promise<{ 71 | members: OrganizationMember[]; 72 | }>; 73 | }; 74 | 75 | export type LocationAPI = { 76 | list(): Promise<{ 77 | locations: Record; 78 | }>; 79 | }; 80 | 81 | export type LogicalDatabaseAPI = { 82 | getAll(org_slug: string): Promise<{ 83 | databases: LogicalDatabase[]; 84 | }>; 85 | getByName( 86 | org_slug: string, 87 | db_name: string, 88 | ): Promise<{ 89 | database: LogicalDatabase; 90 | }>; 91 | create( 92 | org_slug: string, 93 | name: string, 94 | image: "latest" | "canary", 95 | ): Promise<{ 96 | database: LogicalDatabase; 97 | }>; 98 | updateAll(org_slug: string, db_name: string): Promise; 99 | destroy(org_slug: string, db_name: string): Promise<{ database: string }>; 100 | mintAuthToken( 101 | org_slug: string, 102 | db_name: string, 103 | expiration?: string, 104 | authorization?: "read-only" | "full-access", 105 | ): Promise<{ jwt: string }>; 106 | invalidateAllAuthTokens(org_slug: string, db_name: string): Promise; 107 | getCurrentMonthUsage( 108 | org_slug: string, 109 | db_name: string, 110 | ): Promise<{ 111 | database: LogicalDatabase; 112 | }>; 113 | }; 114 | 115 | export type DatabaseInstanceAPI = { 116 | getAll( 117 | org_slug: string, 118 | db_name: string, 119 | ): Promise<{ 120 | instances: DatabaseInstance[]; 121 | }>; 122 | get( 123 | org_slug: string, 124 | db_name: string, 125 | instance_name: string, 126 | ): Promise<{ 127 | instance: DatabaseInstance; 128 | }>; 129 | create( 130 | org_slug: string, 131 | db_name: string, 132 | location: string, 133 | image?: "latest" | "canary", 134 | ): Promise<{ 135 | instance: DatabaseInstance; 136 | }>; 137 | destroy( 138 | org_slug: string, 139 | db_name: string, 140 | instance_name: string, 141 | ): Promise<{ 142 | instance: string; 143 | }>; 144 | }; 145 | 146 | export type Organization = { 147 | name: string; 148 | slug: string; 149 | type: "personal" | "team"; 150 | }; 151 | 152 | export type OrganizationMember = { 153 | username: string; 154 | role: "owner" | "member"; 155 | }; 156 | 157 | export type PlatformApiToken = { 158 | id: string; 159 | name: string; 160 | }; 161 | 162 | export type LogicalDatabase = { 163 | Name: string; 164 | Hostname: string; 165 | IssuedCertLimit: number; 166 | IssuedCertCount: number; 167 | DbId: string; 168 | regions: string[]; 169 | primaryRegion: string; 170 | type: "logical"; 171 | }; 172 | 173 | export type LogicalDatabaseUsage = { 174 | uuid: string; 175 | instances: DatabaseInstanceUsage[]; 176 | }; 177 | 178 | export type DatabaseInstanceUsage = { 179 | uuid: string; 180 | usage: Usage; 181 | }; 182 | 183 | export type Usage = { 184 | rows_read: number; 185 | rows_written: number; 186 | storage_bytes: number; 187 | }; 188 | 189 | export type DatabaseInstance = { 190 | uuid: string; 191 | name: string; 192 | type: "primary" | "replica"; 193 | region: string; 194 | hostname: string; 195 | }; 196 | -------------------------------------------------------------------------------- /packages/beth-stack/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/beth-stack/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/create-beth-app/.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/create-beth-app/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !src/**/* 4 | !dist/**/* 5 | !package.json 6 | !LICENSE 7 | !README.md 8 | !tsconfig.json 9 | -------------------------------------------------------------------------------- /packages/create-beth-app/README.md: -------------------------------------------------------------------------------- 1 | this is a cli to generate a BETH stack app 2 | it is extremely basic rn and _hopefully_ will be iterated on 3 | 4 | Run with `bun run src/index.ts --project-name ` 5 | -------------------------------------------------------------------------------- /packages/create-beth-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-beth-app", 3 | "version": "0.0.9", 4 | "bin": "src/index.ts", 5 | "type": "module", 6 | "dependencies": { 7 | "chalk": "^5.3.0", 8 | "commander": "^11.1.0", 9 | "shelljs": "^0.8.5", 10 | "cli-spinners": "^2.9.2" 11 | }, 12 | "devDependencies": { 13 | "bun-types": "latest" 14 | }, 15 | "peerDependencies": { 16 | "typescript": "^5.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/create-beth-app/src/asciiArt.ts: -------------------------------------------------------------------------------- 1 | // asciiArt.ts 2 | 3 | export const displayCLIHeader = () => { 4 | console.log(` 5 | ██████╗ ███████╗████████╗██╗ ██╗ 6 | ██╔══██╗██╔════╝╚══██╔══╝██║ ██║ 7 | ██████╔╝█████╗ ██║ ███████║ 8 | ██╔══██╗██╔══╝ ██║ ██╔══██║ 9 | ██████╔╝███████╗ ██║ ██║ ██║ 10 | ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ 11 | `); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/create-beth-app/src/colors.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | export const colors = { 4 | error: chalk.hex("#eba0ac"), 5 | info: chalk.hex("#89b4fa"), 6 | warning: chalk.hex("#f9e2af"), 7 | success: chalk.hex("#a3eba0"), 8 | }; 9 | -------------------------------------------------------------------------------- /packages/create-beth-app/src/commander.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | 3 | 4 | export const program = new Command(); 5 | 6 | program 7 | .option("-h", "--help", "show how to use this tool") 8 | .option("-n", "--node", "use the node template") 9 | .option("-njs", "--nextjs", "use the nextjs template") 10 | .option("-rn", "--react-native", "use the react-native template") 11 | .argument("", "the name of the project") 12 | .option("-b", "--bun", "run with bun") 13 | .option("-y", "--yarn", "run with yarn") 14 | .option("-npm", "--npm", "run with npm"); -------------------------------------------------------------------------------- /packages/create-beth-app/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import sh from "shelljs"; 3 | import { displayCLIHeader } from "./asciiArt.js"; 4 | import { colors } from "./colors.js"; 5 | import { program } from "./commander.js"; 6 | import { logger } from "./utils/logger.js"; 7 | import { Spinner } from "./utils/spinner.js"; 8 | 9 | export const main = () => { 10 | displayCLIHeader(); 11 | const spinner = new Spinner(); 12 | try { 13 | console.log(colors.info("\nStarting...")); 14 | console.log(colors.info("Running Pre-checks...")); 15 | spinner.start("Initializing..."); 16 | // Read params from cli 17 | program.parse(process.argv); 18 | 19 | const options = program.opts(); 20 | const args = program.args; 21 | const projectName = args[0]; 22 | 23 | if (!projectName) { 24 | spinner.stop(); 25 | logger.error( 26 | 'Error: No Project Name specified. Please include "--project-name "', 27 | ); 28 | sh.exit(0); 29 | } 30 | 31 | spinner.stop(); 32 | logger.warning(`Creating new beth-stack site at ./${projectName}`); 33 | 34 | Bun.spawnSync( 35 | [ 36 | "git", 37 | "clone", 38 | "https://github.com/ethanniser/beth-big.git", 39 | projectName, 40 | ], 41 | { 42 | onExit(subprocess, exitCode, signalCode, error) { 43 | if (exitCode !== 0) { 44 | console.log(colors.error(error)); 45 | sh.exit(0); 46 | } 47 | }, 48 | }, 49 | ); 50 | 51 | sh.cd(projectName); 52 | 53 | // Replace all instances of template name with new project name 54 | sh.ls("package.json").forEach((file: string) => { 55 | sh.sed("-i", '"test"', `"${projectName}"`, file); 56 | }); 57 | 58 | // Remove the .git folder 59 | sh.exec(`rm -rf .git`); 60 | 61 | spinner.start("Installing dependencies..."); 62 | 63 | Bun.spawnSync(["bun", "install"], { 64 | onExit(subprocess, exitCode, signalCode, error) { 65 | if (exitCode !== 0) { 66 | console.log(colors.error(error)); 67 | sh.exit(0); 68 | } 69 | }, 70 | }); 71 | 72 | // Print our done message 73 | spinner.stop(); 74 | console.log(colors.success.bold("Complete! 🎉")); 75 | } catch (error) { 76 | spinner.stop(); 77 | logger.error( 78 | `Uh oh - Something happened, please create an issue here: \n\nhttps://github.com/lundjrl/repo-cli`, 79 | ); 80 | } finally { 81 | logger.info( 82 | "This CLI is extremely new and barebones, contributions are welcome.", 83 | ); 84 | logger.info("https://github.com/ethanniser/the-beth-stack"); 85 | logger.info("Looking for help? Open an issue or ask in the discord."); 86 | logger.info("Ethan's Discord: https://discord.gg/Z3yUtMfkwa"); 87 | } 88 | }; 89 | 90 | main(); 91 | -------------------------------------------------------------------------------- /packages/create-beth-app/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { colors } from "../colors.js"; 2 | 3 | export const logger = { 4 | info: (message: string) => console.log(colors.info(message)), 5 | error: (message: string) => console.error(colors.error(message)), 6 | warning: (message: string) => console.warn(colors.warning(message)), 7 | success: (message: string) => console.log(colors.success(message)) 8 | }; 9 | -------------------------------------------------------------------------------- /packages/create-beth-app/src/utils/spinner.ts: -------------------------------------------------------------------------------- 1 | import cliSpinners from 'cli-spinners'; 2 | import readline from 'readline'; 3 | 4 | export class Spinner { 5 | private spinner = cliSpinners.dots; 6 | private idx = 0; 7 | private timer: NodeJS.Timeout | null = null; 8 | 9 | start(msg: string = '') { 10 | process.stdout.write(msg); 11 | this.timer = setInterval(() => { 12 | readline.cursorTo(process.stdout, 0); 13 | process.stdout.write(`${msg} ${this.spinner.frames[this.idx]}`); 14 | this.idx = (this.idx + 1) % this.spinner.frames.length; 15 | }, this.spinner.interval); 16 | } 17 | 18 | stop() { 19 | if (this.timer) { 20 | clearInterval(this.timer); 21 | this.timer = null; 22 | readline.clearLine(process.stdout, 0); 23 | readline.cursorTo(process.stdout, 0); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/create-beth-app/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/create-beth-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Config & import("@ianvs/prettier-plugin-sort-imports").PluginConfig} 3 | */ 4 | const config = { 5 | arrowParens: "always", 6 | printWidth: 80, 7 | singleQuote: false, 8 | semi: true, 9 | trailingComma: "all", 10 | tabWidth: 2, 11 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /reset.d.ts: -------------------------------------------------------------------------------- 1 | import "@total-typescript/ts-reset"; 2 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | set up ci: 2 | changsets + github actions 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [".eslintrc.cjs", "prettier.config.mjs"], 3 | "compilerOptions": { 4 | "lib": ["ESNext"], 5 | "module": "esnext", 6 | "target": "esnext", 7 | "moduleResolution": "bundler", 8 | "moduleDetection": "force", 9 | "allowImportingTsExtensions": true, 10 | "composite": true, 11 | "downlevelIteration": true, 12 | "jsx": "react", 13 | "jsxFactory": "Html.createElement", 14 | "jsxFragmentFactory": "Html.Fragment", 15 | "allowJs": true, 16 | "checkJs": true, 17 | "types": [ 18 | "bun-types" // add Bun global 19 | ], 20 | 21 | /* EMIT RULES */ 22 | "outDir": "./dist", 23 | "noEmit": true, // TSUP takes care of emitting js for us, in a MUCH faster way 24 | "declaration": true, 25 | "declarationMap": true, 26 | "sourceMap": true, 27 | "removeComments": true, 28 | 29 | /* TYPE CHECKING RULES */ 30 | "strict": true, 31 | // "noImplicitAny": true, // Included in "Strict" 32 | // "noImplicitThis": true, // Included in "Strict" 33 | // "strictBindCallApply": true, // Included in "Strict" 34 | // "strictFunctionTypes": true, // Included in "Strict" 35 | // "strictNullChecks": true, // Included in "Strict" 36 | // "strictPropertyInitialization": true, // Included in "Strict" 37 | "noFallthroughCasesInSwitch": true, 38 | "noImplicitOverride": true, 39 | "noImplicitReturns": true, 40 | // "noUnusedLocals": true, 41 | // "noUnusedParameters": true, 42 | "useUnknownInCatchVariables": true, 43 | "noUncheckedIndexedAccess": true, // TLDR - Checking an indexed value (array[0]) now forces type as there is no confirmation that index exists 44 | // THE BELOW ARE EXTRA STRICT OPTIONS THAT SHOULD ONLY BY CONSIDERED IN VERY SAFE PROJECTS 45 | "exactOptionalPropertyTypes": true, // TLDR - Setting to undefined is not the same as a property not being defined at all 46 | // "noPropertyAccessFromIndexSignature": true, // TLDR - Use dot notation for objects if youre sure it exists, use ['index'] notaion if unsure 47 | 48 | /* OTHER OPTIONS */ 49 | "allowSyntheticDefaultImports": true, 50 | "esModuleInterop": true, 51 | // "emitDecoratorMetadata": true, 52 | // "experimentalDecorators": true, 53 | "forceConsistentCasingInFileNames": true, 54 | "skipLibCheck": true, 55 | "useDefineForClassFields": true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "lint": { 9 | "dependsOn": ["^build"], 10 | "outputs": [] 11 | }, 12 | "lint:fix": { 13 | "cache": false 14 | }, 15 | "clean": { 16 | "cache": false 17 | }, 18 | "typecheck": { 19 | "dependsOn": ["^build"] 20 | }, 21 | "test:watch": { 22 | "cache": false 23 | }, 24 | "test": { 25 | "dependsOn": ["build"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beth-stack/www", 3 | "version": "0.0.1", 4 | "private": true 5 | } 6 | --------------------------------------------------------------------------------