├── .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(``);
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 | "" +
116 | name +
117 | ">"
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 | * {''}
49 | * ''
50 | *
51 | * {''}
52 | * '<script />
'
53 | *
54 | *
55 | * ''
56 | *
57 | * // Escapes even inner jsx tags
58 | *
59 | * '<div><script /></div>
'
60 | * ```
61 | *
62 | * @default false
63 | *
64 | * @see https://github.com/kitajs/html#sanitization
65 | */
66 | safe?: undefined | boolean;
67 |
68 | /**
69 | * Included here to work as a react drop-in replacement
70 | *
71 | * @deprecated please use `class`.
72 | */
73 | className?: undefined | string;
74 | }
75 |
76 | interface HtmlAnchorTag extends HtmlTag {
77 | href?: undefined | string;
78 | hreflang?: undefined | string;
79 | target?: undefined | string;
80 | download?: undefined | string;
81 | referrerpolicy?: undefined | string;
82 | ping?: undefined | string;
83 | rel?: undefined | string;
84 | media?: undefined | string;
85 | type?: undefined | string;
86 | }
87 |
88 | interface HtmlAreaTag extends HtmlTag {
89 | alt?: undefined | string;
90 | coords?: undefined | string;
91 | shape?: undefined | string;
92 | href?: undefined | string;
93 | target?: undefined | string;
94 | ping?: undefined | string;
95 | rel?: undefined | string;
96 | media?: undefined | string;
97 | hreflang?: undefined | string;
98 | type?: undefined | string;
99 | }
100 |
101 | interface HtmlAudioTag extends HtmlTag {
102 | src?: undefined | string;
103 | autobuffer?: undefined | string;
104 | autoplay?: undefined | string | boolean;
105 | preload?: undefined | string;
106 | muted?: undefined | string | boolean;
107 | loop?: undefined | string | boolean;
108 | controls?: undefined | string;
109 | }
110 |
111 | interface BaseTag extends HtmlTag {
112 | href?: undefined | string;
113 | target?: undefined | string;
114 | }
115 |
116 | interface HtmlQuoteTag extends HtmlTag {
117 | cite?: undefined | string;
118 | }
119 |
120 | interface HtmlBodyTag extends HtmlTag {}
121 |
122 | interface HtmlButtonTag extends HtmlTag {
123 | action?: undefined | string;
124 | autofocus?: undefined | string;
125 | disabled?: undefined | boolean;
126 | enctype?: undefined | string;
127 | form?: undefined | string;
128 | method?: undefined | string;
129 | name?: undefined | string;
130 | novalidate?: undefined | string | boolean;
131 | target?: undefined | string;
132 | type?: undefined | string;
133 | value?: undefined | string;
134 | }
135 |
136 | interface HtmlDataListTag extends HtmlTag {}
137 |
138 | interface HtmlCanvasTag extends HtmlTag {
139 | width?: undefined | string;
140 | height?: undefined | string;
141 | }
142 |
143 | interface HtmlTableColTag extends HtmlTag {
144 | span?: undefined | string;
145 | }
146 |
147 | interface HtmlTableSectionTag extends HtmlTag {}
148 |
149 | interface HtmlTableRowTag extends HtmlTag {}
150 |
151 | interface DataTag extends HtmlTag {
152 | value?: undefined | string;
153 | }
154 |
155 | interface HtmlEmbedTag extends HtmlTag, Record {
156 | src?: undefined | string;
157 | type?: undefined | string;
158 | width?: undefined | string;
159 | height?: undefined | string;
160 | }
161 |
162 | interface HtmlFieldSetTag extends HtmlTag {
163 | disabled?: undefined | boolean;
164 | form?: undefined | string;
165 | name?: undefined | string;
166 | }
167 |
168 | interface HtmlFormTag extends HtmlTag {
169 | ["accept-charset"]?: undefined | string;
170 | action?: undefined | string;
171 | autocomplete?: undefined | string;
172 | enctype?: undefined | string;
173 | method?: undefined | string;
174 | name?: undefined | string;
175 | novalidate?: undefined | string | boolean;
176 | target?: undefined | string;
177 | }
178 |
179 | interface HtmlHtmlTag extends HtmlTag {
180 | manifest?: undefined | string;
181 | }
182 |
183 | interface HtmlIFrameTag extends HtmlTag {
184 | src?: undefined | string;
185 | srcdoc?: undefined | string;
186 | name?: undefined | string;
187 | sandbox?: undefined | string;
188 | seamless?: undefined | string;
189 | width?: undefined | string;
190 | height?: undefined | string;
191 | }
192 |
193 | interface HtmlImageTag extends HtmlTag {
194 | alt?: undefined | string;
195 | src?: undefined | string;
196 | crossorigin?: undefined | string;
197 | usemap?: undefined | string;
198 | ismap?: undefined | string;
199 | width?: undefined | number | string;
200 | height?: undefined | number | string;
201 | }
202 |
203 | interface HtmlInputTag extends HtmlTag {
204 | accept?: undefined | string;
205 | action?: undefined | string;
206 | alt?: undefined | string;
207 | autocomplete?: undefined | string;
208 | autofocus?: undefined | string;
209 | checked?: undefined | string | boolean;
210 | disabled?: undefined | boolean;
211 | enctype?: undefined | string;
212 | form?: undefined | string;
213 | height?: undefined | string;
214 | list?: undefined | string;
215 | max?: undefined | string;
216 | minlength?: undefined | string;
217 | maxlength?: undefined | string;
218 | method?: undefined | string;
219 | min?: undefined | string;
220 | multiple?: undefined | string;
221 | name?: undefined | string;
222 | novalidate?: undefined | string | boolean;
223 | pattern?: undefined | string;
224 | placeholder?: undefined | string;
225 | readonly?: undefined | string;
226 | required?: undefined | string;
227 | size?: undefined | string;
228 | src?: undefined | string;
229 | step?: undefined | string;
230 | target?: undefined | string;
231 | type?: undefined | string;
232 | value?: undefined | string;
233 | width?: undefined | string;
234 | }
235 |
236 | interface HtmlModTag extends HtmlTag {
237 | cite?: undefined | string;
238 | datetime?: undefined | string | Date;
239 | }
240 |
241 | interface KeygenTag extends HtmlTag {
242 | autofocus?: undefined | string;
243 | challenge?: undefined | string;
244 | disabled?: undefined | boolean;
245 | form?: undefined | string;
246 | keytype?: undefined | string;
247 | name?: undefined | string;
248 | }
249 |
250 | interface HtmlLabelTag extends HtmlTag {
251 | form?: undefined | string;
252 | for?: undefined | string;
253 | }
254 |
255 | interface HtmlLITag extends HtmlTag {
256 | value?: undefined | string | number;
257 | }
258 |
259 | interface HtmlLinkTag extends HtmlTag {
260 | href?: undefined | string;
261 | crossorigin?: undefined | string;
262 | rel?: undefined | string;
263 | media?: undefined | string;
264 | hreflang?: undefined | string;
265 | type?: undefined | string;
266 | sizes?: undefined | string;
267 | integrity?: undefined | string;
268 | }
269 |
270 | interface HtmlMapTag extends HtmlTag {
271 | name?: undefined | string;
272 | }
273 |
274 | interface HtmlMetaTag extends HtmlTag {
275 | name?: undefined | string;
276 | ["http-equiv"]?: undefined | string;
277 | content?: undefined | string;
278 | charset?: undefined | string;
279 | }
280 |
281 | interface HtmlMeterTag extends HtmlTag {
282 | value?: undefined | string | number;
283 | min?: undefined | string | number;
284 | max?: undefined | string | number;
285 | low?: undefined | string | number;
286 | high?: undefined | string | number;
287 | optimum?: undefined | string | number;
288 | }
289 |
290 | interface HtmlObjectTag extends HtmlTag {
291 | data?: undefined | string;
292 | type?: undefined | string;
293 | name?: undefined | string;
294 | usemap?: undefined | string;
295 | form?: undefined | string;
296 | width?: undefined | string;
297 | height?: undefined | string;
298 | }
299 |
300 | interface HtmlOListTag extends HtmlTag {
301 | reversed?: undefined | string;
302 | start?: undefined | string | number;
303 | }
304 |
305 | interface HtmlOptgroupTag extends HtmlTag {
306 | disabled?: undefined | boolean;
307 | label?: undefined | string;
308 | }
309 |
310 | interface HtmlOptionTag extends HtmlTag {
311 | disabled?: undefined | boolean;
312 | label?: undefined | string;
313 | selected?: undefined | string;
314 | value?: undefined | string;
315 | }
316 |
317 | interface HtmlOutputTag extends HtmlTag {
318 | for?: undefined | string;
319 | form?: undefined | string;
320 | name?: undefined | string;
321 | }
322 |
323 | interface HtmlParamTag extends HtmlTag {
324 | name?: undefined | string;
325 | value?: undefined | string;
326 | }
327 |
328 | interface HtmlProgressTag extends HtmlTag {
329 | value?: undefined | string | number;
330 | max?: undefined | string | number;
331 | }
332 |
333 | interface HtmlCommandTag extends HtmlTag {
334 | type?: undefined | string;
335 | label?: undefined | string;
336 | icon?: undefined | string;
337 | disabled?: undefined | boolean;
338 | checked?: undefined | string;
339 | radiogroup?: undefined | string;
340 | default?: undefined | string;
341 | }
342 |
343 | interface HtmlLegendTag extends HtmlTag {}
344 |
345 | interface HtmlBrowserButtonTag extends HtmlTag {
346 | type?: undefined | string;
347 | }
348 |
349 | interface HtmlMenuTag extends HtmlTag {
350 | type?: undefined | string;
351 | label?: undefined | string;
352 | }
353 |
354 | interface HtmlScriptTag extends HtmlTag {
355 | src?: undefined | string;
356 | type?: undefined | string;
357 | charset?: undefined | string;
358 | async?: undefined | string | boolean;
359 | defer?: undefined | string | boolean;
360 | crossorigin?: undefined | string;
361 | integrity?: undefined | string;
362 | text?: undefined | string;
363 | }
364 |
365 | interface HtmlDetailsTag extends HtmlTag {
366 | open?: undefined | string;
367 | }
368 |
369 | interface HtmlSelectTag extends HtmlTag {
370 | autofocus?: undefined | string;
371 | disabled?: undefined | boolean;
372 | form?: undefined | string;
373 | multiple?: undefined | string;
374 | name?: undefined | string;
375 | required?: undefined | string;
376 | size?: undefined | string;
377 | }
378 |
379 | interface HtmlSourceTag extends HtmlTag {
380 | src?: undefined | string;
381 | type?: undefined | string;
382 | media?: undefined | string;
383 | }
384 |
385 | interface HtmlStyleTag extends HtmlTag {
386 | media?: undefined | string;
387 | type?: undefined | string;
388 | disabled?: undefined | boolean;
389 | scoped?: undefined | string;
390 | }
391 |
392 | interface HtmlTableTag extends HtmlTag {}
393 |
394 | interface HtmlTableDataCellTag extends HtmlTag {
395 | colspan?: undefined | string | number;
396 | rowspan?: undefined | string | number;
397 | headers?: undefined | string;
398 | }
399 |
400 | interface HtmlTextAreaTag extends HtmlTag {
401 | autofocus?: undefined | string;
402 | cols?: undefined | string;
403 | dirname?: undefined | string;
404 | disabled?: undefined | boolean;
405 | form?: undefined | string;
406 | maxlength?: undefined | string;
407 | minlength?: undefined | string;
408 | name?: undefined | string;
409 | placeholder?: undefined | string;
410 | readonly?: undefined | string;
411 | required?: undefined | string;
412 | rows?: undefined | string;
413 | wrap?: undefined | string;
414 | }
415 |
416 | interface HtmlTableHeaderCellTag extends HtmlTag {
417 | colspan?: undefined | string | number;
418 | rowspan?: undefined | string | number;
419 | headers?: undefined | string;
420 | scope?: undefined | string;
421 | }
422 |
423 | interface HtmlTimeTag extends HtmlTag {
424 | datetime?: undefined | string | Date;
425 | }
426 |
427 | interface HtmlTrackTag extends HtmlTag {
428 | default?: undefined | string;
429 | kind?: undefined | string;
430 | label?: undefined | string;
431 | src?: undefined | string;
432 | srclang?: undefined | string;
433 | }
434 |
435 | interface HtmlVideoTag extends HtmlTag {
436 | src?: undefined | string;
437 | poster?: undefined | string;
438 | autobuffer?: undefined | string;
439 | autoplay?: undefined | string;
440 | loop?: undefined | string;
441 | controls?: undefined | string;
442 | width?: undefined | string;
443 | height?: undefined | string;
444 | }
445 |
446 | // We allow any attributes on svg because its hard to keep track of them all.
447 | interface HtmlSvgTag extends HtmlTag, Record {}
448 |
449 | interface HtmlUnspecifiedTag extends HtmlTag, Record {
450 | of: string;
451 | }
452 |
453 | interface HtmlBodyTag {
454 | onafterprint?: undefined | string;
455 | onbeforeprint?: undefined | string;
456 | onbeforeonload?: undefined | string;
457 | onblur?: undefined | string;
458 | onerror?: undefined | string;
459 | onfocus?: undefined | string;
460 | onhaschange?: undefined | string;
461 | onload?: undefined | string;
462 | onmessage?: undefined | string;
463 | onoffline?: undefined | string;
464 | ononline?: undefined | string;
465 | onpagehide?: undefined | string;
466 | onpageshow?: undefined | string;
467 | onpopstate?: undefined | string;
468 | onredo?: undefined | string;
469 | onresize?: undefined | string;
470 | onstorage?: undefined | string;
471 | onundo?: undefined | string;
472 | onunload?: undefined | string;
473 | }
474 |
475 | interface HtmlTag {
476 | oncontextmenu?: undefined | string;
477 | onkeydown?: undefined | string;
478 | onkeypress?: undefined | string;
479 | onkeyup?: undefined | string;
480 | onclick?: undefined | string;
481 | ondblclick?: undefined | string;
482 | ondrag?: undefined | string;
483 | ondragend?: undefined | string;
484 | ondragenter?: undefined | string;
485 | ondragleave?: undefined | string;
486 | ondragover?: undefined | string;
487 | ondragstart?: undefined | string;
488 | ondrop?: undefined | string;
489 | onmousedown?: undefined | string;
490 | onmousemove?: undefined | string;
491 | onmouseout?: undefined | string;
492 | onmouseover?: undefined | string;
493 | onmouseup?: undefined | string;
494 | onmousewheel?: undefined | string;
495 | onscroll?: undefined | string;
496 | }
497 |
498 | interface FormEvents {
499 | onblur?: undefined | string;
500 | onchange?: undefined | string;
501 | onfocus?: undefined | string;
502 | onformchange?: undefined | string;
503 | onforminput?: undefined | string;
504 | oninput?: undefined | string;
505 | oninvalid?: undefined | string;
506 | onselect?: undefined | string;
507 | onsubmit?: undefined | string;
508 | }
509 |
510 | interface HtmlInputTag extends FormEvents {}
511 |
512 | interface HtmlFieldSetTag extends FormEvents {}
513 |
514 | interface HtmlFormTag extends FormEvents {}
515 |
516 | interface MediaEvents {
517 | onabort?: undefined | string;
518 | oncanplay?: undefined | string;
519 | oncanplaythrough?: undefined | string;
520 | ondurationchange?: undefined | string;
521 | onemptied?: undefined | string;
522 | onended?: undefined | string;
523 | onerror?: undefined | string;
524 | onloadeddata?: undefined | string;
525 | onloadedmetadata?: undefined | string;
526 | onloadstart?: undefined | string;
527 | onpause?: undefined | string;
528 | onplay?: undefined | string;
529 | onplaying?: undefined | string;
530 | onprogress?: undefined | string;
531 | onratechange?: undefined | string;
532 | onreadystatechange?: undefined | string;
533 | onseeked?: undefined | string;
534 | onseeking?: undefined | string;
535 | onstalled?: undefined | string;
536 | onsuspend?: undefined | string;
537 | ontimeupdate?: undefined | string;
538 | onvolumechange?: undefined | string;
539 | onwaiting?: undefined | string;
540 | }
541 |
542 | interface HtmlAudioTag extends MediaEvents {}
543 |
544 | interface HtmlEmbedTag extends MediaEvents {}
545 |
546 | interface HtmlImageTag extends MediaEvents {}
547 |
548 | interface HtmlObjectTag extends MediaEvents {}
549 |
550 | interface HtmlVideoTag extends MediaEvents {}
551 |
552 | interface IntrinsicAttributes {}
553 |
554 | interface ElementChildrenAttribute {
555 | children?: undefined | any;
556 | }
557 |
558 | interface IntrinsicElements {
559 | a: HtmlAnchorTag;
560 | abbr: HtmlTag;
561 | address: HtmlTag;
562 | animate: HtmlSvgTag;
563 | animateMotion: HtmlSvgTag;
564 | animateTransform: HtmlSvgTag;
565 | area: HtmlAreaTag;
566 | article: HtmlTag;
567 | aside: HtmlTag;
568 | audio: HtmlAudioTag;
569 | b: HtmlTag;
570 | base: BaseTag;
571 | bb: HtmlBrowserButtonTag;
572 | bdi: HtmlTag;
573 | bdo: HtmlTag;
574 | blockquote: HtmlQuoteTag;
575 | body: HtmlBodyTag;
576 | br: HtmlTag;
577 | button: HtmlButtonTag;
578 | canvas: HtmlCanvasTag;
579 | caption: HtmlTag;
580 | circle: HtmlSvgTag;
581 | cite: HtmlTag;
582 | clipPath: HtmlSvgTag;
583 | code: HtmlTag;
584 | col: HtmlTableColTag;
585 | colgroup: HtmlTableColTag;
586 | commands: HtmlCommandTag;
587 | data: DataTag;
588 | datalist: HtmlDataListTag;
589 | dd: HtmlTag;
590 | defs: HtmlSvgTag;
591 | del: HtmlModTag;
592 | desc: HtmlSvgTag;
593 | details: HtmlDetailsTag;
594 | dfn: HtmlTag;
595 | div: HtmlTag;
596 | dl: HtmlTag;
597 | dt: HtmlTag;
598 | ellipse: HtmlSvgTag;
599 | em: HtmlTag;
600 | embed: HtmlEmbedTag;
601 | feBlend: HtmlSvgTag;
602 | feColorMatrix: HtmlSvgTag;
603 | feComponentTransfer: HtmlSvgTag;
604 | feComposite: HtmlSvgTag;
605 | feConvolveMatrix: HtmlSvgTag;
606 | feDiffuseLighting: HtmlSvgTag;
607 | feDisplacementMap: HtmlSvgTag;
608 | feDistantLight: HtmlSvgTag;
609 | feDropShadow: HtmlSvgTag;
610 | feFlood: HtmlSvgTag;
611 | feFuncA: HtmlSvgTag;
612 | feFuncB: HtmlSvgTag;
613 | feFuncG: HtmlSvgTag;
614 | feFuncR: HtmlSvgTag;
615 | feGaussianBlur: HtmlSvgTag;
616 | feImage: HtmlSvgTag;
617 | feMerge: HtmlSvgTag;
618 | feMergeNode: HtmlSvgTag;
619 | feMorphology: HtmlSvgTag;
620 | feOffset: HtmlSvgTag;
621 | fePointLight: HtmlSvgTag;
622 | feSpecularLighting: HtmlSvgTag;
623 | feSpotLight: HtmlSvgTag;
624 | feTile: HtmlSvgTag;
625 | feTurbulence: HtmlSvgTag;
626 | fieldset: HtmlFieldSetTag;
627 | figcaption: HtmlTag;
628 | figure: HtmlTag;
629 | filter: HtmlSvgTag;
630 | footer: HtmlTag;
631 | foreignObject: HtmlSvgTag;
632 | form: HtmlFormTag;
633 | g: HtmlSvgTag;
634 | h1: HtmlTag;
635 | h2: HtmlTag;
636 | h3: HtmlTag;
637 | h4: HtmlTag;
638 | h5: HtmlTag;
639 | h6: HtmlTag;
640 | head: HtmlTag;
641 | header: HtmlTag;
642 | hgroup: HtmlTag;
643 | hr: HtmlTag;
644 | html: HtmlHtmlTag;
645 | i: HtmlTag;
646 | iframe: HtmlIFrameTag;
647 | image: HtmlSvgTag;
648 | img: HtmlImageTag;
649 | input: HtmlInputTag;
650 | ins: HtmlModTag;
651 | kbd: HtmlTag;
652 | keygen: KeygenTag;
653 | label: HtmlLabelTag;
654 | legend: HtmlLegendTag;
655 | li: HtmlLITag;
656 | line: HtmlSvgTag;
657 | linearGradient: HtmlSvgTag;
658 | link: HtmlLinkTag;
659 | main: HtmlTag;
660 | map: HtmlMapTag;
661 | mark: HtmlTag;
662 | marker: HtmlSvgTag;
663 | mask: HtmlSvgTag;
664 | menu: HtmlMenuTag;
665 | meta: HtmlMetaTag;
666 | metadata: HtmlSvgTag;
667 | meter: HtmlMeterTag;
668 | mpath: HtmlSvgTag;
669 | nav: HtmlTag;
670 | noscript: HtmlTag;
671 | object: HtmlObjectTag;
672 | ol: HtmlOListTag;
673 | optgroup: HtmlOptgroupTag;
674 | option: HtmlOptionTag;
675 | output: HtmlOutputTag;
676 | p: HtmlTag;
677 | param: HtmlParamTag;
678 | path: HtmlSvgTag;
679 | pattern: HtmlSvgTag;
680 | polygon: HtmlSvgTag;
681 | polyline: HtmlSvgTag;
682 | pre: HtmlTag;
683 | progress: HtmlProgressTag;
684 | q: HtmlQuoteTag;
685 | radialGradient: HtmlSvgTag;
686 | rb: HtmlTag;
687 | rect: HtmlSvgTag;
688 | rp: HtmlTag;
689 | rt: HtmlTag;
690 | rtc: HtmlTag;
691 | ruby: HtmlTag;
692 | s: HtmlTag;
693 | samp: HtmlTag;
694 | script: HtmlScriptTag;
695 | section: HtmlTag;
696 | select: HtmlSelectTag;
697 | set: HtmlSvgTag;
698 | small: HtmlTag;
699 | source: HtmlSourceTag;
700 | span: HtmlTag;
701 | stop: HtmlSvgTag;
702 | strong: HtmlTag;
703 | style: HtmlStyleTag;
704 | sub: HtmlTag;
705 | summary: HtmlTag;
706 | sup: HtmlTag;
707 | svg: HtmlSvgTag;
708 | switch: HtmlSvgTag;
709 | symbol: HtmlSvgTag;
710 | table: HtmlTableTag;
711 | tag: HtmlUnspecifiedTag;
712 | tbody: HtmlTag;
713 | td: HtmlTableDataCellTag;
714 | template: HtmlTag;
715 | text: HtmlSvgTag;
716 | textarea: HtmlTextAreaTag;
717 | textPath: HtmlSvgTag;
718 | tfoot: HtmlTableSectionTag;
719 | th: HtmlTableHeaderCellTag;
720 | thead: HtmlTableSectionTag;
721 | time: HtmlTimeTag;
722 | title: HtmlTag;
723 | tr: HtmlTableRowTag;
724 | track: HtmlTrackTag;
725 | tspan: HtmlSvgTag;
726 | u: HtmlTag;
727 | ul: HtmlTag;
728 | use: HtmlSvgTag;
729 | var: HtmlTag;
730 | video: HtmlVideoTag;
731 | view: HtmlSvgTag;
732 | wbr: HtmlTag;
733 | }
734 | }
735 |
--------------------------------------------------------------------------------
/packages/beth-stack/src/jsx/register.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import idkHtml from ".";
4 |
5 | globalThis.Html = idkHtml;
6 |
7 | declare global {
8 | /**
9 | * The html factory namespace.
10 | */
11 | var Html: typeof idkHtml;
12 | }
13 |
--------------------------------------------------------------------------------
/packages/beth-stack/src/jsx/render.ts:
--------------------------------------------------------------------------------
1 | import { BETH_GLOBAL_RENDER_CACHE } from "../shared/global";
2 |
3 | export function renderToString JSX.Element>(
4 | lazyHtml: T,
5 | ): JSX.Element {
6 | BETH_GLOBAL_RENDER_CACHE.reset();
7 | const resultPromise = lazyHtml();
8 | return resultPromise;
9 | }
10 |
11 | export async function renderToStringResponse JSX.Element>(
12 | lazyHtml: T,
13 | ): Promise {
14 | const result = await renderToString(lazyHtml);
15 | return new Response(result, {
16 | headers: {
17 | "Content-Type": "text/html; charset=utf-8",
18 | },
19 | });
20 | }
21 |
22 | export function renderToStreamResponse JSX.Element>(
23 | lazyHtml: T,
24 | ): Response {
25 | const stream = renderToStream(lazyHtml);
26 | return new Response(stream, {
27 | headers: {
28 | "Content-Type": "text/html; charset=utf-8",
29 | },
30 | });
31 | }
32 |
33 | export function renderToStream JSX.Element>(
34 | lazyHtml: T,
35 | ): ReadableStream {
36 | BETH_GLOBAL_RENDER_CACHE.reset();
37 | const stream = new ReadableStream({
38 | start(c) {
39 | BETH_GLOBAL_RENDER_CACHE.streamController = c;
40 | lazyHtml()
41 | .then((data) => {
42 | BETH_GLOBAL_RENDER_CACHE.streamController?.enqueue(data);
43 | BETH_GLOBAL_RENDER_CACHE.checkIfEndAndClose();
44 | })
45 | .catch((error) => {
46 | console.error("Error in promise:", error);
47 | // Handle error appropriately
48 | BETH_GLOBAL_RENDER_CACHE.streamController?.error(error);
49 | BETH_GLOBAL_RENDER_CACHE.closeNow();
50 | });
51 | },
52 | });
53 |
54 | return stream;
55 | }
56 |
--------------------------------------------------------------------------------
/packages/beth-stack/src/jsx/suspense.tsx:
--------------------------------------------------------------------------------
1 | import { BETH_GLOBAL_RENDER_CACHE } from "../shared/global";
2 |
3 | export const swapScript = `
4 |
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 |
59 | ${content}
60 |
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 |
207 | `,
208 | swapScript +
209 | `
210 |
211 | loaded in: 100ms
212 |
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 |
--------------------------------------------------------------------------------