├── .example.env ├── .prettierignore ├── svelte-app ├── index.ts ├── src │ ├── routes │ │ ├── +layout.ts │ │ ├── Galery.svelte │ │ └── +page.svelte │ ├── functions.ts │ └── app.html ├── vite.config.ts ├── README.md ├── svelte.config.js ├── package.json ├── .gitignore ├── tsconfig.json ├── static │ └── favicon.svg └── bun.lock ├── assets ├── otag_hand.gif ├── petpet_hand.gif ├── petpet_template.gif ├── petpet_hand_modified.gif └── petpet_template_modified.gif ├── .prettierrc ├── .gitignore ├── src ├── wss.ts ├── avatars.ts ├── explain.ts ├── parseConfigFile.ts ├── index.ts ├── genConfig.ts ├── petpet.ts ├── help.ts ├── router.ts ├── config.ts ├── flags.ts ├── db.ts ├── types.ts └── functions.ts ├── LICENSE ├── tsconfig.json ├── package.json ├── README.md ├── routes.md ├── configuration.md └── cache.md /.example.env: -------------------------------------------------------------------------------- 1 | API_PROVIDER="localhost" 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /svelte-app/index.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello via Bun!"); -------------------------------------------------------------------------------- /svelte-app/src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /assets/otag_hand.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaksymIgnatiev/petpet_server/HEAD/assets/otag_hand.gif -------------------------------------------------------------------------------- /assets/petpet_hand.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaksymIgnatiev/petpet_server/HEAD/assets/petpet_hand.gif -------------------------------------------------------------------------------- /assets/petpet_template.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaksymIgnatiev/petpet_server/HEAD/assets/petpet_template.gif -------------------------------------------------------------------------------- /assets/petpet_hand_modified.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaksymIgnatiev/petpet_server/HEAD/assets/petpet_hand_modified.gif -------------------------------------------------------------------------------- /assets/petpet_template_modified.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaksymIgnatiev/petpet_server/HEAD/assets/petpet_template_modified.gif -------------------------------------------------------------------------------- /svelte-app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import {sveltekit} from "@sveltejs/kit/vite"; 2 | 3 | export default { 4 | plugins: [sveltekit()] 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": false, 4 | "trailingComma": "all", 5 | "useTabs": true, 6 | "tabWidth": 4, 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test.* 3 | *.test.* 4 | config.toml 5 | .cache/ 6 | svelte-app/node_modules/ 7 | svelte-app/build/ 8 | svelte-app/.svelte-kit/ 9 | .env 10 | -------------------------------------------------------------------------------- /svelte-app/src/functions.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage } from "../../src/types" 2 | 3 | export function validateWSMessage(data: unknown): data is WSMessage { 4 | return typeof data === "object" && data !== null && "type" in data 5 | } 6 | -------------------------------------------------------------------------------- /svelte-app/README.md: -------------------------------------------------------------------------------- 1 | # Svelte frontend for PetPet server 2 | 3 | 1. Install dependencies: 4 | ```sh 5 | bun i 6 | ``` 7 | 2. Run in dev mode (not all issues might be fixed) 8 | ```sh 9 | bun run dev 10 | ``` 11 | 2. Run `build` script to generate production code 12 | ```sh 13 | bun run build 14 | ``` 15 | -------------------------------------------------------------------------------- /svelte-app/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-static" 2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte" 3 | 4 | export default { 5 | kit: { 6 | adapter: adapter({ 7 | pages: "build", 8 | assets: "build", 9 | fallback: undefined, 10 | precompress: false, 11 | strict: true, 12 | }), 13 | }, 14 | preprocess: vitePreprocess(), 15 | } 16 | -------------------------------------------------------------------------------- /svelte-app/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Svelte App 7 | 8 | %sveltekit.head% 9 | 10 | 11 | 12 |
%sveltekit.body%
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/wss.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketServer, WebSocket } from "ws" 2 | 3 | export var wss = new WebSocketServer({ port: 8080 }) 4 | 5 | var clients = new Set() 6 | 7 | wss.on("connection", (ws) => { 8 | clients.add(ws) 9 | console.log(`[WSS]: new connection! ✔`) 10 | ws.on("message", (data, isBinary) => { 11 | console.log({ data, isBinary }) 12 | }) 13 | 14 | ws.on("close", () => { 15 | console.log("[WSS]: connection closed ❌") 16 | clients.delete(ws) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /svelte-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-app", 3 | "type": "module", 4 | "scripts": { 5 | "build": "vite build", 6 | "dev": "vite" 7 | }, 8 | "devDependencies": { 9 | "@types/bun": "latest" 10 | }, 11 | "peerDependencies": { 12 | "typescript": "^5" 13 | }, 14 | "dependencies": { 15 | "@sveltejs/adapter-static": "^3.0.8", 16 | "@sveltejs/kit": "^2.19.2", 17 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 18 | "svelte": "^5.23.1", 19 | "vite": "^6.2.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /svelte-app/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | 30 | # IntelliJ based IDEs 31 | .idea 32 | 33 | # Finder (MacOS) folder config 34 | .DS_Store 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025, MaksymIgnatiev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "allowJs": true, 9 | 10 | // Bundler mode 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "noEmit": true, 15 | 16 | // Best practices 17 | "strict": true, 18 | "skipLibCheck": true, 19 | "noFallthroughCasesInSwitch": true, 20 | 21 | // Some stricter flags (disabled by default) 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noPropertyAccessFromIndexSignature": false 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /svelte-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | }, 27 | "extends": "./.svelte-kit/tsconfig.json" 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "petpet_http_server", 3 | "version": "1.0.0", 4 | "license": "0BSD", 5 | "module": "index.ts", 6 | "type": "module", 7 | "scripts": { 8 | "start": "bun run src/index.ts", 9 | "dev": "bun --watch src/index.ts", 10 | "": "Running src/help.ts directly does not invoke all processing stuff => less CPU and RAM usage :)", 11 | "help": "bun run src/help.ts -f", 12 | "explain": "bun run src/explain.ts -f" 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 16 | "@types/bun": "^1.1.14", 17 | "vite": "^6.2.2" 18 | }, 19 | "peerDependencies": { 20 | "typescript": "^5.6.2" 21 | }, 22 | "dependencies": { 23 | "@sveltejs/adapter-static": "^3.0.8", 24 | "@sveltejs/kit": "^2.19.2", 25 | "gifwrap": "^0.10.1", 26 | "hono": "^4.6.16", 27 | "sharp": "^0.33.5", 28 | "svelte": "^5.23.1", 29 | "svelte-kit": "^1.2.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/avatars.ts: -------------------------------------------------------------------------------- 1 | import { fileNotForRunning } from "./functions" 2 | import type { AvatarExtensionDiscord } from "./types" 3 | 4 | var { API_PROVIDER, API_HTTPS } = process.env 5 | 6 | if (!API_PROVIDER && fileNotForRunning()) { 7 | import("./functions").then((m) => { 8 | console.log(m.error("API provider domain name is not set")) 9 | process.exit() 10 | }) 11 | } 12 | 13 | /** Fetch the avatar from 3rd party API, and return a promise with resolving value: `Uint8Array` with PNG image, and rejecting value: `Responce` with response from API */ 14 | export function fetchAvatarDiscord(id: string, size?: number, ext?: AvatarExtensionDiscord) { 15 | return new Promise((resolve, reject) => { 16 | var params = [] as string[] 17 | 18 | size !== undefined && params.push(`size=${size}`) 19 | ext !== undefined && params.push(`ext=${ext}`) 20 | 21 | fetch( 22 | `http${API_HTTPS === "true" ? "s" : ""}://${API_PROVIDER}/${id}${params.length ? `?${params.join("&")}` : ""}`, 23 | ).then( 24 | (res) => 25 | res.ok 26 | ? res.arrayBuffer().then((data) => resolve(new Uint8Array(data))) 27 | : reject(res), 28 | reject, 29 | ) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /svelte-app/src/routes/Galery.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 24 | 25 | 41 | -------------------------------------------------------------------------------- /src/explain.ts: -------------------------------------------------------------------------------- 1 | import { green, print, warning } from "./functions" 2 | import { helpFlags } from "./help" 3 | 4 | if (process.argv[1].match(/explain\.ts$/)) { 5 | if (process.argv[2] === "-f") { 6 | // if (process.env.npm_config_user_agent?.includes("bun")) 7 | // process.stdout.write("\x1b[1A\x1b[2K") 8 | main() 9 | } else { 10 | print( 11 | warning( 12 | `File ${green("./src/explain.ts")} is a utility file, and is not intended to be run directly. If you realy need to run it, add '${green("-f")}' flag`, 13 | ), 14 | ) 15 | process.exit() 16 | } 17 | } 18 | 19 | function main() { 20 | var info = [ 21 | `Run the project: ${green("bun start")}`, 22 | `See the help page: ${green("bun run help")} (more preferable) or ${green("bun start -h")}`, 23 | `See specific help page section: ${green("bun run help {section}")} or ${green("bun start -h {section}")}, where ${green("section")} can be: ${helpFlags.map(green).join(", ")}`, 24 | `During the runtime, you can use ${["q", ":q", "ZZ", ""].map(green).join(", ")} to exit the process. You can also use ${["SIGINT", "SIGTERM"].map(green).join(", ")} to stop the process. Key ${green("")} or ${green("SIGTSTP")} will send task to background (if i'll do all corectly)`, 25 | ] 26 | for (var text of info) print(text) 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PetPet HTTP server 2 | 3 | ## Petpet HTTP server written in TypeScript and Svelte.js, supported with Bun.js, Hono, Sharp.js, and Gifwrap 4 | 5 | First of all, you need a Bun runtime to run this project. 6 | 7 | To install Bun: 8 | 1. Head over to [Bun's official website](https://bun.sh) 9 | 2. Follow instruction on how to install per operating system (GNU+Linux/Mac/Windows) 10 | 11 | To use the server: 12 | 13 | Clone the repo: 14 | ```sh 15 | git clone https://github.com/MaksymIgnatiev/petpet_server.git 16 | cd petpet_server 17 | ``` 18 | 19 | 1. Install dependencies: 20 | ```sh 21 | bun install 22 | ``` 23 | 24 | 2. Run the projct: 25 | ```sh 26 | bun start 27 | ``` 28 | 29 | 2. To see help page: 30 | ```sh 31 | # More preferable 32 | bun run help 33 | # or 34 | bun start -h 35 | ``` 36 | 37 | > [!Note] 38 | > To access the root endpoint `/` with regular HTML (home page, sort of), you need to install dependencies inside `svelte-app` directory, and run either in development mode or build the production code. 39 | 40 | You can spesify runtime options in `config.toml` file in the root of the project, or with flags. Read [configuration.md](configuration.md) file for more, or see the help page with available flags. 41 | There are different routes available. Documentation can be found in [routes.md](routes.md) file. 42 | Information about cache can be found in [cache.md](cache.md) file. 43 | 44 | ## License 45 | 46 | This project is licensed under the 0BSD License - see the [LICENSE](LICENSE) file for details. 47 | -------------------------------------------------------------------------------- /routes.md: -------------------------------------------------------------------------------- 1 | # Routes for the HTTP server 2 | 3 | `/`: 4 | - Status code: `204` 5 | - Explanation: no content, nothing special 6 | 7 | `/:id`: 8 | - Parameters: 9 | - `size`: `'integer'`; size of the avatar image in pixels, default GIF dimensions are `128`x`128` px, integer ∈ (0, +Infinity). Ex: '100', '50', '200' 10 | - `gifsize`: `'integer'`; size of the output GIF in pixels, default is `128` px, integer ∈ (0, +Infinity). Ex: '100', '50', '200'. _Note!_ The bigger the image, the more time it will be processed first time. 11 | - `resize`: `'{number}x{number}'` the base image from center by `X` and `Y` pixels on `X` and `Y` axises, '{X}x{Y}', '{horizontal}x{vertical}', number ∈ (-Infinity, +Infinity). Ex: '5x-10', '20x6', '-5x8' 12 | - `shift`: `'{number}x{number}'`; shifts the base image from the original position by `X` and `Y` pixels on `X` and `Y` axises, '{X}x{Y}' axis, '{horizontal}x{vertical}', number ∈ (-Infinity, +Infinity). Ex: '5x-10', '20x6', '-5x8' 13 | - `squeeze`: `'number'`; squeeze factor in the middle of animation (hand down) in pixels, number ∈ (-Infinity, +Infinity). Ex: '3', '8', '4' 14 | - `fps`: `'integer'`; desire FPS for the gif, integer ∈ [1, 50). Ex: '16', '12', '24'. _Note!_ Please take notice about gif frame rate compatibility. Read 'https://www.fileformat.info/format/gif/egff.htm' for more information 15 | - `upd`: Forse generate the GIF despite it can potensialy be in cache. Just include it in the params as `&upd`/`?upd` with no/optional value just for indication proposes 16 | - Status code: `200`/`400`/`4xx`/`500`/`5xx` 17 | - Explanation: Success / Incorect parameter usage / Internal server error / Bad third-partly API response 18 | - Content-Type: `"image/gif"` | `"application/json"` 19 | - Response: Petpet GIF with the avatar of Discord's user ID 20 | 21 | `/avatar/:id`: 22 | - Status code: `200`/`4xx`/`5xx` 23 | - Explanation: Success / Bad third-partly API response 24 | - Content-Type: `"image/png"` | `"application/json"` 25 | - Response: Avatar for Discord's user ID 26 | 27 | `*` (all other routes): 28 | - Status code: `204` 29 | - Explanation: no content, nothing special 30 | 31 | More routes are expected to be added in the future and the avatar database will be expanded to other platforms in form of the `/{platform_name}/:id` route for GIF and `/avatar/{platform_name}/:id` route for avatars. 32 | -------------------------------------------------------------------------------- /svelte-app/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | 80 | 81 |
82 |

Image Gallery

83 | 84 |
85 | -------------------------------------------------------------------------------- /src/parseConfigFile.ts: -------------------------------------------------------------------------------- 1 | fileNotForRunning() 2 | 3 | import { globalOptionsDefault, setGlobalObjectOption, setGlobalOption } from "./config" 4 | import { fileNotForRunning, green, log, sameType, warning } from "./functions" 5 | import type { AllGlobalConfigOptions, Values } from "./types" 6 | 7 | function warningLog(text: string) { 8 | log("warning", warning(text)) 9 | } 10 | 11 | function formatValue(value: any) { 12 | return typeof value === "string" ? `'${value}'` : value 13 | } 14 | 15 | function unknownObjKey(configFile: string, obj: string, key: string) { 16 | warningLog( 17 | `Unknown property key in '${configFile}' configuration file on object '${green(obj)}': '${green(key)}'`, 18 | ) 19 | } 20 | 21 | function unknownKey(type: string, key: string) { 22 | warningLog(`Unknown key in '${type}' configuration file: '${green(key)}'`) 23 | } 24 | 25 | export function parseToml(obj: Record) { 26 | for (var keyRaw of Object.keys(obj) as unknown as keyof AllGlobalConfigOptions) { 27 | var key = keyRaw as keyof AllGlobalConfigOptions 28 | if (Object.hasOwn(globalOptionsDefault, key) && key !== "useConfig") { 29 | var prop = obj[key] 30 | if (sameType(globalOptionsDefault[key], prop)) { 31 | if ( 32 | typeof prop !== "object" && 33 | !Array.isArray(prop) && 34 | key !== "logOptions" && 35 | key !== "server" 36 | ) { 37 | setGlobalOption(key, prop, 1) 38 | } else { 39 | for (var propKeyRaw of Object.keys(prop)) { 40 | var propKey = propKeyRaw as keyof Values, 41 | GODO = globalOptionsDefault[ 42 | key 43 | ] as unknown as Values 44 | 45 | if (Object.hasOwn(GODO, propKey)) { 46 | if (sameType(GODO[propKey], prop[propKey])) 47 | setGlobalObjectOption( 48 | key as keyof FilteredObjectConfigProps, 49 | propKey as keyof Values, 50 | prop[propKey] as Values< 51 | keyof Values 52 | >, 53 | 1, 54 | ) 55 | else 56 | warningLog( 57 | `Option type missmatch in 'config.toml' configuration file. Property '${green(propKey)}' on object '${green(key)}' should have type '${green(typeof GODO[propKey])}', found: '${green(typeof prop[propKey])}', at: ${green(formatValue(prop[propKey]))}`, 58 | ) 59 | } else unknownObjKey("config.toml", key, propKey) 60 | } 61 | } 62 | } else 63 | warningLog( 64 | `Option type missmatch in 'config.toml' configuration file. Option '${green(key)}' should have type '${green(typeof globalOptionsDefault[key])}', found: '${green(typeof prop)}', at: ${green(formatValue(prop))}`, 65 | ) 66 | } else unknownKey("config.toml", key) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration for the project 2 | 3 | You can use configuration file or flags to specify features that you want to use in project. They will override the default values that can be found in [`./src/config.ts`](https://github.com/MaksymIgnatiev/petpet_server/blob/master/src/config.ts#L69) file under the [`globalOptionsDefault`](https://github.com/MaksymIgnatiev/petpet_server/blob/master/src/config.ts#L69) object on line [`69`](https://github.com/MaksymIgnatiev/petpet_server/blob/master/src/config.ts#L69). Flags will override the config file values. Priority is: `flags` > `config file` > `default`. 4 | 5 | There are 1 configuration file available: `config.toml` 6 | - If `config.toml` is specified - it will be used 7 | - If `config.toml` is not specified - default values will be used 8 | 9 | Or in other words: `config.toml` > `default` 10 | 11 | Config files can be omitted with `-O`/`--omit-config` flag. Then none of configuration files will be loaded and parsed. 12 | Project can be run with the `-w`/`--watch` flag to watch for changes in the configuration files, and restart the server when changes occur (full restart, not patch values) 13 | 14 | _Note!_ Flag `-w`/`--watch` will watch for: file creation, file content change, and file removal. 15 | 16 | --- 17 | Use the `bun start -g` command to generate a default config file with default values to see and modify them to the needs. 18 | 19 | Use `bun run help [section]` (more preferable) or `bun start -h [section]` to see all flags and their descriptions or a specific section of the help page. 20 | 21 | _Note!_ Flags `-W`/`--no-warnings`/`-E`/`--no-errors` will affect only warnings and errors during runtime after the flags are parsed. Warnings and errors will be shown if there will be an warning/error during the process of parsing flags. 22 | _Note!_ Flag `-q`/`--quiet` will affect all output during whole project runtime. 23 | 24 | 25 | ## Format options for `timestampFormat` option / `timestamps` flag 26 | 27 | | format | description | 28 | |:------:|:-----------------------------------------------| 29 | | u | microseconds | 30 | | S | milliseconds | 31 | | s | seconds | 32 | | m | minutes | 33 | | h | hours (24h format, 12:00, 13:00, 24:00, 01:00) | 34 | | H | hours (12h format, 12 PM, 1 PM, 12 AM, 1 AM) | 35 | | P | `AM`/`PM` indicator | 36 | | d | day (first 3 letters of the day of the week) | 37 | | D | day (of month) | 38 | | M | month (number) | 39 | | N | month (3 first letters of the month name) | 40 | | Y | year | 41 | 42 | _Note!_ To escape some character that are listed in formating - use backslash symbol `\` before character (you would probably need second one, to escape the escape character like `\n`, `\t` or others). 43 | 44 | *Examples*: 45 | | format | description | 46 | |:--------------|:--------------------------------------------------------------------------------------------------------| 47 | | "s:m:h D.M.Y" | `seconds:minutes:hours day(of month).month(number).year` | 48 | | "Y N d m:h" | `year month(3 first letters of the month name) day(first 3 letters of the day of the week) minutes:hours` | 49 | | "m:s:S.u" | `minutes:seconds:milliseconds.microseconds` | 50 | | "s/m/h" | `seconds/minutes/hours` | 51 | | "m:h" | `minutes:hours` | 52 | 53 | 54 | ## Cache 55 | Information about cache can be found in [cache.md](/cache.md) file. Be patient, and read all document if you need to understand in depth how it works. Or you can read the source code directly :) 56 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // if (process.env.npm_config_user_agent?.includes("bun")) { 2 | // // if it was a script - clear the line above (`$ bun run src/index.ts`) 3 | // if (process.stdout.isTTY) process.stdout.write("\x1b[1A\x1b[2K") // 1A - move cursor one line up; 2K - clear the entire line 4 | // } 5 | 6 | import { join } from "path" 7 | import { Stats, unwatchFile, watchFile } from "fs" 8 | import { stdout } from "process" 9 | import type { Server } from "bun" 10 | 11 | import type { AlwaysResolvingPromise } from "./types" 12 | import { 13 | chechCache, 14 | enterAlternateBuffer, 15 | error, 16 | EXIT, 17 | getConfig, 18 | green, 19 | info, 20 | log, 21 | verboseError, 22 | } from "./functions" 23 | import { getGlobalConfigOption, getGlobalOption, getServerOption, ROOT_PATH } from "./config" 24 | import { processFlags } from "./flags" 25 | import app from "./router" 26 | 27 | var args = process.argv.slice(2), 28 | server: Server, 29 | /** Cleanup function to stop watching the config file */ 30 | configWatcher: undefined | (() => void), 31 | intervalID: Timer, 32 | /** Exit function from the alternate buffer 33 | * `undefined` - not in alternate buffer 34 | * `function` - in alternate buffer */ 35 | exitAlternate: undefined | (() => void), 36 | /** Exit the whole process with exiting alternate buffer 37 | * 38 | * DON'T TRY TO USE THIS FUNCTION UNLESS YOU KNOW WHAT YOU ARE DOING 39 | * 40 | * Basicaly, it's just a function that force exits the alternate buffer, and exit the process, so, nothing special */ 41 | exit: undefined | (() => void) 42 | 43 | function setupWatchConfig() { 44 | // Watch the root directory for changes, and if it was a config file, 45 | // if `useConfig` global option is enabled, 46 | // then start the server with `restart: true` value for indication 47 | // proposes to default all values, and re-load flags and config file 48 | var filepath = join(ROOT_PATH, "config.toml"), 49 | listener = (curr: Stats, prev: Stats) => { 50 | if (curr.mtime.getTime() === 0) { 51 | // File was deleted 52 | if (getGlobalConfigOption("useConfig")) restart("deleted") 53 | } else if (prev.mtime.getTime() === 0) { 54 | // File was created 55 | if (getGlobalConfigOption("useConfig")) restart("created") 56 | } else if (curr.mtime > prev.mtime) { 57 | // File was modified 58 | if (getGlobalConfigOption("useConfig")) restart("changed") 59 | } 60 | } 61 | watchFile(filepath, { interval: 1000 }, listener).on("error", (e) => { 62 | verboseError( 63 | e, 64 | error("Error while watching config files for change:\n"), 65 | error("Error while watching config files for change"), 66 | ) 67 | }) 68 | return () => unwatchFile(filepath, listener) 69 | } 70 | 71 | function handleCacheInterval() { 72 | if (intervalID) clearInterval(intervalID) 73 | if (getGlobalOption("cache") && !getGlobalOption("permanentCache")) 74 | intervalID = setInterval(chechCache, getGlobalOption("cacheCheckTime")) 75 | } 76 | 77 | function handleWatcher() { 78 | if (configWatcher) { 79 | configWatcher() 80 | configWatcher = undefined 81 | } 82 | if (getGlobalOption("watch")) { 83 | configWatcher = setupWatchConfig() 84 | } 85 | } 86 | 87 | function handleServer() { 88 | if (server) server?.stop?.() 89 | server = Bun.serve({ 90 | fetch: app.fetch, 91 | idleTimeout: 60, 92 | port: getServerOption("port"), 93 | hostname: getServerOption("host"), 94 | }) 95 | } 96 | 97 | /** Dynamicaly enter and exit alternate buffer depending on config preferences, and handle `SIGINT` / `SIGTERM` signals to exit alternate buffer */ 98 | function handleAlternateBuffer() { 99 | if (getGlobalOption("alternateBuffer")) { 100 | if (!exitAlternate) { 101 | exitAlternate = enterAlternateBuffer() 102 | exit = () => { 103 | if (exitAlternate) exitAlternate() 104 | EXIT() 105 | } 106 | process.on("SIGINT", exit) 107 | process.on("SIGTERM", exit) 108 | } 109 | } else { 110 | if (exitAlternate) { 111 | exitAlternate() 112 | process.removeListener("SIGINT", exit!) 113 | process.removeListener("SIGTERM", exit!) 114 | exitAlternate = undefined 115 | } 116 | } 117 | } 118 | 119 | function listening() { 120 | log( 121 | "info", 122 | info(server?.url ? `Listening on URL: ${green(server.url)}` : "Server is not yet started"), 123 | ) 124 | } 125 | 126 | /** Process server setup after geting the config */ 127 | function processAfterConfig() { 128 | handleAlternateBuffer() 129 | handleCacheInterval() 130 | handleServer() 131 | handleWatcher() 132 | } 133 | 134 | async function restart(eventType: "created" | "changed" | "deleted") { 135 | return main(true, false).then((text) => { 136 | if (getGlobalOption("clearOnRestart")) stdout.write("\x1b[2J\x1b[H") 137 | if (text) log("info", text) 138 | log("watch", info(`Server restarted due to changes in config file: ${green(eventType)}`)) 139 | listening() 140 | }) 141 | } 142 | 143 | /** Process flags (if exist), try to get and parse the config file, and after all, init all other things based on the result */ 144 | function main(reload?: boolean, log?: boolean): AlwaysResolvingPromise 145 | function main(r = false, l = true): AlwaysResolvingPromise { 146 | var printText = "" 147 | // Do not process anything if there are no flags => less CPU & RAM usage and faster startup time :) 148 | if (args.length) printText = processFlags(args) 149 | return getConfig(r) 150 | .then(processAfterConfig) 151 | .then(() => { 152 | if (printText) log("info", printText) 153 | // logGlobalOptions() 154 | if (l) listening() 155 | }) 156 | .then(() => printText) as AlwaysResolvingPromise 157 | } 158 | 159 | main() 160 | -------------------------------------------------------------------------------- /src/genConfig.ts: -------------------------------------------------------------------------------- 1 | fileNotForRunning() 2 | 3 | type ObjDescriptions = FilterObjectProps> 4 | var toml = "", 5 | /** Pattern for default value anotation inside comments. If spesified - will be replaced. If not - will be appended to the end in silgle lines (not the multiple ones) */ 6 | defaultValueRegex = //, 7 | config: { 8 | [K in keyof BaseConfigOptions]: BaseConfigOptions[K] extends Record 9 | ? { description: string } & { 10 | [V in keyof BaseConfigOptions[K]]: string 11 | } 12 | : string 13 | } = { 14 | accurateTime: "Enable or disable accurate time marks", 15 | alternateBuffer: 16 | "Use an alternate buffer to output all logs \n" + 17 | "Like a normal window, only provided at the shell level, which is not related to the existing one\n" + 18 | "The shell can get cluttered after a long period of work, so it is strongly recommended to enable an alternate buffer to prevent this", 19 | avatars: "Enable or disable avatar caching", 20 | cacheTime: "Cache duration in milliseconds", 21 | cacheCheckTime: "Interval to check cache validity in milliseconds", 22 | cache: "Enable or disable caching", 23 | cacheType: "Type of the cache to save ('code' | 'fs' | 'both')", 24 | compression: "Use compression to store all cache in code in compressed format or not", 25 | clearOnRestart: 26 | "Clear the stdout from previous logs on server restart due to changes in config file", 27 | errors: "Enable or disable error logging (affect only after parsing flags)", 28 | logFeatures: "Log logging features on startup/restart", 29 | permanentCache: 30 | "Keep the cache permanently (`cacheTime` does nothing, if `cache` == `false` - overrides)", 31 | quiet: "Run in quiet mode, suppressing all output (litteraly all output). Affects only runtime, not the flag parsing stage", 32 | timestamps: "Enable timestamps in logs", 33 | timestampFormat: 34 | "Format for timestamps in logs (see `configuration.md` file for format options)", 35 | warnings: "Enable or disable warnings (affect only after parsing flags)", 36 | watch: "Watching the config file for changes, and do a restart on change (creation, content change, removal) \nEnable if you want to make changes and at the same time apply changes without restarting the server maualy,\nor if you need to change parameters often", 37 | verboseErrors: "Show full errors on each error during runtime", 38 | logOptions: { 39 | description: "Options for logging stuff", 40 | rest: "Log REST API requests/responses", 41 | gif: "Log GIF related info (creation/availability in cache/repaiting/etc.)", 42 | params: "Log REST parameters for the GIF", 43 | cache: "Log cache actions (how many was deleted/repaired/etc.)", 44 | watch: "Log when server restarted due to `watch` global option when config file changed (`watch` global option needs to be enabled)", 45 | }, 46 | server: { 47 | description: "Options for server", 48 | port: "Port for the server", 49 | host: "Host for the server", 50 | }, 51 | } 52 | 53 | import { join } from "path" 54 | import { fileNotForRunning, memoize } from "./functions" 55 | import type { AllGlobalConfigOptions, BaseConfigOptions, FilterObjectProps, Values } from "./types" 56 | import { globalOptionsDefault, ROOT_PATH } from "./config" 57 | 58 | type ObjectProps = FilterObjectProps> 59 | 60 | export function genConfig() { 61 | if (!toml.length) { 62 | toml += "# TOML configuration file\n" 63 | for (var [keyRaw, value] of Object.entries(config)) { 64 | var key = keyRaw as keyof typeof config 65 | if (typeof value === "object" && !Array.isArray(value)) 66 | addObjToToml(key as keyof ObjectProps, value) 67 | else addPropToToml(key, config[key] as string) 68 | } 69 | } 70 | Bun.write(Bun.file(join(ROOT_PATH, "config.toml")), toml) 71 | } 72 | 73 | function formatValue(value: any) { 74 | return typeof value === "string" ? `'${value}'` : value 75 | } 76 | 77 | function defaultValue(value: any) { 78 | return `(default = ${value})` 79 | } 80 | 81 | function addPropToToml

(prop: P, description: string) { 82 | var value = formatValue(globalOptionsDefault[prop]), 83 | dv = memoize(defaultValue, value), 84 | lines = formatDescription(description) 85 | toml += `\n` 86 | lines.forEach((line) => (toml += `${formatDefaultValue(line, dv.value, lines.length === 1)}`)) 87 | toml += `\n${prop} = ${value}` 88 | } 89 | 90 | function formatDescription(description: string) { 91 | return description.split(/\n+/).map((e) => `\n# ${e}`) 92 | } 93 | 94 | function formatDefaultValue(line: string, dv: string, singleline = true) { 95 | return line.match(defaultValueRegex) 96 | ? line.replace(defaultValueRegex, dv) 97 | : `${line}${singleline ? ` ${dv}` : ""}` 98 | } 99 | 100 | function addObjToToml( 101 | name: N, 102 | obj: T, 103 | ) { 104 | var value, 105 | key: keyof Values | "description", 106 | dv: { readonly value: string }, 107 | description = (config as ObjDescriptions)[name].description, 108 | descriptionLines = formatDescription(description) 109 | 110 | toml += `\n\n` 111 | descriptionLines.forEach((line) => (toml += line)) 112 | toml += `\n[${name}]\n` 113 | 114 | for (var [keyRaw, description] of Object.entries(obj)) { 115 | key = keyRaw as typeof key 116 | value = formatValue(globalOptionsDefault[name][key as keyof Values]) 117 | dv = memoize(defaultValue, value) 118 | if (key !== "description") { 119 | descriptionLines = formatDescription(description) 120 | descriptionLines.forEach( 121 | (line) => 122 | (toml += `${formatDefaultValue(line, dv.value, descriptionLines.length === 1)}`), 123 | ) 124 | toml += `\n${key} = ${value}` 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /cache.md: -------------------------------------------------------------------------------- 1 | # Cache in the project 2 | 3 | There are 2 types of cache: in code, in filesystem 4 | The third, `both` combines them into one type, but has priority in the code. Filesystem cache serves as a kind of insurance, or duplication of everything that is present in the code. If for some reason the code goes wrong (this is unlikely to happen, since there are checks everywhere) - the lost cache will be loaded from the file system. And vice versa. 5 | 6 | 7 | Useful information: 8 | 9 | PetPet object: object with following type `{ hash: Hash; id: string; lastSeen: number; gif: Buffer; }` 10 | Avatar object: object with following type `{ id: string; avatar: Buffer; }` 11 | Nullable value: non successfull value received from a function/object when expected successfull one 12 | 13 | 14 | How it works (`->` arrows indicates different tasks and branches with `if`/`else if`/`else` keywords): 15 | (assuming that cache and avatars are enabled, otherwise don't even look at cache, because cache will return a nullable value, representing absence of object/buffer) 16 | (if cache time is enabled, and permanent cache not, otherwise don't perform a check at all) 17 | 18 | 19 | ## Type `code`: 20 | 21 | ## Request 22 | -> check if request parameters are correct: if not: send a JSON response with 400 status code and explanation what is wrong -> Response (JSON) 23 | -> create hash from request UID and parameters 24 | -> if requested hash is in the cache Map object: get the PetPet object from cache, decompress the GIF buffer, and send with 200 status code -> Response (Buffer) 25 | else if hash is in the queue (generating process): get the promise for GIF generation, get the buffer, send with 200 status code -> Response (Buffer) 26 | else if hash is not in the queue: add the generation process to the queue, and repeat previous step -> Response (Buffer) 27 | -> if there was a problem with 3rd party API: send a JSON response with status code of the response from API -> Response (JSON) 28 | -> if there was a problem during processing the request: send a JSON response with 500 status code -> Response (JSON) 29 | 30 | ## Generation process 31 | -> if avatar by UID is in the cache Map object: get the Avatar object from cache, decompress the afatar buffer, use it in GIF generation, repeat previous steps 32 | else if avatar is in the queue (fetching process): get the promise, get the buffer, repeat previous steps 33 | else if avatar is not in the queue: add the fetching task to the queue, repeat previous steps 34 | -> returns a buffer representing ready petpet GIF 35 | 36 | ## Cache checking 37 | -> every `n` milliseconds perform a check for old cache by iterating through the cache Map object, and comparing delta time between now and last seen time 38 | -> if delta is bigger than cache time: remove the GIF 39 | else skip 40 | -> check avatars for dependencies (does any GIF for given UID need them?) 41 | -> if at least 1 PetPet object that has field `id` === UID is in the cache: skip 42 | else remove from cache 43 | 44 | 45 | ## Type `fs`: 46 | 47 | ### Request 48 | -> check if request parameters are correct: if not: send a JSON response with 400 status code and explanation what is wrong -> Response (JSON) 49 | -> create hash from request UID and parameters 50 | -> if requested hash is in the filesystem cache (./cache/gif/): read the `{hash}.gif` file, read the `{hash}.json` file (if exist), modify the `lastSeen` property on JSON object, write the JSON file as a `{hash}.json`, send the buffer with 200 status code -> Response 51 | else if hash is in the queue (generating process): get the promise for GIF generation, get the buffer, create PetPet object, write this PetPet object to filesystem cache as a `{hash}.gif`, `{hash}.json` files, send the buffer with 200 status code -> Response 52 | else if hash is not in the queue: add the generation process to the queue, and repeat previous step -> Response 53 | 54 | ### Generation process 55 | -> if avatar by UID is in the filesystem cache (./cache/avatar/): read the `{UID}.png` file, use it in generation process, repeat previous steps 56 | else if avatar is in the queue: get the promise, get the buffer from it, write the avatar buffer to the cache as `{UID}.png`, use this buffer for generation process, repeat previous steps 57 | else if avatar is not in the queue: add the fetch task to the queue, repeat previous steps 58 | -> returns a buffer representing ready petpet GIF 59 | 60 | ### Cache checking 61 | -> every `n` milliseconds perform a check for old cache by iterating through the filesystem cache `{hash}.json` file 62 | -> read the `{hash}.json` file 63 | -> if delta time is bigger than cache time: remove `{hash}.gif`, `{hash}.json` files from the filesystem cache 64 | else skip 65 | -> check avatars for dependencies 66 | -> if at least one file from GIF filesystem cache in the name includes UID: skip 67 | else remove from cache 68 | 69 | 70 | ## Type `both`: 71 | 72 | (overall it's the same as 2 different types on their own, but filesystem is checked much less often. Here will be only things that differ from regular behaviour. Tasks are not 100% in the same order, but just for indication that they differ) 73 | 74 | ### Request 75 | -> if hash is not in the cache Map object: chech for `{hash}.gif` file in the filesystem 76 | 77 | ### Generation process 78 | -> if avatar is not in the cache Map object: check for `{UID}.png` file in the filesystem 79 | 80 | ### Cache checking 81 | -> every `n` milliseconds perform a check for old cache and the reliability of both caches by first iterating through cache Map object first, and then filesystem cache 82 | (iteration through code cache) 83 | -> if file `{hash}.gif` or `{hash}.json` files can't be found on each iteration: use PetPet object from cache to replace them, and assign the lastSeen property to the current date 84 | (iteration through filesystem cache) 85 | -> if PetPet object with field `id` === UID can't be found in cache Map object: read the `{hash}.gif`, `{hash}.json` files to create new PetPet object, and assign the lastSeen property to the current date 86 | -------------------------------------------------------------------------------- /src/petpet.ts: -------------------------------------------------------------------------------- 1 | fileNotForRunning() 2 | 3 | import { join } from "path" 4 | import sharp from "sharp" 5 | import { GifCodec, GifFrame, GifUtil, BitmapImage } from "gifwrap" 6 | 7 | import type { Join, PetPetParams, Values } from "./types" 8 | import { fileNotForRunning, formatObject } from "./functions" 9 | import { ROOT_PATH } from "./config" 10 | 11 | type ObjectsType = ["hand", "avatar"] 12 | 13 | var sharpBlankImageOpts = { r: 0, g: 0, b: 0, alpha: 0 }, 14 | gifEncodeOpts = { loops: 0, colorScope: 0 as 0 | 1 | 2 | undefined }, 15 | finaSharplImageOpts = { palette: true, colors: 256, dither: 1 }, 16 | handFrames: GifFrame[], 17 | FrameCount: number, 18 | r = Math.round.bind(Math) 19 | 20 | export var defaultPetPetParams: { +readonly [K in keyof PetPetParams]: PetPetParams[K] } = { 21 | shiftX: 0, 22 | shiftY: 0, 23 | size: 100, 24 | gifsize: 128, 25 | fps: 16, 26 | resizeX: 0, 27 | resizeY: 0, 28 | squeeze: 12, 29 | objects: "both", 30 | } as const, 31 | objectsTypes: ObjectsType = ["hand", "avatar"], 32 | objectsTypesJoined = objectsTypes.join("|") as Join 33 | 34 | Bun.file(join(ROOT_PATH, "assets", "petpet_template_modified.gif")) 35 | .arrayBuffer() 36 | .then((arrBuffer) => { 37 | GifUtil.read(Buffer.from(arrBuffer)).then((gif) => { 38 | handFrames = gif.frames 39 | FrameCount = handFrames.length 40 | }) 41 | }) 42 | 43 | function haveObject(current: PetPetParams["objects"], obj: Values) { 44 | return current === "both" || current === obj 45 | } 46 | 47 | /** Get the progress of the GIF frame by frame providing an index of the frame 48 | * Each value represents a multiplyer to the `squeeze` value */ 49 | function getProgress(index: number) { 50 | if (index === 0) return 0.1 51 | else if (index === 1) return 0.5 52 | else if (index === 2) return 1 53 | else if (index === 3) return 0.9 54 | else if (index === 4) return 0.2 55 | else return 0 56 | } 57 | 58 | export async function generatePetPet( 59 | avatar: Uint8Array, 60 | params: Partial = defaultPetPetParams, 61 | ) { 62 | var { 63 | shiftX = defaultPetPetParams.shiftX, 64 | shiftY = defaultPetPetParams.shiftY, 65 | size = defaultPetPetParams.size, 66 | gifsize = defaultPetPetParams.gifsize, 67 | fps = defaultPetPetParams.fps, 68 | resizeX = defaultPetPetParams.resizeX, 69 | resizeY = defaultPetPetParams.resizeY, 70 | squeeze = defaultPetPetParams.squeeze, 71 | objects = defaultPetPetParams.objects, 72 | } = params, 73 | /** Normalized fps for gifwrap.GifFrame 74 | * 1s = 1000ms = 100cs */ 75 | FPS = ~~(100 / fps), 76 | gifCodec = new GifCodec(), 77 | /** Shift the base image by `x` pixels in the other half of image */ 78 | baseShift = 1, 79 | expansionFactor = 0.5, 80 | scaleFactor = gifsize / defaultPetPetParams.gifsize, 81 | framesPromises = Array.from({ length: FrameCount }, async (_, i) => { 82 | var m = Math.floor(FrameCount / 2), 83 | progress = r(getProgress(i) * squeeze * Math.tanh(scaleFactor) ** 0.4), 84 | needShift = i > m, 85 | shiftBase = needShift ? r(baseShift * scaleFactor) : 0, 86 | handOffsetY = r(progress * Math.tanh(scaleFactor)), 87 | newWidth = r((size + resizeX + progress * expansionFactor) * scaleFactor), 88 | newHeight = r((size + resizeY - progress) * scaleFactor), 89 | totalShiftX = r(shiftX * scaleFactor), 90 | totalShiftY = r((shiftY + progress) * scaleFactor), 91 | centerX = r((gifsize - newWidth) / 2), 92 | centerY = r((gifsize - newHeight) / 2), 93 | handFrame = handFrames[i] 94 | console.log( 95 | formatObject({ 96 | size, 97 | resizeY, 98 | progress, 99 | scaleFactor, 100 | newHeight, 101 | totalShiftY, 102 | centerY, 103 | }), 104 | ) 105 | return Promise.all([ 106 | new Promise((resolve) => { 107 | haveObject(objects, "avatar") 108 | ? resolve( 109 | sharp(avatar) 110 | .resize({ 111 | width: newWidth, 112 | height: newHeight, 113 | kernel: sharp.kernel.lanczos2, 114 | fit: "fill", 115 | }) 116 | .extract({ 117 | top: centerY < 0 ? Math.abs(centerY) : 0, 118 | left: centerX < 0 ? Math.abs(centerX) : 0, 119 | width: newWidth > gifsize ? gifsize : newWidth, 120 | height: newHeight > gifsize ? gifsize : newHeight, 121 | }) 122 | .toBuffer(), 123 | ) 124 | : resolve(Buffer.alloc(0)) 125 | }), 126 | new Promise((resolve) => { 127 | haveObject(objects, "hand") 128 | ? resolve( 129 | sharp(handFrame.bitmap.data, { 130 | raw: { 131 | width: handFrame.bitmap.width, 132 | height: handFrame.bitmap.height, 133 | channels: 4, 134 | }, 135 | }) 136 | .resize({ 137 | width: gifsize, 138 | height: gifsize, 139 | kernel: sharp.kernel.lanczos2, 140 | fit: "fill", 141 | }) 142 | .raw() 143 | .toBuffer(), 144 | ) 145 | : resolve(Buffer.alloc(0)) 146 | }), 147 | ]).then(([resizedAvatar, resizedHand]) => 148 | sharp({ 149 | create: { 150 | width: gifsize, 151 | height: gifsize, 152 | channels: 4, 153 | background: sharpBlankImageOpts, 154 | }, 155 | }) 156 | .composite( 157 | (() => { 158 | var objs: sharp.OverlayOptions[] = [] 159 | if (haveObject(objects, "avatar")) 160 | objs.push({ 161 | input: resizedAvatar, 162 | top: (centerY < 1 ? 0 : centerY) + totalShiftY, 163 | left: Math.max(centerX + shiftBase, 0) + totalShiftX, 164 | }) 165 | if (haveObject(objects, "hand")) 166 | objs.push({ 167 | input: resizedHand, 168 | raw: { 169 | width: gifsize, 170 | height: gifsize, 171 | channels: 4 as const, 172 | }, 173 | top: handOffsetY, 174 | left: 0, 175 | }) 176 | 177 | return objs 178 | })(), 179 | ) 180 | .png(finaSharplImageOpts) 181 | .raw() 182 | .toBuffer() 183 | .then((combinedImage) => { 184 | var bitmap = new BitmapImage(gifsize, gifsize, combinedImage) 185 | 186 | GifUtil.quantizeDekker(bitmap, 256) 187 | 188 | return new GifFrame(gifsize, gifsize, bitmap.bitmap.data, { 189 | delayCentisecs: FPS, 190 | }) 191 | }), 192 | ) 193 | }) 194 | 195 | return Promise.all(framesPromises).then((frames) => 196 | gifCodec.encodeGif(frames, gifEncodeOpts).then((obj) => Uint8Array.from(obj.buffer)), 197 | ) 198 | } 199 | -------------------------------------------------------------------------------- /src/help.ts: -------------------------------------------------------------------------------- 1 | var terminalWidth = process.stdout.columns || 80 2 | 3 | export var helpFlags = ["flags", "log_options", "timestamp_format", "sections"] as const, 4 | isSmallScreen = terminalWidth < 91 5 | 6 | var flagsGap = 1, 7 | optionsColumnWidth = 35 as const, 8 | optionsLog: Section = new Map([ 9 | [["0"], "No logging"], 10 | [["1"], "Requests and responses"], 11 | [["2"], "Previous + GIF creation time / When GIF was in cache"], 12 | [["3"], "Previous + Request parameters"], 13 | [["4"], "Previous + Info about cleanup the cache"], 14 | [["5"], "Previous + Restarting the server (all logging)"], 15 | [["r", "rest"], "Requests and responses"], 16 | [["g", "gif"], "GIF creation time / When GIF was in cache"], 17 | [["p", "params"], "Request parameters"], 18 | [["c", "cache"], "Info about cleanup the cache"], 19 | [ 20 | ["w", "watch"], 21 | "inform when server restarted due to changes in cofnig file (watch global option needs to be enabled)", 22 | ], 23 | ]), 24 | timestamps: Section = new Map([ 25 | [`u`, "microseconds"], 26 | [`S`, "milliseconds "], 27 | [`s`, "seconds"], 28 | [`m`, "minutes"], 29 | [`h`, "hours (24h format, 12:00, 13:00, 24:00, 01:00)"], 30 | [`H`, "hours (12h format, 12 PM, 1 PM, 12 AM, 1 AM)"], 31 | [`P`, "`AM`/`PM` indicator"], 32 | [`d`, "day (3 first letters of the day of the week)"], 33 | [`D`, "day (of month)"], 34 | [`M`, "month (number)"], 35 | [`N`, "month (3 first letters of the month name)"], 36 | [`Y`, "year"], 37 | ]), 38 | sections: Section = new Map([ 39 | ["flags", "Show all available flags with extended descriptions and default values"], 40 | ["log_options", "Show log options"], 41 | ["timestamp_format", "Show timestamp formating"], 42 | ["sections", "Show this message"], 43 | ]) 44 | 45 | if (process.argv[1].match(/help\.ts$/)) { 46 | if (process.argv[2] === "-f") { 47 | if (process.env.npm_config_user_agent?.includes("bun")) { 48 | if (process.stdout.isTTY) process.stdout.write("\x1b[1A\x1b[2K") 49 | } 50 | // Running this file directly will not load the entry point to process flags => less CPU usage :) 51 | 52 | var flag = process.argv[3]?.toLowerCase().trim() 53 | if (flag === undefined || helpFlags.includes(flag as (typeof helpFlags)[number])) 54 | print(main(flag as (typeof helpFlags)[number])) 55 | else print(error(`Unknown help section: ${green(flag)}`)) 56 | } else { 57 | print( 58 | warning( 59 | `File ${green("./src/help.ts")} is a utility file, and is not intended to be run directly. If you realy need to run it, add '${green("-f")}' flag`, 60 | ), 61 | ) 62 | process.exit() 63 | } 64 | } 65 | 66 | import { flagObjects, setupFlagHandlers } from "./flags" 67 | import { error, green, print, warning } from "./functions" 68 | type FlagRest = [string | string[], string] | string 69 | type Section = Map 70 | 71 | /** SS = Small Screen for short 72 | * return first value if screen is small. Else second 73 | */ 74 | export function ss(ifTrue: T, ifFalse: F): T | F { 75 | return isSmallScreen ? ifTrue : ifFalse 76 | } 77 | 78 | class FormatedOption { 79 | #formated: string 80 | #raw: string 81 | constructor(formatedOption: string) { 82 | this.#formated = formatedOption 83 | this.#raw = formatedOption.replace(/\u001b\[\d+m/g, "") 84 | } 85 | get value() { 86 | return { 87 | raw: this.#raw, 88 | formated: this.#formated, 89 | } 90 | } 91 | get length() { 92 | return { 93 | raw: this.#raw.length, 94 | formated: this.#formated.length, 95 | } 96 | } 97 | } 98 | 99 | class CLIOption { 100 | /** indentation for all options (leading characters) when terminal size is normal */ 101 | private static indent = " " as const 102 | private key = new FormatedOption("") 103 | private props: string = "" 104 | private descriptionText: string = "" 105 | private keyWidth: number 106 | private descriptionWidth: number 107 | private stdoutWidth: number 108 | 109 | constructor( 110 | optionColumnWidth: number = optionsColumnWidth, 111 | stdoutWidth: number = terminalWidth, 112 | ) { 113 | optionColumnWidth = optionColumnWidth - (isSmallScreen ? CLIOption.indent.length : 0) 114 | 115 | this.keyWidth = optionColumnWidth 116 | this.stdoutWidth = stdoutWidth 117 | this.descriptionWidth = stdoutWidth - optionColumnWidth 118 | } 119 | 120 | option(option: FormatedOption) { 121 | this.key = option 122 | return this 123 | } 124 | 125 | properties(value: string) { 126 | this.props = value 127 | return this 128 | } 129 | 130 | description(description: string) { 131 | this.descriptionText = description 132 | return this 133 | } 134 | 135 | build() { 136 | var propsText = this.props ? ` ${this.props}` : "", 137 | fullKey = (isSmallScreen ? "" : CLIOption.indent) + this.key.value.formated + propsText, 138 | fullKeyRaw = (isSmallScreen ? "" : CLIOption.indent) + this.key.value.raw + propsText, 139 | fullKeyPadValue = this.keyWidth + 1 - fullKeyRaw.length, 140 | fullKeyPad = " ".repeat(fullKeyPadValue < 0 ? 0 : fullKeyPadValue), 141 | readyLines = this.wrapText(this.descriptionText).map((line, index) => 142 | index === 0 143 | ? fullKey + fullKeyPad + line 144 | : " ".repeat(this.stdoutWidth - this.descriptionWidth + 1) + line, 145 | ) 146 | return { 147 | get lines() { 148 | return readyLines 149 | }, 150 | get string() { 151 | return readyLines.join("\n") 152 | }, 153 | } 154 | } 155 | 156 | private wrapText(text: string) { 157 | // any word/ansi escape code exept space 158 | var words = text.match(/[^ ]+/g) ?? [text], 159 | // raw version without ansi escape codes 160 | rawWords = words.map((w) => w.replace(/\u001b\[\d+m/g, "")), 161 | lines: string[] = [], 162 | currentLine = "", 163 | currentLineLen = 0 164 | for (var i = 0; i < words.length; i++) { 165 | if ( 166 | currentLineLen + (currentLine ? 1 : 0) + rawWords[i].length + +!isSmallScreen >= 167 | this.descriptionWidth 168 | ) { 169 | lines.push(currentLine) 170 | currentLine = words[i] 171 | currentLineLen = rawWords[i].length 172 | } else { 173 | currentLine += (currentLine ? " " : "") + words[i] 174 | currentLineLen += (currentLine ? 1 : 0) + rawWords[i].length 175 | } 176 | } 177 | if (currentLine || lines.length === 0) lines.push(currentLine) 178 | return lines 179 | } 180 | } 181 | 182 | class CLISection { 183 | private name: Name 184 | private options: CLIOption[] 185 | private PS: string[] 186 | constructor(name: Name, options: CLIOption[], postScriptum: string[] = []) { 187 | this.name = name 188 | this.options = options 189 | this.PS = postScriptum 190 | } 191 | get string() { 192 | // todo: return a string, not print 193 | var out = `${green(this.name)}:\n` 194 | out += this.options.map((option) => option.build().string).join("\n") 195 | out += "\n" 196 | out += this.PS.join("\n") 197 | return out 198 | } 199 | } 200 | 201 | function formatOptions(optionList: string[]): FormatedOption { 202 | return new FormatedOption( 203 | `${(optionList.every((e) => /[a-zA-Z]{2,}/.test(e)) 204 | ? ((optionList[0] = `${` ${ss(optionList[0].includes("-") ? " " : "", " ")}${ss("", " ".repeat(flagsGap - +!optionList[0].includes("-")))}`}${optionList[0]}`), 205 | optionList) 206 | : optionList 207 | ) 208 | .map(green) 209 | .join(ss(",", `,${" ".repeat(flagsGap)}`))}`, 210 | ) 211 | } 212 | 213 | function createCLISection( 214 | name: Name, 215 | options: Section, 216 | ps: string[] = [], 217 | optionWidth: number = optionsColumnWidth, 218 | ) { 219 | var CLIOptions: CLIOption[] = [] 220 | for (var [key, value] of options.entries()) { 221 | var optionsArr: string[] = [], 222 | propsArr: string[] = [], 223 | description = "" 224 | if (typeof key === "string") optionsArr.push(key) 225 | else optionsArr = key 226 | if (typeof value === "string") description = value 227 | else { 228 | if (typeof value[0] === "string") propsArr.push(value[0]) 229 | else propsArr = value[0] 230 | description = value[1] 231 | } 232 | CLIOptions.push( 233 | new CLIOption(optionWidth) 234 | .option(formatOptions(optionsArr)) 235 | .properties(propsArr.join(" ")) 236 | .description(description), 237 | ) 238 | } 239 | return new CLISection(name, CLIOptions, ps) 240 | } 241 | 242 | function getFlagsDescription(extended = false) { 243 | return createCLISection( 244 | "FLAGS", 245 | (() => { 246 | setupFlagHandlers() 247 | return flagObjects.reduce((a, c) => { 248 | var parameter = [], 249 | value = [], 250 | description = "" 251 | if (c.short) parameter.push(`-${c.short}`) 252 | if (c.parameter) value.push(c.parameter) 253 | 254 | description = extended ? c.extendedDescription || c.description : c.description 255 | 256 | parameter.push(`--${c.long}`) 257 | value.push(description) 258 | a.set(parameter, (value.length === 1 ? value[0] : value) as FlagRest) 259 | return a 260 | }, new Map()) 261 | })(), 262 | [ 263 | `You can combine flags that don't require a value into a sequense of flags. Ex: '${green("-LAWEt")}'`, 264 | ], 265 | ).string 266 | } 267 | 268 | function getLogOptions() { 269 | return createCLISection( 270 | "LOG OPTIONS", 271 | optionsLog, 272 | [ 273 | `To select specific features, use coma to separate them. Ex: '${green("-l r,g,p,c,w")}', or specify the level of logging. Ex: '${green("-l 3")}'`, 274 | ], 275 | 13, 276 | ).string 277 | } 278 | 279 | function getTimespampOptions() { 280 | return createCLISection( 281 | "TIMESTAMP FORMAT", 282 | timestamps, 283 | [ 284 | "\nExamples:", 285 | `'${green("s:m:h D.M.Y")}' - seconds:minutes:hours day(of month).month(number).year`, 286 | `'${green("Y N d m:h")}' - year month(3 first letters of the month name) day(first 3 letters of the day of the week) minutes:hours`, 287 | `'${green("m:s:S.u")} - minutes:seconds:milliseconds.microseconds`, 288 | `'${green("s/m/h")}' - seconds/minutes/hours`, 289 | `'${green("m:h")}' - minutes:hours`, 290 | ], 291 | 9, 292 | ).string 293 | } 294 | 295 | function getSections() { 296 | return createCLISection("SECTIONS", sections, [], 23).string 297 | } 298 | 299 | export default function main(section?: (typeof helpFlags)[number]) { 300 | var flags = false, 301 | logOptions = false, 302 | sections = false, 303 | timestamps = false, 304 | result = "", 305 | add = (text: string) => (result += `${!!result ? "\n" : ""}${text}`) 306 | 307 | !section && add(`Usage: ${green("bun start [FLAGS...]")}`) 308 | if (section === "flags") flags = true 309 | if (section === "log_options") logOptions = true 310 | if (section === "sections") sections = true 311 | if (section === "timestamp_format") timestamps = true 312 | if (section && !helpFlags.includes(section)) 313 | add(error(`Unknown help section: ${green(section)}`)) 314 | 315 | !section && add(getFlagsDescription()) 316 | 317 | flags && add(getFlagsDescription(true)) 318 | logOptions && add(getLogOptions()) 319 | sections && add(getSections()) 320 | timestamps && add(getTimespampOptions()) 321 | 322 | !section && 323 | add( 324 | `\nYou can watch a spesific section of the help page by running '${green("bun start -h/--help {section}")}' or '${green("bun run help {section}")}'\nAvailable sections: ${helpFlags.map(green).join(", ")}`, 325 | ) 326 | return result 327 | } 328 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path" 2 | import { Hono, type Context, type TypedResponse } from "hono" 3 | import { 4 | APILogger, 5 | checkValidRequestParams, 6 | colorValue, 7 | error, 8 | info, 9 | log, 10 | gray, 11 | green, 12 | isCurrentCacheType, 13 | isLogfeatureEnabled, 14 | isStringNumber, 15 | print, 16 | verboseError, 17 | } from "./functions" 18 | import { cache, statControll, stats } from "./db" 19 | import { defaultPetPetParams } from "./petpet" 20 | import type { ChechValidPetPetParams, Hash, ImageResponse, PetPetParams } from "./types" 21 | import { cors } from "hono/cors" 22 | import { serveStatic } from "hono/bun" 23 | import { getGlobalOption, ROOT_PATH } from "./config" 24 | import { existsSync, readdirSync } from "fs" 25 | import "./wss" 26 | 27 | var app = new Hono(), 28 | noContent = (c: Context) => { 29 | // do not ask again for 30 days 30 | c.header("Cache-Control", `public, max-age=${cacheTime}`) 31 | c.status(204) 32 | return c.json({ ok: true, code: 204, statusText: "No Content" }) 33 | }, 34 | // 30d do not ask again via cache in HTTP headers 35 | cacheTime = 30 * 24 * 60 * 60, 36 | GIFResponse = (gif: Uint8Array) => 37 | new Response(gif, { 38 | status: 200, 39 | statusText: "OK", 40 | headers: { "Content-Type": "image/gif" }, 41 | }), 42 | PNGResponse = (avatar: Uint8Array) => 43 | new Response(avatar, { 44 | status: 200, 45 | statusText: "OK", 46 | headers: { "Content-Type": "image/png" }, 47 | }), 48 | internalServerError = (c: Context, route: "/avatar/:id" | "/:id") => { 49 | statControll.response[route === "/avatar/:id" ? "avatars" : "common"].increment.failure() 50 | return c.json( 51 | { 52 | ok: false as const, 53 | code: 500 as const, 54 | statusText: "Internal Server Error" as const, 55 | message: "Something went wrong while processing the request" as const, 56 | }, 57 | 500 as const, 58 | ) 59 | } 60 | 61 | app.use("*", cors({ origin: "*" })) 62 | 63 | app.use( 64 | "/_app/*", 65 | serveStatic({ 66 | root: "./svelte-app/build", 67 | rewriteRequestPath: (path) => path.replace(/^\/_app/, "_app"), 68 | }), 69 | ) 70 | 71 | app.get("/favicon.ico", noContent) 72 | 73 | app.get("/stats", (c) => c.json(stats)) 74 | 75 | app.use("/:id", async (c, next) => { 76 | // `APILogger` is the function to log the requests and responses 77 | if (isStringNumber(c.req.param("id"))) { 78 | return isLogfeatureEnabled("rest") ? APILogger()(c, next) : next() 79 | } else return next() 80 | }) 81 | 82 | app.get("/:id", async (c) => { 83 | return c.json({ message: "Not yet fully implemented!" }) 84 | var userID = c.req.param("id"), 85 | update = !!c.req.url.match(/[?&]upd(&|=|$)/), 86 | shiftRaw = c.req.query("shift"), 87 | sizeRaw = c.req.query("size"), 88 | gifsizeRaw = c.req.query("gifsize"), 89 | resizeRaw = c.req.query("resize"), 90 | fpsRaw = c.req.query("fps"), 91 | squeezeRaw = c.req.query("squeeze"), 92 | objectsRaw = c.req.query("objects"), 93 | shiftX = defaultPetPetParams.shiftX, 94 | shiftY = defaultPetPetParams.shiftY, 95 | size = defaultPetPetParams.size, 96 | gifsize = defaultPetPetParams.gifsize, 97 | fps = defaultPetPetParams.fps, 98 | resizeX = defaultPetPetParams.resizeX, 99 | resizeY = defaultPetPetParams.resizeY, 100 | squeeze = defaultPetPetParams.squeeze, 101 | objects = defaultPetPetParams.objects 102 | 103 | if (!isStringNumber(userID)) return noContent(c) 104 | 105 | var params: ChechValidPetPetParams = { 106 | shift: shiftRaw, 107 | size: sizeRaw, 108 | gifsize: gifsizeRaw, 109 | resize: resizeRaw, 110 | fps: fpsRaw, 111 | squeeze: squeezeRaw, 112 | objects: objectsRaw, 113 | }, 114 | notValidParams = checkValidRequestParams(c, params) 115 | 116 | if (notValidParams) { 117 | statControll.response.common.increment.failure() 118 | return notValidParams 119 | } 120 | 121 | if (isLogfeatureEnabled("params")) { 122 | var requestParams: string[] = [] 123 | for (var [paramKey, paramValue] of Object.entries(params)) { 124 | if (paramValue !== undefined) { 125 | requestParams.push(`${gray(paramKey)}: ${colorValue(paramValue)}`) 126 | } 127 | } 128 | print(`${"Request params:"} ${requestParams.join(`${gray(",")} `)}`) 129 | } 130 | 131 | // at this point, each parameter is checked with `checkValidRequestParams` function, and can be used if it's exist 132 | if (shiftRaw !== undefined) [shiftX, shiftY] = shiftRaw.split("x").map(Number) 133 | if (resizeRaw !== undefined) [resizeX, resizeY] = resizeRaw.split("x").map(Number) 134 | if (sizeRaw !== undefined) size = +sizeRaw 135 | if (gifsizeRaw !== undefined) gifsize = +gifsizeRaw 136 | if (fpsRaw !== undefined) fps = +fpsRaw 137 | if (squeezeRaw !== undefined) squeeze = +squeezeRaw 138 | if (objectsRaw !== undefined) 139 | objects = objectsRaw.includes(",") ? "both" : (objectsRaw as typeof objects) 140 | else objects = "both" 141 | 142 | var petpetHash: Hash = `${userID}-${shiftX}x${shiftY}-${resizeX}x${resizeY}-${squeeze}-${size}-${gifsize}-${fps}-${objects}`, 143 | petpetParams: Partial = { 144 | shiftX, 145 | shiftY, 146 | size, 147 | fps, 148 | resizeX, 149 | resizeY, 150 | squeeze, 151 | gifsize, 152 | objects, 153 | } 154 | 155 | try { 156 | return new Promise((resolve) => { 157 | // 3 essentials that will do whole work automaticaly thanks to the `thenableObject`s 158 | var resolved = false, 159 | ready = (uintArr: Uint8Array) => { 160 | statControll.response.common.increment.success() 161 | resolve(GIFResponse(uintArr)) 162 | }, 163 | promise = Promise.withResolvers(), 164 | addCallback = ( 165 | fn: ( 166 | buffer: Uint8Array, 167 | ) => 168 | | void 169 | | PromiseLike 170 | | Uint8Array 171 | | PromiseLike 172 | | PromiseLike, 173 | ) => promise.promise.then(fn) 174 | 175 | print("Gif:") 176 | if (!update && isCurrentCacheType("code")) { 177 | if (cache.gif.code.has(petpetHash)) { 178 | print(green("gif in code cache")) 179 | ready(cache.gif.code.get(petpetHash)!.gif) 180 | resolved ||= true 181 | } 182 | } 183 | if (!update && isCurrentCacheType("fs")) { 184 | if ((!resolved || isCurrentCacheType("both")) && cache.gif.fs.has(petpetHash)) { 185 | print(green("gif in fs cache")) 186 | cache.gif.fs 187 | .get(petpetHash) 188 | .then((gif) => (gif instanceof Uint8Array ? ready(gif) : void 0)) 189 | resolved ||= true 190 | } 191 | } 192 | 193 | if (!update && !resolved && cache.gif.queue.has(petpetHash)) { 194 | print(green("gif in queue")) 195 | addCallback(ready) 196 | promise.resolve(cache.gif.queue.get(petpetHash)) 197 | resolved ||= true 198 | } 199 | if (!update && isCurrentCacheType("code")) { 200 | if (!resolved && cache.avatar.code.has(userID)) { 201 | print(green("avatar in code cache")) 202 | addCallback((png) => 203 | cache.gif.queue.add(petpetHash, png, petpetParams).then(ready), 204 | ) 205 | promise.resolve(cache.avatar.code.get(userID)!.avatar) 206 | resolved ||= true 207 | } 208 | } 209 | if (!update && isCurrentCacheType("fs")) { 210 | if ((!resolved || isCurrentCacheType("both")) && cache.avatar.fs.has(userID)) { 211 | print(green("avatar in fs cache")) 212 | addCallback((png) => 213 | cache.gif.queue.add(petpetHash, png, petpetParams).then(ready), 214 | ) 215 | cache.avatar.fs 216 | .get(userID)! 217 | .then((avatar) => 218 | avatar instanceof Uint8Array ? promise.resolve(avatar) : void 0, 219 | ) 220 | resolved ||= true 221 | } 222 | } 223 | if (!update && !resolved && cache.avatar.queue.has(userID)) { 224 | print(green("avatar in queue")) 225 | addCallback((png) => cache.gif.queue.add(petpetHash, png, petpetParams).then(ready)) 226 | cache.avatar.queue.get(userID)!.then( 227 | (png) => promise.resolve(png), 228 | (response) => { 229 | statControll.response.common.increment.failure() 230 | resolve(response) 231 | }, 232 | ) 233 | resolved ||= true 234 | } else if (!resolved) { 235 | print(green("feting avatar")) 236 | addCallback(ready) 237 | cache.gif.queue.addWithAvatar(petpetHash, petpetParams).then( 238 | (gif) => promise.resolve(gif), 239 | (response) => { 240 | statControll.response.common.increment.failure() 241 | resolve(response) 242 | }, 243 | ) 244 | resolved ||= true 245 | } 246 | }) 247 | } catch (e) { 248 | if (e instanceof Error) 249 | verboseError( 250 | e, 251 | error(`Error while processing GET /${userID} :\n`), 252 | error(`Error while processing GET /${userID}`), 253 | ) 254 | return internalServerError(c, "/:id") 255 | } 256 | }) 257 | 258 | app.use("/avatar/:id", async (c, next) => { 259 | // `APILogger` is the function to log the requests and responses 260 | if (isStringNumber(c.req.param("id"))) 261 | return isLogfeatureEnabled("rest") ? APILogger()(c, next) : next() 262 | else return next() 263 | }) 264 | 265 | app.get("/avatar/:id", async (c) => { 266 | var id = c.req.param("id"), 267 | size = c.req.query("size") 268 | if (!isStringNumber(id)) return noContent(c) 269 | if (size && !/^\d+$/.test(size)) { 270 | statControll.response.avatars.increment.failure() 271 | return c.json( 272 | { 273 | ok: false, 274 | code: 400, 275 | message: 276 | "Invalid size parameter. Size must contain only digits from 0 to 9 and be positive", 277 | statusText: "Bad Request", 278 | }, 279 | 400, 280 | ) 281 | } else { 282 | try { 283 | return new Promise((resolve) => { 284 | var resolved = false, 285 | ready = (uintArr: Uint8Array) => { 286 | statControll.response.common.increment.success() 287 | resolve(PNGResponse(uintArr)) 288 | }, 289 | promise = Promise.withResolvers(), 290 | addCallback = ( 291 | fn: ( 292 | buffer: Uint8Array, 293 | ) => 294 | | void 295 | | PromiseLike 296 | | Uint8Array 297 | | PromiseLike 298 | | PromiseLike, 299 | ) => promise.promise.then(fn) 300 | print("Avatar:") 301 | if (isCurrentCacheType("code")) { 302 | if (!resolved && cache.avatar.code.has(id)) { 303 | print(green("avatar in code cache")) 304 | ready(cache.avatar.code.get(id)!.avatar) 305 | resolved ||= true 306 | } 307 | } 308 | if (isCurrentCacheType("fs")) { 309 | if (!resolved && cache.avatar.fs.has(id)) { 310 | print(green("avatar in fs cache")) 311 | cache.avatar.fs 312 | .get(id)! 313 | .then((avatar) => 314 | avatar instanceof Uint8Array ? ready(avatar) : void 0, 315 | ) 316 | resolved ||= true 317 | } 318 | } 319 | if (!resolved && cache.avatar.queue.has(id)) { 320 | print(green("avatar in queue")) 321 | cache.avatar.queue.get(id)!.then(ready, (response) => { 322 | statControll.response.common.increment.failure() 323 | resolve(response) 324 | }) 325 | resolved ||= true 326 | } else if (!resolved) { 327 | print(green("feting avatar")) 328 | addCallback(ready) 329 | cache.avatar.queue.add(id).then( 330 | (gif) => promise.resolve(gif), 331 | (response) => { 332 | statControll.response.common.increment.failure() 333 | resolve(response) 334 | }, 335 | ) 336 | resolved ||= true 337 | } 338 | }) 339 | } catch (e) { 340 | if (e instanceof Error) 341 | verboseError( 342 | e, 343 | error(`Error while processing GET /avatar/${id} :\n`), 344 | error(`Error while processing GET /avatar/${id}`), 345 | ) 346 | return internalServerError(c, "/avatar/:id") 347 | } 348 | } 349 | }) 350 | 351 | app.use( 352 | "/", 353 | serveStatic({ 354 | root: "./svelte-app/build", 355 | }), 356 | ) 357 | 358 | app.get("/", () => { 359 | console.log(info(`/ is requested`)) 360 | return new Response(Bun.file("./svelte-app/build/index.html"), { 361 | headers: { "Content-Type": "text/html" }, 362 | }) 363 | }) 364 | 365 | // API endpoint to list images in `.cache/` 366 | app.get("/api/images", (c): TypedResponse => { 367 | console.log(info(`/api/images is requested`)) 368 | var type = c.req.query("type") 369 | if (!type || !/^(?:avatars|petpets|all)$/.test(type)) type = "all" 370 | var cacheDirs = [ 371 | join(ROOT_PATH, ".cache"), 372 | join(ROOT_PATH, ".cache", "gifs"), 373 | join(ROOT_PATH, ".cache", "avatars"), 374 | ] as const, 375 | dirsExists = cacheDirs.map(existsSync), 376 | imageFiles: string[] = [] 377 | 378 | if (!dirsExists[0]) c.json({ state: "no-content" }) 379 | 380 | if (/all|petpets/.test(type)) { 381 | if (dirsExists[1]) 382 | imageFiles.push(...readdirSync(cacheDirs[1]).map((file) => `/cache/gifs/${file}`)) 383 | } 384 | if (/all|avatars/.test(type)) { 385 | if (dirsExists[2]) 386 | imageFiles.push(...readdirSync(cacheDirs[2]).map((file) => `/cache/avatars/${file}`)) 387 | } 388 | 389 | if (!imageFiles.length) 390 | return c.json({ state: getGlobalOption("cache") ? "no-content" : "cache-disabled" }) 391 | 392 | return c.json({ state: "completed", value: imageFiles }) 393 | }) 394 | 395 | app.get("/cache/:type/:id", async (c) => { 396 | var type = c.req.param("type") 397 | 398 | if (!type || !/^(?:avatars|gifs)$/.test(type)) type = "avatar" 399 | var id = c.req.param("id") 400 | var filePath = join(ROOT_PATH, ".cache", type, id) 401 | return Bun.file(filePath) 402 | .arrayBuffer() 403 | .then( 404 | (arrbuf) => 405 | new Response(arrbuf, { 406 | headers: { 407 | "Content-type": `image/${id.match(/(?<=\.)\w+$/)?.[0] ?? "png"}`, 408 | }, 409 | }), 410 | ) 411 | }) 412 | 413 | // no content on all other routes 414 | app.get("*", noContent) 415 | 416 | export default app 417 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | var callStackRegex = 2 | /^Error\n\s*at\s+check(S|G)etStateCallStack\s+.*config\.ts.*\n\s*at\s+state\s+.*config\.ts.*\n\s*at\s+((s|g)etState|setGlobalOption)\s+.*config\.ts/ 3 | 4 | class Config implements GlobalOptions { 5 | #state: GlobalOptions["state"] = "ready" 6 | readonly version = "0.1.0" 7 | accurateTime!: GlobalOptions["accurateTime"] 8 | alternateBuffer!: GlobalOptions["alternateBuffer"] 9 | avatars!: GlobalOptions["avatars"] 10 | cache!: GlobalOptions["cache"] 11 | cacheCheckTime!: GlobalOptions["cacheCheckTime"] 12 | cacheTime!: GlobalOptions["cacheTime"] 13 | cacheType!: GlobalOptions["cacheType"] 14 | compression!: GlobalOptions["compression"] 15 | inAlternate!: GlobalOptions["inAlternate"] 16 | clearOnRestart!: GlobalOptions["clearOnRestart"] 17 | errors!: GlobalOptions["errors"] 18 | logFeatures!: GlobalOptions["logFeatures"] 19 | logOptions!: GlobalOptions["logOptions"] 20 | permanentCache!: GlobalOptions["permanentCache"] 21 | quiet!: GlobalOptions["quiet"] 22 | server!: GlobalOptions["server"] 23 | timestamps!: GlobalOptions["timestamps"] 24 | timestampFormat!: GlobalOptions["timestampFormat"] 25 | useConfig!: GlobalOptions["useConfig"] 26 | verboseErrors!: GlobalOptions["verboseErrors"] 27 | warnings!: GlobalOptions["warnings"] 28 | watch!: GlobalOptions["watch"] 29 | constructor() { 30 | for (var rawKey in globalOptionsDefault) { 31 | var key = rawKey as keyof AllGlobalConfigOptions, 32 | value = globalOptionsDefault[key] 33 | Object.defineProperty(this, key, { 34 | value: typeof value === "object" ? applyGlobalProp(value) : globalProp(value), 35 | }) 36 | } 37 | } 38 | // Shhhhhhhhh, i know they are the same thing. I need to keep globalOptionsDefault at line 69 39 | private checkSetStateCallStack() { 40 | return callStackRegex.test(new Error().stack ?? "") 41 | } 42 | 43 | private checkGetStateCallStack() { 44 | return callStackRegex.test(new Error().stack ?? "") 45 | } 46 | get state() { 47 | if (this.checkGetStateCallStack()) { 48 | return this.#state 49 | } else { 50 | // Do not try to get the value of the 'state' field from other place, because it can be unsafe 51 | throw new Error( 52 | "Global option 'state' was retrieved from another place. It is preferable to use the 'getState' function from './src/config.ts' file", 53 | ) 54 | } 55 | } 56 | set state(value) { 57 | if (this.checkSetStateCallStack()) { 58 | this.#state = value 59 | } else { 60 | // Do not try to set the value of the 'state' field from other place, because it can be unsafe 61 | throw new Error( 62 | "Global option 'state' was set from another place. It is preferable to use the 'setState' function from './src/config.ts' file", 63 | ) 64 | } 65 | } 66 | } 67 | 68 | /** Default options for the project runtime */ 69 | export var globalOptionsDefault: AllGlobalConfigOptions = { 70 | accurateTime: true, // false 71 | alternateBuffer: false, // true 72 | avatars: true, // true 73 | cacheTime: 15 * 60_000, // 15 minutes in `ms` 74 | cacheCheckTime: 60_000, // 1 minute in `ms` 75 | cache: true, // true 76 | cacheType: "code", 77 | compression: true, // true 78 | clearOnRestart: false, // true 79 | inAlternate: false, 80 | errors: true, // true 81 | logFeatures: false, // false 82 | quiet: false, // false 83 | permanentCache: false, // false 84 | warnings: true, // true 85 | watch: true, // false 86 | timestamps: true, // false 87 | timestampFormat: "h:m:s:S.u", // "h:m:s D.M.Y" 88 | useConfig: true, // true 89 | verboseErrors: false, // false 90 | logOptions: { 91 | rest: true, // false 92 | gif: false, // false 93 | params: false, // false 94 | cache: false, // false 95 | watch: false, // false 96 | }, 97 | server: { 98 | port: 3000, 99 | host: "0.0.0.0", 100 | }, 101 | }, 102 | /** Absolute path to the root of the project */ 103 | ROOT_PATH = join(dirname(fileURLToPath(import.meta.url)), "../"), 104 | priorityLevel = ["original", "config", "arguments"] as const, 105 | logLevel = ["rest", "gif", "params", "cache", "watch"] as const, 106 | cacheType = ["code", "fs", "both"] as const 107 | 108 | var globalOptions: GlobalOptions = new Config(), 109 | stateList = ["configuring", "ready"] as const, 110 | configOptions = ["useConfig", "config"] as const 111 | 112 | import { join, dirname } from "path" 113 | import { fileURLToPath } from "url" 114 | import { hasNullable, sameType, green, exitAlternateBuffer, print } from "./functions" 115 | import type { 116 | AdditionalGlobalOptions, 117 | AllGlobalConfigOptions, 118 | FilterObjectProps, 119 | GlobalObjectOptions, 120 | GlobalOptionProp, 121 | GlobalOptionPropPriorityAll, 122 | GlobalOptionPropPriorityLevel, 123 | GlobalOptionPropPriorityString, 124 | GlobalOptions, 125 | GlobalOptionsValues, 126 | IndexOf, 127 | Values, 128 | } from "./types" 129 | 130 | function applyGlobalProp>(obj: O) { 131 | return Object.fromEntries( 132 | Object.entries(obj).map(([key, value]) => [key, globalProp(value)]), 133 | ) as { [K in keyof O]: GlobalOptionProp } 134 | } 135 | 136 | function globalProp(value: T): GlobalOptionProp 137 | function globalProp( 138 | value: T, 139 | source?: S, 140 | ): GlobalOptionProp 141 | function globalProp( 142 | value: T, 143 | source = "original" as S, 144 | ): GlobalOptionProp { 145 | return { value, source } 146 | } 147 | 148 | export function getState() { 149 | return globalOptions.state 150 | } 151 | export function setState(state: S) { 152 | if (stateList.includes(state)) { 153 | globalOptions.state = state 154 | } else { 155 | // this will only work with type assertion. Don't play with it :) 156 | throw new Error( 157 | `State for global configuration does not satisfies the type: '${stateList.map(green).join("' | '")}'. State '${state}' was provided`, 158 | ) 159 | } 160 | } 161 | 162 | /** Set the config specific values that can be modified everywhere any time, just to trac some ofher states */ 163 | export function setGlobalConfigOption< 164 | K extends keyof AdditionalGlobalOptions, 165 | V extends AdditionalGlobalOptions[K], 166 | >(option: K, value: V) { 167 | var success = false 168 | if ( 169 | !hasNullable(option, value) && 170 | Object.hasOwn(globalOptions, option) && 171 | sameType(globalOptions[option].value, value) 172 | ) { 173 | globalOptions[option].value = value 174 | success = true 175 | } 176 | return success 177 | } 178 | 179 | /** Set the config specific values that can be modified everywhere any time, just to trac some ofher states */ 180 | export function getGlobalConfigOption( 181 | option: K, 182 | ): AdditionalGlobalOptions[K] { 183 | return globalOptions[option].value 184 | } 185 | export function setGlobalOption< 186 | K extends keyof GlobalOptionsValues, 187 | V extends GlobalOptionsValues[K], 188 | P extends GlobalOptionPropPriorityAll, 189 | >(option: K, value: V, priority: P) { 190 | var success = false 191 | if ( 192 | globalOptions.state === "configuring" && 193 | !hasNullable(option, value, priority) && 194 | Object.hasOwn(globalOptions, option) && 195 | comparePriorities(globalOptions[option].source, priority) < 0 && 196 | sameType(globalOptions[option].value, value) 197 | ) { 198 | globalOptions[option].value = value 199 | globalOptions[option].source = normalizePriority(priority, "string") 200 | success = true 201 | } 202 | return success 203 | } 204 | type GetGlobalOptionValue = GlobalOptions[O]["value"] 205 | 206 | export function getVersion() { 207 | return globalOptions.version 208 | } 209 | 210 | export function getGlobalOption( 211 | option: O, 212 | ): GetGlobalOptionValue { 213 | return globalOptions[option].value 214 | } 215 | 216 | export function resetGlobalOptionsHandler(prepareState = true) { 217 | prepareState && setState("configuring") 218 | resetGlobalOptions() 219 | prepareState && setState("ready") 220 | } 221 | 222 | /** Resets the global options */ 223 | function resetGlobalOptions(): void 224 | /** You shouldn't use this overload */ 225 | function resetGlobalOptions(root: Record, objToSet: Record): void 226 | function resetGlobalOptions(root?: Record, objToSet?: Record) { 227 | root ??= globalOptionsDefault 228 | objToSet ??= globalOptions 229 | for (var [key, value] of Object.entries(root)) 230 | if (typeof value === "object") resetGlobalOptions(value, objToSet[key]) 231 | else { 232 | objToSet[key].value = value 233 | objToSet[key].source = "original" 234 | } 235 | } 236 | type FilteredObjectProperties = FilterObjectProps> 237 | type CompareTable = { 238 | 0: { 0: 0; 1: -1; 2: -1 } 239 | 1: { 0: 1; 1: 0; 2: -1 } 240 | 2: { 0: 1; 1: 1; 2: 0 } 241 | } 242 | 243 | type ComparePriorities< 244 | P1 extends GlobalOptionPropPriorityAll, 245 | P2 extends GlobalOptionPropPriorityAll, 246 | > = CompareTable[GetPriotiry][GetPriotiry] 247 | 248 | /** Compares 2 priorities. `First < Second = -1`, `First === Second = 0`, `First > Second = 1` 249 | * @returns Comparison result `-1 | 0 | 1` 250 | * */ 251 | export function comparePriorities< 252 | P1 extends GlobalOptionPropPriorityAll, 253 | P2 extends GlobalOptionPropPriorityAll, 254 | >(first: P1, second: P2): ComparePriorities { 255 | var p1: GlobalOptionPropPriorityLevel = normalizePriority(first), 256 | p2: GlobalOptionPropPriorityLevel = normalizePriority(second) 257 | return (p1 === p2 ? 0 : p1 < p2 ? -1 : p1 > p2 ? 1 : 0) as ComparePriorities 258 | } 259 | 260 | type GetPriotiry< 261 | P extends GlobalOptionPropPriorityAll, 262 | T extends "string" | "number" = "number", 263 | > = T extends "string" 264 | ? P extends string 265 | ? P 266 | : P extends number 267 | ? (typeof priorityLevel)[P] 268 | : never 269 | : T extends "number" 270 | ? P extends string 271 | ? IndexOf 272 | : P extends number 273 | ? P 274 | : never 275 | : never 276 | 277 | /** 278 | * @param type what value to return: `"number"` 279 | * @param priority what priority is coming in to test and get the value represented: `0`|`1`|`2`|`"original"`|`"config"`|`"arguments"` 280 | * @returns represented value of the given priority in provided `type` 281 | * "number" = `0`|`1`|`2` 282 | */ 283 | export function normalizePriority

( 284 | priority: P, 285 | ): GetPriotiry 286 | /** 287 | * @param priority what priority is coming in to test and get the value represented: `0`|`1`|`2`|`"original"`|`"config"`|`"arguments"` 288 | * @param type what value to return: `"string"`|`"number"` 289 | * @returns represented value of the given priority in provided `type` 290 | * "string" = `"original"`|`"config"`|`"arguments"` 291 | * "number" = `0`|`1`|`2` 292 | */ 293 | export function normalizePriority< 294 | P extends GlobalOptionPropPriorityAll, 295 | Type extends "string" | "number", 296 | >(priority: P, type: Type): GetPriotiry 297 | /** 298 | * @param priority what priority is coming in to test and get the value represented: `0`|`1`|`2`|`"original"`|`"config"`|`"arguments"` 299 | * @param type what value to return: `"string"` 300 | * @returns string represented value of the given priority (`"original"`|`"config"`|`"arguments"`) 301 | */ 302 | export function normalizePriority

( 303 | priority: P, 304 | type: Type, 305 | ): GetPriotiry 306 | /** 307 | * @param priority what priority is coming in to test and get the value represented: `0`|`1`|`2`|`"original"`|`"config"`|`"arguments"` 308 | * @param type what value to return: `"number"` 309 | * @returns number represented value of the given priority (`0`|`1`|`2`) 310 | */ 311 | export function normalizePriority

( 312 | priority: P, 313 | type: Type, 314 | ): GetPriotiry 315 | 316 | export function normalizePriority< 317 | P extends GlobalOptionPropPriorityAll, 318 | Type extends "string" | "number", 319 | >(priority: P, type = "number" as Type): GetPriotiry { 320 | return ( 321 | hasNullable(type, priority) 322 | ? "original" 323 | : type === "string" 324 | ? typeof priority === "string" 325 | ? priority 326 | : priorityLevel[typeof priority === "number" ? priority : 0] 327 | : type === "number" 328 | ? typeof priority === "string" 329 | ? priorityLevel.indexOf(priority) 330 | : typeof priority === "number" 331 | ? priority 332 | : 0 333 | : "original" 334 | ) as GetPriotiry 335 | } 336 | 337 | export function getPriorityForOption( 338 | option: O, 339 | ): GlobalOptionPropPriorityString { 340 | return globalOptions[option]?.source ?? "original" 341 | } 342 | 343 | /** Creates a setter for global properties on object properties */ 344 | function createGlobalOptionObjectSetter(object: O) { 345 | /** Sets the global object property, and returns a boolean value representing status of the process (true=`success`, false=`failure`) */ 346 | return function < 347 | K extends keyof FilteredObjectProperties[O], 348 | V extends FilteredObjectProperties[O][K] extends GlobalOptionProp 349 | ? FilteredObjectProperties[O][K]["value"] 350 | : never, 351 | P extends GlobalOptionPropPriorityAll, 352 | >(option: K, value: V, priority: P) { 353 | var success = false 354 | if ( 355 | !hasNullable(option, value, priority) && 356 | Object.hasOwn(globalOptions[object], option) && 357 | comparePriorities( 358 | (globalOptions[object]?.[option] as GlobalOptionProp).value, 359 | priority, 360 | ) < 0 && 361 | sameType((globalOptions[object]?.[option] as GlobalOptionProp).value, value) 362 | ) { 363 | var optionObj = globalOptions[object][option] as GlobalOptionProp 364 | optionObj.value = value 365 | optionObj.source = normalizePriority(priority, "string") 366 | success = true 367 | } 368 | return success 369 | } 370 | } 371 | 372 | /** Creates a getter for global properties on object properties */ 373 | function createGlobalOptionObjectGetter(object: O) { 374 | return function

( 375 | option: P, 376 | ): GlobalOptions[O][P] extends GlobalOptionProp ? GlobalOptions[O][P]["value"] : never { 377 | return (globalOptions[object][option] as GlobalOptionProp).value 378 | } 379 | } 380 | 381 | export function getGlobalObject( 382 | object: OBJ, 383 | ): Readonly { 384 | return globalOptions[object] 385 | } 386 | 387 | /** Universal function for setting global options on object options */ 388 | export function setGlobalObjectOption< 389 | OBJ extends keyof FilteredObjectProperties, 390 | K extends keyof FilteredObjectProperties[OBJ], 391 | V extends FilteredObjectProperties[OBJ][K] extends GlobalOptionProp 392 | ? FilteredObjectProperties[OBJ][K]["value"] 393 | : never, 394 | P extends GlobalOptionPropPriorityAll, 395 | >(object: OBJ, key: K, value: V, priority: P) { 396 | var success = false 397 | if ( 398 | !hasNullable(object, key, value, priority) && 399 | Object.hasOwn(globalOptions, object) && 400 | typeof globalOptions[object] === "object" && 401 | !Array.isArray(globalOptions[object]) && 402 | Object.hasOwn(globalOptions[object], key) && 403 | sameType((globalOptions[object][key] as GlobalOptionProp).value, value) && 404 | comparePriorities((globalOptions[object][key] as GlobalOptionProp).source, priority) < 0 405 | ) { 406 | var obj = globalOptions[object][key] as GlobalOptionProp 407 | obj.value = value 408 | obj.source = normalizePriority(priority, "string") 409 | success = true 410 | } 411 | return success 412 | } 413 | 414 | export var setServerOption = createGlobalOptionObjectSetter("server"), 415 | getServerOption = createGlobalOptionObjectGetter("server"), 416 | setLogOption = createGlobalOptionObjectSetter("logOptions"), 417 | getLogOption = createGlobalOptionObjectGetter("logOptions") 418 | 419 | var SIGTSTP = () => { 420 | process.removeListener("SIGTSTP", SIGTSTP) 421 | process.stdout.write("\x1b[?1049l") 422 | if (getGlobalConfigOption("inAlternate")) { 423 | exitAlternateBuffer() 424 | } 425 | process.kill(process.pid, "SIGTSTP") 426 | } 427 | 428 | process.on("SIGTSTP", SIGTSTP) 429 | -------------------------------------------------------------------------------- /src/flags.ts: -------------------------------------------------------------------------------- 1 | fileNotForRunning() 2 | 3 | import { 4 | error, 5 | fileNotForRunning, 6 | formatDate, 7 | green, 8 | isStringNumber, 9 | memoize, 10 | normalizeLogOption, 11 | parseLogOption, 12 | print, 13 | } from "./functions" 14 | import { 15 | getGlobalOption, 16 | getServerOption, 17 | getVersion, 18 | globalOptionsDefault, 19 | setGlobalConfigOption, 20 | setGlobalOption, 21 | setLogOption, 22 | setServerOption, 23 | setState, 24 | } from "./config" 25 | import printHelp, { helpFlags, ss } from "./help" 26 | import type { Flag, FlagValueArray, FlagValueUnion, LogLevel } from "./types" 27 | import { genConfig } from "./genConfig" 28 | 29 | var flagRegex = /^-([a-zA-Z]|-[a-z\-]+)$/, 30 | readHelpPage = 31 | `Run '${green("bun run help")}' or '${green("bun start -h")}' to read the help page to see the correct flag usage` as const, 32 | isFlagError = false, 33 | isError = false, 34 | exit = false, 35 | printMsg = "", 36 | errorMsg = "", 37 | flagErrors = { 38 | noProp(flag: string, args?: string | string[] | readonly string[]) { 39 | errorLog( 40 | `Expected argument for flag '${green(flag)}', but argument was not provided${formatParamsForError(args)}`, 41 | ) 42 | isFlagError ||= true 43 | }, 44 | nextFlag(flag: string) { 45 | errorLog(`Expected argument for flag '${green(flag)}', but next flag was passed`) 46 | 47 | isFlagError ||= true 48 | }, 49 | notRecognized(flag: string) { 50 | errorLog(`Flag '${green(flag)}' is not recognized as valid`) 51 | isFlagError ||= true 52 | }, 53 | incorrectParametersOrder(flag: string, values: FlagValueArray) { 54 | errorLog( 55 | `The parameters of the flag '${green(flag)}' must comply with the ideology: "required" -> "optional". Found: '${values}'`, 56 | ) 57 | isFlagError ||= true 58 | }, 59 | wrongValueType(flag: string) { 60 | errorLog( 61 | `Type of 'value' property on flag '${green(flag)}' is not compatitable with expected type. Consider reading '${green("./src/types")}' file under the '${green("Flag")}' type`, 62 | ) 63 | isFlagError ||= true 64 | }, 65 | } 66 | 67 | /** dv = Default Value, for sure 68 | * returns `(default=${value})` string with space around equality sign depending on screen size 69 | */ 70 | function dv(value: any) { 71 | return `(default${ss("", " ")}=${ss("", " ")}${value})` 72 | } 73 | 74 | function formatParamsForError(args?: string | string[] | readonly string[]) { 75 | var result = "" 76 | 77 | if (args !== undefined) { 78 | if (Array.isArray(args)) result = `. Arguments: ${args.map(green).join(" ")}` 79 | else result = `. Argument: ${green(args)}` 80 | } 81 | 82 | return result 83 | } 84 | 85 | export var flags: Map> = new Map(), 86 | flagObjects: Flag[] = [], 87 | addFlag = { 88 | empty(flag: Flag<"none">) { 89 | addFlagHandler(flag) 90 | }, 91 | optional(flag: Flag<"optional">) { 92 | addFlagHandler(flag as Flag) 93 | }, 94 | required(flag: Flag<"required">) { 95 | addFlagHandler(flag as Flag) 96 | }, 97 | multipleParams(flag: Flag) { 98 | addFlagHandler(flag as unknown as Flag) 99 | }, 100 | } 101 | 102 | function addFlagHandler(flag: Flag) { 103 | if (flag.short) flags.set(`-${flag.short}`, flag) 104 | flags.set(`--${flag.long}`, flag) 105 | flagObjects.push(flag) 106 | } 107 | 108 | export function setupFlagHandlers() { 109 | if (flags.size !== 0) return 110 | addFlag.optional({ 111 | short: "h", 112 | long: "help", 113 | value: "optional", 114 | parameter: "[section]", 115 | description: `Display this help message (${green("bun run help")}) or a spesific section. See ${green("SECTIONS")}`, 116 | extendedDescription: `Display this help message (alias is also '${green("bun run help")}') or a spesific section. Available sections: ${helpFlags.map(green).join(ss(",", ", "))}`, 117 | handler(value) { 118 | addText(printHelp(value as (typeof helpFlags)[number])) 119 | exit = true 120 | }, 121 | }) 122 | 123 | addFlag.empty({ 124 | short: "v", 125 | long: "version", 126 | value: "none", 127 | parameter: "", 128 | description: "Display version and exit", 129 | extendedDescription: `Display version and exit. Current version: ${green(getVersion())}`, 130 | handler() { 131 | print(getVersion()) 132 | exit = true 133 | }, 134 | }) 135 | 136 | addFlag.empty({ 137 | short: "q", 138 | long: "quiet", 139 | value: "none", 140 | parameter: "", 141 | description: "Run the server without any output", 142 | extendedDescription: `Run the server without any output, including all errors on startup ${dv(green(getGlobalOption("quiet")))}`, 143 | handler() { 144 | setGlobalOption("quiet", true, 2) 145 | }, 146 | }) 147 | 148 | addFlag.required({ 149 | short: "l", 150 | long: "log", 151 | value: "required", 152 | parameter: "", 153 | description: `Log level (0-5) or custom features to log. See ${green("LOG OPTIONS")}`, 154 | extendedDescription: `Log level (0-5) or custom features to log. See ${green("LOG OPTIONS")} section. (default values: ${Object.entries( 155 | globalOptionsDefault.logOptions, 156 | ) 157 | .reduce( 158 | (a, [k, v]) => (a.push(`${green(k)}${ss("", " ")}=${ss("", " ")}${green(v)}`), a), 159 | [], 160 | ) 161 | .join(", ")})`, 162 | handler(value) { 163 | if (/^0$/.test(value)) { 164 | for (var i = 1; i < 6; i++) 165 | setLogOption(normalizeLogOption(i as LogLevel)!, false, 2) 166 | } else { 167 | var parsed = parseLogOption(value), 168 | seeLogOptions = memoize( 169 | addError, 170 | `Read '${green("log_options")}' help page section by running: ${green("bun run help log_options")}`, 171 | ) 172 | if (Array.isArray(parsed)) { 173 | var options = value.split(/,/g) 174 | for (var [idx, val] of Object.entries(parsed)) { 175 | if (val === undefined) { 176 | errorLog( 177 | `Log option '${green(options[idx as unknown as number])}' is not recognized as valid`, 178 | ) 179 | seeLogOptions.call 180 | } 181 | } 182 | } else if (typeof parsed === "object") { 183 | if ("duplicate" in parsed) 184 | errorLog( 185 | `Log option '${green(parsed.duplicate)}' appeared more than once. Why should you do that?`, 186 | ) 187 | else 188 | errorLog( 189 | `Log option '${green(parsed.notFound)}' is not recognized as valid`, 190 | ) 191 | } else if (parsed === -1) { 192 | errorLog(`Log level '${green(value)}' is not in the range: ${green("1-5")}`) 193 | seeLogOptions.call 194 | } 195 | } 196 | }, 197 | }) 198 | 199 | addFlag.empty({ 200 | short: "L", 201 | long: "log-features", 202 | value: "none", 203 | parameter: "", 204 | description: "Log logging features on startup/restart", 205 | extendedDescription: `Log logging features on startup/restart ${dv(green(getGlobalOption("logFeatures")))}`, 206 | handler() { 207 | setGlobalOption("logFeatures", true, 2) 208 | }, 209 | }) 210 | 211 | addFlag.required({ 212 | long: "cache-time", 213 | value: "required", 214 | parameter: "", 215 | description: `Cache time in miliseconds`, 216 | extendedDescription: `Cache time in miliseconds (default${ss("", " ")}=${ss("", " ")}${green(getGlobalOption("cacheTime"))}${ss("", " ")}ms, ${green(getGlobalOption("cacheTime") / 60_000)}${ss("", " ")}mins)`, 217 | handler(value) { 218 | if (isStringNumber(value)) setGlobalOption("cacheTime", +value, 2) 219 | else { 220 | errorLog(`Flag '${green("c")}' accepted a non-numeric parameter`) 221 | exit ||= true 222 | } 223 | }, 224 | }) 225 | 226 | addFlag.required({ 227 | long: "cache-type", 228 | value: "required", 229 | parameter: "", 230 | description: `What kind of cache to use`, 231 | extendedDescription: `What kind of cache to use (read '${green("cache.md")}' file for more) ${dv(green(getGlobalOption("cacheType")))}`, 232 | handler(value) { 233 | errorLog(`Flag --cache-type is not implemented. Value is: '${value}'`) 234 | exit ||= true 235 | }, 236 | }) 237 | 238 | addFlag.required({ 239 | short: "c", 240 | long: "cache", 241 | value: "required", 242 | parameter: "", 243 | description: "Enable permanent cache, or disable it completely", 244 | extendedDescription: `Enable permanent cache, or disable it completely. (default: cache${ss("", " ")}=${ss("", " ")}${green(getGlobalOption("cache"))}, permanentCache${ss("", " ")}=${ss("", " ")}${green(getGlobalOption("permanentCache"))})`, 245 | handler(value) { 246 | if (value.match(/y|yes/i)) setGlobalOption("permanentCache", true, 2) 247 | else if (value.match(/n|no/i)) setGlobalOption("cache", false, 2) 248 | else { 249 | errorLog(`Flag --cache accepts only following arguments: y, yes, n, no`) 250 | exit ||= true 251 | } 252 | }, 253 | }) 254 | 255 | addFlag.empty({ 256 | short: "A", 257 | long: "no-avatars", 258 | value: "none", 259 | parameter: "", 260 | description: "Do not store avatars", 261 | extendedDescription: `Do not store avatars ${dv(green(getGlobalOption("avatars")))}`, 262 | handler() { 263 | setGlobalOption("avatars", false, 2) 264 | }, 265 | }) 266 | 267 | addFlag.empty({ 268 | short: "W", 269 | long: "no-warnings", 270 | value: "none", 271 | parameter: "", 272 | description: "Do not output any warnings", 273 | extendedDescription: `Do not output any warnings. This includes all warnings during runtime, excluding parsing of command line arguments ${dv(green(getGlobalOption("warnings")))}`, 274 | handler() { 275 | setGlobalOption("warnings", false, 2) 276 | }, 277 | }) 278 | 279 | addFlag.empty({ 280 | short: "E", 281 | long: "no-errors", 282 | value: "none", 283 | parameter: "", 284 | description: "Do not output any errors", 285 | extendedDescription: `Do not output any errors. This includes all runtime errors, excluding incorrect project startup ${dv(green(getGlobalOption("errors")))}`, 286 | handler() { 287 | setGlobalOption("errors", false, 2) 288 | }, 289 | }) 290 | 291 | addFlag.optional({ 292 | short: "t", 293 | long: "timestamps", 294 | value: "optional", 295 | parameter: "[format]", 296 | description: `Include timestamps in all logging stuff, and optionaly pass the format. See ${green("TIMESTAMP FORMAT")}`, 297 | extendedDescription: `Include timestamps in all logging stuff ${dv(green(getGlobalOption("timestamps")))}, and optionaly pass the format how to format timestamp. See ${green("TIMESTAMP FORMAT")} ${dv(green(`'${getGlobalOption("timestampFormat")}'`))}`, 298 | handler(value) { 299 | setGlobalOption("timestamps", true, 2) 300 | if (value) { 301 | setGlobalOption("timestampFormat", value, 2) 302 | } 303 | }, 304 | }) 305 | 306 | addFlag.empty({ 307 | short: "g", 308 | long: "gen-config", 309 | value: "none", 310 | parameter: "", 311 | description: `Generate default config file`, 312 | extendedDescription: `Generate default config file in the root of the project with default values`, 313 | handler() { 314 | genConfig() 315 | addText(`Generated '${green("config.toml")}' configuration file`) 316 | exit ||= true 317 | }, 318 | }) 319 | 320 | addFlag.empty({ 321 | short: "O", 322 | long: "omit-config", 323 | value: "none", 324 | parameter: "", 325 | description: "Omit the configuration file", 326 | extendedDescription: 327 | "Omit the configuration file (don't load any value from it to the global options for runtime)", 328 | handler() { 329 | setGlobalConfigOption("useConfig", false) 330 | }, 331 | }) 332 | 333 | addFlag.required({ 334 | short: "P", 335 | long: "port", 336 | value: "required", 337 | parameter: "", 338 | description: "Port on which the server will be running", 339 | extendedDescription: `Port on which the server will be running ${dv(green(getServerOption("port")))}`, 340 | handler(value) { 341 | if (isStringNumber(value)) { 342 | setServerOption("port", +value, 2) 343 | } else { 344 | errorLog(`Flag -p accepted non-numeric value`) 345 | } 346 | }, 347 | }) 348 | 349 | addFlag.required({ 350 | short: "H", 351 | long: "host", 352 | value: "required", 353 | parameter: "", 354 | description: "Host on which the server will be running", 355 | extendedDescription: `Host on which the server will be running ${dv(green(`'${getServerOption("host")}'`))}`, 356 | handler(value) { 357 | errorLog(`Flag -H, --host is not implemented. Value is: '${value}'`) 358 | }, 359 | }) 360 | 361 | // test flag 362 | addFlag.empty({ 363 | short: "X", 364 | long: "XXX", 365 | value: "none", 366 | parameter: "", 367 | description: "test, nothing spesial", 368 | extendedDescription: "", 369 | handler() { 370 | print( 371 | green(`[${formatDate(new Date(), getGlobalOption("timestampFormat"))}]`) + 372 | "handler called", 373 | ) 374 | }, 375 | }) 376 | } 377 | 378 | function addText(text: any) { 379 | printMsg += `${!!printMsg ? "\n" : ""}${text}` 380 | } 381 | 382 | function addError(text: any) { 383 | errorMsg += `${!!errorMsg ? "\n" : ""}${text}` 384 | } 385 | 386 | function errorLog(text: any) { 387 | addError(error(text)) 388 | isError ||= true 389 | } 390 | 391 | function checkFlagParamsOrder(params: FlagValueArray) { 392 | var current = params[0] 393 | if (current === undefined) return false 394 | for (var i = 1; i < params.length; i++) { 395 | if (current === "optional") { 396 | if (params[i] === "required") return false 397 | } else if (params[i] === "optional") current = "optional" 398 | } 399 | return true 400 | } 401 | 402 | /** Process command line arguments 403 | * 404 | * don't look at implementation... 405 | */ 406 | export function processFlags(argList: string[]) { 407 | setState("configuring") 408 | setupFlagHandlers() 409 | 410 | var flagHandler: Flag, 411 | nextArgument: string, 412 | flagArguments: string[] = [], 413 | argument: string, 414 | flag: string, 415 | multipleParamOk = true, 416 | i = 0, 417 | j = 0 418 | 419 | // 💀, but it's kinda fast, to be fair, and runtime-safe :) 420 | for (i = 0; i < argList.length; i++) { 421 | argument = argList[i] 422 | if (/^-[a-zA-Z]{2,}$/.test(argument)) { 423 | // short flag list 424 | for (flag of argument.slice(1)) { 425 | flag = `-${flag}` 426 | if (flags.has(flag)) { 427 | flagHandler = flags.get(flag)! 428 | if (flagHandler.value !== "none") { 429 | if (typeof flagHandler.value === "string") { 430 | if (flagHandler.value === "required") 431 | flagErrors.noProp(flag, flagHandler.parameter) 432 | else flagHandler.handler() 433 | } else if (Array.isArray(flagHandler.value)) { 434 | if (!checkFlagParamsOrder(flagHandler.value)) 435 | flagErrors.incorrectParametersOrder(flag, flagHandler.value) 436 | else if (flagHandler.value.every((e) => e === "optional")) 437 | flagHandler.handler() 438 | else flagErrors.noProp(flag, flagHandler.parameter) 439 | } else flagErrors.wrongValueType(flag) 440 | } else flagHandler.handler() 441 | } else flagErrors.notRecognized(flag) 442 | } 443 | } else if (flagRegex.test(argument)) { 444 | // regular flag (short/long) 445 | flag = argument 446 | if (flags.has(flag)) { 447 | flagHandler = flags.get(flag)! 448 | if (Array.isArray(flagHandler.value)) { 449 | if (!checkFlagParamsOrder(flagHandler.value)) 450 | flagErrors.incorrectParametersOrder(flag, flagHandler.value) 451 | else { 452 | multipleParamOk = true 453 | for (j = 0; j < flagHandler.value.length; j++) { 454 | nextArgument = argList[i + j + 1] 455 | if (nextArgument === undefined) { 456 | if (flagHandler.value[j] === "required") { 457 | flagErrors.noProp(flag, flagHandler.parameter) 458 | multipleParamOk = false 459 | break 460 | } 461 | } else if (flagRegex.test(nextArgument)) { 462 | if (flagHandler.value[j] === "required") { 463 | flagErrors.nextFlag(flag) 464 | multipleParamOk = false 465 | break 466 | } 467 | } else flagArguments.push(nextArgument) 468 | } 469 | if (multipleParamOk) flagHandler.handler(...flagArguments) 470 | i += flagArguments.length 471 | if (flagArguments.length) flagArguments = [] 472 | } 473 | } else { 474 | if (flagHandler.value === "required") { 475 | nextArgument = argList[i + 1] 476 | if (nextArgument === undefined) 477 | flagErrors.noProp(flag, flagHandler.parameter) 478 | else { 479 | if (flagRegex.test(nextArgument)) flagErrors.nextFlag(flag) 480 | else flagHandler.handler(nextArgument), i++ 481 | } 482 | } else if (flagHandler.value === "optional") { 483 | nextArgument = argList[i + 1] 484 | if (nextArgument === undefined) flagHandler.handler() 485 | else if (flagRegex.test(nextArgument)) flagHandler.handler() 486 | else flagHandler.handler(nextArgument), i++ 487 | } else flagHandler.handler() 488 | } 489 | } else flagErrors.notRecognized(argument) 490 | } else flagErrors.notRecognized(argument) 491 | } 492 | 493 | if (isError) print(errorMsg) 494 | 495 | if (isFlagError) print(readHelpPage) 496 | else if (!isError && !isFlagError && exit) print(printMsg) 497 | 498 | if (isError || isFlagError || exit) process.exit() 499 | setState("ready") 500 | return printMsg 501 | } 502 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import { join } from "path" 3 | import { getGlobalOption, ROOT_PATH } from "./config" 4 | import { 5 | compress, 6 | decompress, 7 | error, 8 | isCurrentCacheType, 9 | log, 10 | updateObject, 11 | verboseError, 12 | } from "./functions" 13 | import { generatePetPet } from "./petpet" 14 | import type { 15 | AvatarQueue, 16 | Avatars, 17 | PetPetType, 18 | PetPetQueue, 19 | PetPets, 20 | AvatarType, 21 | Cache, 22 | Hash, 23 | Stats, 24 | } from "./types" 25 | import { fetchAvatarDiscord } from "./avatars" 26 | 27 | type BufferType = "gif" | "avatar" 28 | 29 | var petpets: PetPets = new Map(), 30 | avatars: Avatars = new Map(), 31 | petpetQueue: PetPetQueue = new Map(), 32 | avatarQueue: AvatarQueue = new Map(), 33 | hashRegex = /\d+--?\d+x-?\d+--?\d+x-?\d+-\d?\d--?\d+/g, 34 | idRegex = /\d+/g, 35 | // btw, "as `template string literal`" is optional. It's just for indication 36 | cacheDir = join(ROOT_PATH, ".cache") as `${string}/.cache`, 37 | gifCacheDir = join(cacheDir, "gif") as `${string}/.cache/gif`, 38 | avatarCacheDir = join(cacheDir, "avatar") as `${string}/.cache/avatar` 39 | 40 | export var requestTime = { 41 | fromScratch: [] as number[], 42 | fromCache: [] as number[], 43 | }, 44 | /** Object for configuring queues and cache in code and filesystem */ 45 | cache: Cache = { 46 | gif: { 47 | queue: { 48 | add(hash, avatar, params) { 49 | var promise = generatePetPet(avatar, params) 50 | promise.then((buffer) => { 51 | addPetPetToCache(new PetPet(hash, buffer)) 52 | petpetQueue.delete(hash) 53 | }) 54 | petpetQueue.set(hash, promise) 55 | return promise 56 | }, 57 | addWithAvatar(hash, params, size) { 58 | var id = hash.match(/^\d+/)?.[0] ?? "1234", 59 | promise = new Promise((resolve) => { 60 | cache.avatar.queue.add(id, size).then((png) => { 61 | generatePetPet(png, params).then((gif) => { 62 | addPetPetToCache(new PetPet(hash, gif)) 63 | petpetQueue.delete(hash) 64 | resolve(gif) 65 | }) 66 | }) 67 | }) 68 | petpetQueue.set(hash, promise) 69 | return promise 70 | }, 71 | has(hash) { 72 | return petpetQueue.has(hash) 73 | }, 74 | get(hash) { 75 | return petpetQueue.get(hash) 76 | }, 77 | }, 78 | code: { 79 | set(petpet) { 80 | return getGlobalOption("cache") 81 | ? (petpets.has(petpet.hash) 82 | ? updateObject(petpets.get(petpet.hash)!, petpet) 83 | : petpets.set(petpet.hash, petpet), 84 | true) 85 | : false 86 | }, 87 | get(hash) { 88 | return getGlobalOption("cache") ? petpets.get(hash) : undefined 89 | }, 90 | has(hash) { 91 | return getGlobalOption("cache") ? petpets.has(hash) : false 92 | }, 93 | remove(hash) { 94 | return getGlobalOption("cache") ? petpets.delete(hash) : false 95 | }, 96 | checkCacheTime(hash) { 97 | return getGlobalOption("cache") 98 | ? checkCacheTime(petpets.has(hash) ? petpets.get(hash)!?.lastSeen : 0) 99 | : false 100 | }, 101 | get hashes() { 102 | return getGlobalOption("cache") ? Array.from(petpets.keys()) : [] 103 | }, 104 | clear() { 105 | var amount = getGlobalOption("cache") ? petpets.size : 0 106 | petpets.clear() 107 | return amount 108 | }, 109 | }, 110 | fs: { 111 | async set(petpet) { 112 | if (!getGlobalOption("cache")) return false 113 | checkAndCreateCacheType("gif") 114 | var json = JSON.stringify( 115 | { 116 | id: petpet.id, 117 | hash: petpet.hash, 118 | lastSeen: petpet.lastSeen, 119 | }, 120 | null, 121 | "\t", 122 | ) 123 | return new Promise((resolve) => { 124 | Bun.write(join(gifCacheDir, `${petpet.hash}.json`), json).then( 125 | () => { 126 | Bun.write(join(gifCacheDir, `${petpet.hash}.gif`), petpet.gif).then( 127 | () => resolve(true), 128 | 129 | (e) => { 130 | log( 131 | "error", 132 | error("Error while tryingto create .gif file:\n"), 133 | e, 134 | ) 135 | resolve(false) 136 | }, 137 | ) 138 | }, 139 | () => resolve(false), 140 | ) 141 | }) 142 | }, 143 | async get(hash) { 144 | if (!getGlobalOption("cache")) return undefined 145 | checkAndCreateCacheType("gif") 146 | var file = Bun.file(join(gifCacheDir, `${hash}.gif`)) 147 | return file 148 | .exists() 149 | .then((exists) => (exists ? file.bytes() : undefined)) 150 | .catch(() => undefined) 151 | }, 152 | has(hash) { 153 | if (!getGlobalOption("cache")) return false 154 | checkAndCreateCacheType("gif") 155 | return fs.existsSync(join(gifCacheDir, `${hash}.gif`)) 156 | }, 157 | remove(hash) { 158 | if (!getGlobalOption("cache")) return false 159 | checkAndCreateCacheType("gif") 160 | try { 161 | fs.rmSync(join(gifCacheDir, `${hash}.json`)) 162 | fs.rmSync(join(gifCacheDir, `${hash}.gif`)) 163 | return true 164 | } catch { 165 | return false 166 | } 167 | }, 168 | async checkCacheTime(hash) { 169 | if (!getGlobalOption("cache")) return false 170 | checkAndCreateCacheType("gif") 171 | return new Promise(async (resolve) => { 172 | var file = Bun.file( 173 | join(gifCacheDir, `${hash.match(/^\d+(?=-)/)}_${hash}.json`), 174 | ), 175 | rf = () => resolve(false) 176 | 177 | return file 178 | .exists() 179 | .then( 180 | (exists) => 181 | exists 182 | ? file 183 | .json() 184 | .then( 185 | (data) => 186 | resolve( 187 | checkCacheTime(data?.lastSeen ?? 0), 188 | ), 189 | rf, 190 | ) 191 | : resolve(false), 192 | rf, 193 | ) 194 | }) 195 | }, 196 | checkSafe(hash) { 197 | checkAndCreateCacheType("gif") 198 | return getGlobalOption("cache") 199 | ? fs.readdirSync(gifCacheDir).join(",").match(new RegExp(hash, "g")) 200 | ?.length === 2 201 | : false 202 | }, 203 | get hashes() { 204 | checkAndCreateCacheType("gif") 205 | var result: string[] = [] 206 | if (!getGlobalOption("cache")) return result as Array 207 | return fs.readdirSync(gifCacheDir).reduce((a, filename) => { 208 | var hash = filename.match(hashRegex)?.[0] 209 | if (hash) if (!a.includes(hash)) a.push(hash) 210 | return a 211 | }, result) as Array 212 | }, 213 | clear() { 214 | checkAndCreateCacheType("gif") 215 | var amount = getGlobalOption("cache") 216 | ? fs.readdirSync(gifCacheDir).reduce((a, f) => { 217 | var filename = f.match(hashRegex)?.[0] as Hash | undefined 218 | if (filename) if (!a.has(filename)) a.add(filename) 219 | 220 | return a 221 | }, new Set()).size 222 | : 0 223 | // bruh, why there are no safe way of doing that? 224 | try { 225 | fs.rmdirSync(gifCacheDir) 226 | } catch {} 227 | try { 228 | fs.mkdirSync(gifCacheDir) 229 | } catch {} 230 | 231 | return amount 232 | }, 233 | }, 234 | }, 235 | avatar: { 236 | queue: { 237 | add(id: string, size?: number) { 238 | var promise = new Promise((resolve, reject) => { 239 | fetchAvatarDiscord(id, size).then( 240 | (result) => resolve(result), 241 | (e) => { 242 | verboseError( 243 | e, 244 | error("Error while fetching avatar:\n"), 245 | error("Error while fetching avatar"), 246 | ) 247 | reject(e) 248 | }, 249 | ) 250 | }) 251 | promise.then( 252 | (png) => { 253 | addAvatarToCache(new Avatar(id, png)) 254 | avatarQueue.delete(id) 255 | }, 256 | () => {}, 257 | ) 258 | avatarQueue.set(id, promise) 259 | return promise 260 | }, 261 | has(id) { 262 | return avatarQueue.has(id) 263 | }, 264 | get(id) { 265 | return avatarQueue.get(id) 266 | }, 267 | }, 268 | code: { 269 | set(avatar) { 270 | return getGlobalOption("avatars") 271 | ? (avatars.set(avatar.id, avatar), true) 272 | : false 273 | }, 274 | get(id) { 275 | return getGlobalOption("avatars") ? avatars.get(id) : undefined 276 | }, 277 | has(id) { 278 | return getGlobalOption("avatars") ? avatars.has(id) : false 279 | }, 280 | remove(id) { 281 | return getGlobalOption("avatars") ? avatars.delete(id) : false 282 | }, 283 | checkDependencies(id) { 284 | if (!getGlobalOption("avatars")) return false 285 | return petpets.values().some((petpet) => petpet.id === id) 286 | }, 287 | get IDs() { 288 | return getGlobalOption("avatars") ? Array.from(avatars.keys()) : [] 289 | }, 290 | clear() { 291 | var amount = getGlobalOption("cache") ? avatars.size : 0 292 | avatars.clear() 293 | return amount 294 | }, 295 | }, 296 | fs: { 297 | async set(avatar) { 298 | if (!getGlobalOption("avatars")) return false 299 | checkAndCreateCacheType("avatar") 300 | return Bun.write( 301 | join(avatarCacheDir, `${avatar.id}.png`), 302 | new Uint8Array(avatar.avatar), 303 | ).then( 304 | () => true, 305 | () => false, 306 | ) 307 | }, 308 | async get(id) { 309 | if (!getGlobalOption("avatars")) return undefined 310 | checkAndCreateCacheType("avatar") 311 | var file = Bun.file(join(avatarCacheDir, `${id}.png`)) 312 | return file 313 | .exists() 314 | .then((exists) => (exists ? file.bytes() : undefined)) 315 | .catch(() => undefined) 316 | }, 317 | has(id) { 318 | if (!getGlobalOption("avatars")) return false 319 | checkAndCreateCacheType("avatar") 320 | return fs.existsSync(join(avatarCacheDir, `${id}.png`)) 321 | }, 322 | remove(id) { 323 | checkAndCreateCacheType("avatar") 324 | try { 325 | fs.rmSync(join(avatarCacheDir, `${id}.png`)) 326 | return true 327 | } catch { 328 | return false 329 | } 330 | }, 331 | async checkDependencies(id) { 332 | if (!getGlobalOption("avatars")) return false 333 | checkAndCreateCacheType("avatar") 334 | return fs.readdirSync(gifCacheDir).some((e) => e.startsWith(id)) 335 | }, 336 | get IDs() { 337 | checkAndCreateCacheType("avatar") 338 | var result: string[] = [] 339 | if (!getGlobalOption("cache")) return result as Array 340 | return fs.readdirSync(gifCacheDir).reduce((a, filename) => { 341 | var hash = filename.match(hashRegex)?.[0] 342 | if (hash) if (!a.includes(hash)) a.push(hash) 343 | return a 344 | }, result) as Array 345 | }, 346 | clear() { 347 | checkAndCreateCacheType("avatar") 348 | var amount = getGlobalOption("cache") 349 | ? fs.readdirSync(avatarCacheDir).reduce((a, f) => { 350 | var filename = f.match(idRegex)?.[0] 351 | if (filename) if (!a.has(filename)) a.add(filename) 352 | 353 | return a 354 | }, new Set()).size 355 | : 0 356 | try { 357 | fs.rmdirSync(avatarCacheDir) 358 | } catch {} 359 | try { 360 | fs.mkdirSync(avatarCacheDir) 361 | } catch {} 362 | 363 | return amount 364 | }, 365 | }, 366 | }, 367 | }, 368 | /** Statistics for the whole server lifetime */ 369 | stats: Stats = { 370 | cache: { 371 | get type() { 372 | return getGlobalOption("cacheType") 373 | }, 374 | gif: { 375 | get inCache() { 376 | var result = 0 377 | if (isCurrentCacheType("code")) result += petpets.size 378 | if (isCurrentCacheType("fs")) result += cache.gif.fs.hashes.length 379 | return result 380 | }, 381 | get processing() { 382 | return petpetQueue.size 383 | }, 384 | }, 385 | avatar: { 386 | get inCache() { 387 | var result = 0 388 | if (isCurrentCacheType("code")) result += avatars.size 389 | if (isCurrentCacheType("fs")) result += cache.avatar.fs.IDs.length 390 | return result 391 | }, 392 | get processing() { 393 | return avatarQueue.size 394 | }, 395 | }, 396 | }, 397 | response: { 398 | routes: { 399 | "/:id": { 400 | successful: 0, 401 | failed: 0, 402 | total: 0, 403 | }, 404 | "/avatar/:id": { 405 | successful: 0, 406 | failed: 0, 407 | total: 0, 408 | }, 409 | }, 410 | average: { 411 | get fromScratch() { 412 | return +( 413 | requestTime.fromScratch.reduce((a, c) => a + c, 0) / 414 | (requestTime.fromScratch.length || 1) 415 | ).toFixed(4) 416 | }, 417 | get fromCache() { 418 | return +( 419 | requestTime.fromCache.reduce((a, c) => a + c, 0) / 420 | (requestTime.fromCache.length || 1) 421 | ).toFixed(4) 422 | }, 423 | }, 424 | failed: 0, 425 | successful: 0, 426 | total: 0, 427 | }, 428 | }, 429 | // controll over responses in statistics 430 | statControll = { 431 | response: { 432 | common: { 433 | increment: { 434 | /** Increment "/:id" success count by optional value, or 1 */ 435 | success(value = 1) { 436 | stats.response.routes["/:id"].successful += value 437 | stats.response.routes["/:id"].total += value 438 | stats.response.total += value 439 | stats.response.successful += value 440 | }, 441 | /** Decrement "/:id" failed count by optional value, or 1 */ 442 | failure(value = 1) { 443 | stats.response.routes["/:id"].failed += value 444 | stats.response.routes["/:id"].total += value 445 | stats.response.total += value 446 | stats.response.failed += value 447 | }, 448 | }, 449 | /** Reset "/:id" response counters */ 450 | reset() { 451 | var obj = stats.response.routes["/:id"] 452 | obj.successful = 0 453 | obj.total = 0 454 | obj.failed = 0 455 | }, 456 | }, 457 | avatars: { 458 | increment: { 459 | /** Increment "/avatar/:id" success count by optional value, or 1 */ 460 | success(value = 1) { 461 | stats.response.routes["/avatar/:id"].successful += value 462 | stats.response.routes["/avatar/:id"].total += value 463 | stats.response.failed += value 464 | }, 465 | /** Decrement "/avatar/:id" failed count by optional value, or 1 */ 466 | failure(value = 1) { 467 | stats.response.routes["/avatar/:id"].failed += value 468 | stats.response.routes["/avatar/:id"].total += value 469 | stats.response.failed += value 470 | }, 471 | }, 472 | /** Reset "/avatar/:id" response counters */ 473 | reset() { 474 | var obj = stats.response.routes["/avatar/:id"] 475 | obj.successful = 0 476 | obj.total = 0 477 | obj.failed = 0 478 | }, 479 | }, 480 | }, 481 | all: { 482 | /** Reset general response counters */ 483 | reset() { 484 | var obj = stats.response 485 | obj.successful = 0 486 | obj.total = 0 487 | obj.failed = 0 488 | }, 489 | /** Reset all response counters (all sub routes also) */ 490 | hardReset() { 491 | stats.response.successful = 0 492 | stats.response.total = 0 493 | stats.response.failed = 0 494 | var routes = stats.response.routes 495 | for (var r in stats.response.routes) { 496 | var route = r as keyof typeof routes 497 | routes[route].total = 0 498 | routes[route].successful = 0 499 | routes[route].failed = 0 500 | } 501 | }, 502 | }, 503 | } 504 | 505 | function addAvatarToCache(avatar: Avatar) { 506 | if (getGlobalOption("cache")) { 507 | if (isCurrentCacheType("code")) avatars.set(avatar.id, avatar) 508 | if (isCurrentCacheType("fs")) cache.avatar.fs.set(avatar) 509 | } 510 | } 511 | function addPetPetToCache(petpet: PetPet) { 512 | if (getGlobalOption("cache")) { 513 | if (isCurrentCacheType("code")) petpets.set(petpet.hash, petpet) 514 | if (isCurrentCacheType("fs")) cache.gif.fs.set(petpet) 515 | } 516 | } 517 | 518 | function checkCacheTime(time: number) { 519 | return time < getGlobalOption("cacheTime") 520 | } 521 | 522 | function checkCacheTypeExist(type: T) { 523 | return fs.existsSync(join(cacheDir, type)) 524 | } 525 | 526 | function createCacheType(type: T) { 527 | try { 528 | fs.mkdirSync(join(cacheDir, type), { recursive: true }) 529 | } catch {} 530 | } 531 | 532 | /** Checks for certain cachce type to exist, and if not - creates it */ 533 | function checkAndCreateCacheType(type: T) { 534 | if (!checkCacheTypeExist(type)) createCacheType(type) 535 | } 536 | 537 | export class PetPet implements PetPetType { 538 | #gif!: Uint8Array 539 | hash: Hash 540 | id: string 541 | lastSeen: number 542 | 543 | constructor(hash: Hash, gif: Uint8Array) { 544 | this.id = hash.match(/^\d+/)?.[0] ?? "" 545 | this.hash = hash 546 | this.lastSeen = Date.now() 547 | // set the Uint8Array, automaticaly invoking the setter function 548 | this.gif = gif 549 | } 550 | 551 | get gif() { 552 | return getGlobalOption("compression") ? decompress(this.#gif) : this.#gif 553 | } 554 | set gif(value) { 555 | this.#gif = getGlobalOption("compression") ? compress(value) : value 556 | } 557 | } 558 | 559 | export class Avatar implements AvatarType { 560 | #avatar!: Uint8Array 561 | id: string 562 | 563 | constructor(id: string, avatar: Uint8Array) { 564 | this.id = id 565 | // set the Uint8Array, automaticaly invoking the setter function 566 | this.avatar = avatar 567 | } 568 | 569 | get avatar() { 570 | return getGlobalOption("compression") ? decompress(this.#avatar) : this.#avatar 571 | } 572 | set avatar(value) { 573 | this.#avatar = getGlobalOption("compression") ? compress(value) : value 574 | } 575 | } 576 | -------------------------------------------------------------------------------- /svelte-app/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 38 | 43 | 44 | 50 | 60 | 66 | 76 | 82 | 92 | 98 | 108 | 114 | 124 | 130 | 140 | 146 | 156 | 162 | 172 | 178 | 188 | 194 | 204 | 210 | 220 | 226 | 236 | 242 | 252 | 258 | 268 | 274 | 284 | 290 | 300 | 306 | 316 | 323 | 333 | 339 | 349 | 355 | 365 | 371 | 381 | 387 | 397 | 402 | 404 | 405 | 407 | 409 | 410 | 411 | 412 | 413 | -------------------------------------------------------------------------------- /svelte-app/bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "svelte-app", 6 | "dependencies": { 7 | "@sveltejs/adapter-static": "^3.0.8", 8 | "@sveltejs/kit": "^2.19.2", 9 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 10 | "svelte": "^5.23.1", 11 | "vite": "^6.2.2", 12 | }, 13 | "devDependencies": { 14 | "@types/bun": "latest", 15 | }, 16 | "peerDependencies": { 17 | "typescript": "^5", 18 | }, 19 | }, 20 | }, 21 | "packages": { 22 | "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], 23 | 24 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ=="], 25 | 26 | "@esbuild/android-arm": ["@esbuild/android-arm@0.25.1", "", { "os": "android", "cpu": "arm" }, "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q=="], 27 | 28 | "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.1", "", { "os": "android", "cpu": "arm64" }, "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA=="], 29 | 30 | "@esbuild/android-x64": ["@esbuild/android-x64@0.25.1", "", { "os": "android", "cpu": "x64" }, "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw=="], 31 | 32 | "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ=="], 33 | 34 | "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA=="], 35 | 36 | "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A=="], 37 | 38 | "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww=="], 39 | 40 | "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.1", "", { "os": "linux", "cpu": "arm" }, "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ=="], 41 | 42 | "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ=="], 43 | 44 | "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ=="], 45 | 46 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg=="], 47 | 48 | "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg=="], 49 | 50 | "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg=="], 51 | 52 | "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ=="], 53 | 54 | "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ=="], 55 | 56 | "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA=="], 57 | 58 | "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.1", "", { "os": "none", "cpu": "arm64" }, "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g=="], 59 | 60 | "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.1", "", { "os": "none", "cpu": "x64" }, "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA=="], 61 | 62 | "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg=="], 63 | 64 | "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw=="], 65 | 66 | "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg=="], 67 | 68 | "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ=="], 69 | 70 | "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A=="], 71 | 72 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg=="], 73 | 74 | "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], 75 | 76 | "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 77 | 78 | "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], 79 | 80 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], 81 | 82 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], 83 | 84 | "@polka/url": ["@polka/url@1.0.0-next.28", "", {}, "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="], 85 | 86 | "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.36.0", "", { "os": "android", "cpu": "arm" }, "sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w=="], 87 | 88 | "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.36.0", "", { "os": "android", "cpu": "arm64" }, "sha512-NyfuLvdPdNUfUNeYKUwPwKsE5SXa2J6bCt2LdB/N+AxShnkpiczi3tcLJrm5mA+eqpy0HmaIY9F6XCa32N5yzg=="], 89 | 90 | "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.36.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JQ1Jk5G4bGrD4pWJQzWsD8I1n1mgPXq33+/vP4sk8j/z/C2siRuxZtaUA7yMTf71TCZTZl/4e1bfzwUmFb3+rw=="], 91 | 92 | "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.36.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6c6wMZa1lrtiRsbDziCmjE53YbTkxMYhhnWnSW8R/yqsM7a6mSJ3uAVT0t8Y/DGt7gxUWYuFM4bwWk9XCJrFKA=="], 93 | 94 | "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.36.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-KXVsijKeJXOl8QzXTsA+sHVDsFOmMCdBRgFmBb+mfEb/7geR7+C8ypAml4fquUt14ZyVXaw2o1FWhqAfOvA4sg=="], 95 | 96 | "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.36.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dVeWq1ebbvByI+ndz4IJcD4a09RJgRYmLccwlQ8bPd4olz3Y213uf1iwvc7ZaxNn2ab7bjc08PrtBgMu6nb4pQ=="], 97 | 98 | "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.36.0", "", { "os": "linux", "cpu": "arm" }, "sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg=="], 99 | 100 | "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.36.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg=="], 101 | 102 | "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.36.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A=="], 103 | 104 | "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.36.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw=="], 105 | 106 | "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.36.0", "", { "os": "linux", "cpu": "none" }, "sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg=="], 107 | 108 | "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.36.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg=="], 109 | 110 | "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.36.0", "", { "os": "linux", "cpu": "none" }, "sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA=="], 111 | 112 | "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.36.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag=="], 113 | 114 | "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.36.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ=="], 115 | 116 | "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.36.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ=="], 117 | 118 | "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.36.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A=="], 119 | 120 | "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.36.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-t+RY0JuRamIocMuQcfwYSOkmdX9dtkr1PbhKW42AMvaDQa+jOdpUYysroTF/nuPpAaQMWp7ye+ndlmmthieJrQ=="], 121 | 122 | "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.36.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw=="], 123 | 124 | "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], 125 | 126 | "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="], 127 | 128 | "@sveltejs/kit": ["@sveltejs/kit@2.19.2", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-OkW7MMGkjXtdfqdHWlyPozh/Ct1X3pthXAKTSqHm+mwmvmTBASmPE6FhwlvUgsqlCceRYL+5QUGiIJfOy0xIjQ=="], 129 | 130 | "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.0.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.0", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.15", "vitefu": "^1.0.4" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw=="], 131 | 132 | "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], 133 | 134 | "@types/bun": ["@types/bun@1.2.5", "", { "dependencies": { "bun-types": "1.2.5" } }, "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg=="], 135 | 136 | "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], 137 | 138 | "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], 139 | 140 | "@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], 141 | 142 | "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], 143 | 144 | "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], 145 | 146 | "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], 147 | 148 | "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], 149 | 150 | "bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="], 151 | 152 | "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 153 | 154 | "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], 155 | 156 | "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 157 | 158 | "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 159 | 160 | "devalue": ["devalue@5.1.1", "", {}, "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="], 161 | 162 | "esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="], 163 | 164 | "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 165 | 166 | "esrap": ["esrap@1.4.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-CjNMjkBWWZeHn+VX+gS8YvFwJ5+NDhg8aWZBSFJPR8qQduDNjbJodA2WcwCm7uQa5Rjqj+nZvVmceg1RbHFB9g=="], 167 | 168 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 169 | 170 | "import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="], 171 | 172 | "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], 173 | 174 | "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 175 | 176 | "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], 177 | 178 | "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], 179 | 180 | "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], 181 | 182 | "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], 183 | 184 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 185 | 186 | "nanoid": ["nanoid@3.3.10", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-vSJJTG+t/dIKAUhUDw/dLdZ9s//5OxcHqLaDWWrW4Cdq7o6tdLIczUkMXt2MBNmk6sJRZBZRXVixs7URY1CmIg=="], 187 | 188 | "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 189 | 190 | "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], 191 | 192 | "rollup": ["rollup@4.36.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.36.0", "@rollup/rollup-android-arm64": "4.36.0", "@rollup/rollup-darwin-arm64": "4.36.0", "@rollup/rollup-darwin-x64": "4.36.0", "@rollup/rollup-freebsd-arm64": "4.36.0", "@rollup/rollup-freebsd-x64": "4.36.0", "@rollup/rollup-linux-arm-gnueabihf": "4.36.0", "@rollup/rollup-linux-arm-musleabihf": "4.36.0", "@rollup/rollup-linux-arm64-gnu": "4.36.0", "@rollup/rollup-linux-arm64-musl": "4.36.0", "@rollup/rollup-linux-loongarch64-gnu": "4.36.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.36.0", "@rollup/rollup-linux-riscv64-gnu": "4.36.0", "@rollup/rollup-linux-s390x-gnu": "4.36.0", "@rollup/rollup-linux-x64-gnu": "4.36.0", "@rollup/rollup-linux-x64-musl": "4.36.0", "@rollup/rollup-win32-arm64-msvc": "4.36.0", "@rollup/rollup-win32-ia32-msvc": "4.36.0", "@rollup/rollup-win32-x64-msvc": "4.36.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-zwATAXNQxUcd40zgtQG0ZafcRK4g004WtEl7kbuhTWPvf07PsfohXl39jVUvPF7jvNAIkKPQ2XrsDlWuxBd++Q=="], 193 | 194 | "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], 195 | 196 | "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], 197 | 198 | "sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="], 199 | 200 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 201 | 202 | "svelte": ["svelte@5.23.1", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.3", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-DUu3e5tQDO+PtKffjqJ548YfeKtw2Rqc9/+nlP26DZ0AopWTJNylkNnTOP/wcgIt1JSnovyISxEZ/lDR1OhbOw=="], 203 | 204 | "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], 205 | 206 | "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], 207 | 208 | "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 209 | 210 | "vite": ["vite@6.2.2", "", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-yW7PeMM+LkDzc7CgJuRLMW2Jz0FxMOsVJ8Lv3gpgW9WLcb9cTW+121UEr1hvmfR7w3SegR5ItvYyzVz1vxNJgQ=="], 211 | 212 | "vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="], 213 | 214 | "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | fileNotForRunning() 2 | 3 | import type { logLevel } from "./config" 4 | import type { Avatar, PetPet } from "./db" 5 | import { fileNotForRunning } from "./functions" 6 | 7 | // ---- Utility types ---- 8 | export type PositiveNumber = number 9 | export type Positive = `${T}` extends `${infer C}${infer _}` 10 | ? C extends "-" 11 | ? never 12 | : number 13 | : never 14 | export type ArrayRange = Acc["length"] extends L 15 | ? Acc 16 | : ArrayRange 17 | 18 | // Just for indication proposes 19 | export type RangeType = number | S | E 20 | 21 | // TypeScript can't handle more than 30 22 | export type Range< 23 | Start extends number, 24 | End extends number, 25 | T extends any[] = ArrayRange, 26 | > = T["length"] extends End ? T["length"] : T["length"] | Range 27 | 28 | export type ANSIColor = ANSIRGB | ANSIColorShort 29 | export type ANSIColorShort = `$;5;${number}` 30 | export type ANSIRGB< 31 | R extends number = number, 32 | G extends number = number, 33 | B extends number = number, 34 | T extends "fg" | "bg" = "fg" | "bg", 35 | > = `${T extends "fg" ? "38" : "48"};2;${R};${G};${B}` 36 | 37 | /** Promise that always resolve a value without any failures */ 38 | export type AlwaysResolvingPromise = Omit, "catch" | "then"> & { 39 | then(callback?: (value: T) => TResult | PromiseLike): Promise 40 | } 41 | 42 | /** Returns values of the object */ 43 | export type Values> = T[keyof T] 44 | 45 | /** Returns index of given element as a number literal, or `never` */ 46 | export type IndexOf = T extends [ 47 | infer First, 48 | ...infer Rest, 49 | ] 50 | ? First extends U 51 | ? Acc["length"] 52 | : IndexOf 53 | : never 54 | /** Make all object's props optional, and do it recursively */ 55 | export type PartialAllObjectsProps> = { 56 | [K in keyof O]?: O[K] extends Record ? PartialAllObjectsProps : O[K] 57 | } 58 | 59 | /** Exclude `key: value` pairs, value's type of which is specified */ 60 | export type ExcludeObjectProps, T = any> = { 61 | [K in keyof O as O[K] extends T ? never : K]: O[K] 62 | } 63 | 64 | /** Filter object's props by provided type */ 65 | export type FilterObjectProps, T = never> = { 66 | [K in keyof O as O[K] extends T ? K : never]: O[K] 67 | } 68 | 69 | /** Pick last type in a Union type */ 70 | export type Last = ( 71 | (T extends any ? (x: () => T) => void : never) extends (x: infer I) => void ? I : never 72 | ) extends () => infer U 73 | ? U 74 | : never 75 | 76 | /** Convert Union type to tuple of types */ 77 | export type UnionToTuple = [T] extends [never] 78 | ? A 79 | : UnionToTuple>, [Last, ...A]> 80 | 81 | /** Join strings with a separator */ 82 | export type Join = Tuple extends [ 83 | // need a fix at `readonly` 84 | infer First extends string, 85 | ...infer Rest extends string[], 86 | ] 87 | ? Rest["length"] extends 0 88 | ? First 89 | : `${First}${Separator}${Join}` 90 | : "" 91 | 92 | export type TupleToUnion = T[number] 93 | 94 | /** Create a variations of strings out of Union type and an optional separator */ 95 | export type Combinations< 96 | T extends string, 97 | S extends string = "", 98 | _ extends string = T, 99 | > = T extends any ? T | `${T}${S}${Combinations, S>}` : never 100 | 101 | // ---- Options ---- 102 | 103 | export type GlobalOptionPropPriorityString = "original" | "config" | "arguments" 104 | export type GlobalOptionPropPriorityLevel = 0 | 1 | 2 105 | export type GlobalOptionPropPriorityAll = 106 | | GlobalOptionPropPriorityLevel 107 | | GlobalOptionPropPriorityString 108 | export type GlobalOptionProp = { 109 | value: T 110 | source: GlobalOptionPropPriorityString 111 | } 112 | 113 | export type GlobalOptionsValues = ExcludeObjectProps> 114 | type ApplyGlobalOptionPropTypeRecursively> = { 115 | [K in keyof O]: O[K] extends Record 116 | ? ApplyGlobalOptionPropTypeRecursively 117 | : GlobalOptionProp 118 | } 119 | 120 | // Options that runtime accepts via config file or flags. If you wish to add more options - do it here 121 | /** Base config options 122 | * 123 | * Options that can be configured via flags or config file */ 124 | export type BaseConfigOptions = { 125 | /** Enable microseconds notation in milliseconds values, and milliseconds notation in minutes (default=`false`) */ 126 | accurateTime: boolean 127 | /** Use alternate buffer to run the main application or not (output may mess up shell state) (default=`true`) */ 128 | alternateBuffer: boolean 129 | /** Cache avatars to reduce amount of requests to get them (default=`true`) */ 130 | avatars: boolean 131 | /** Store cache or not (default=`true`) */ 132 | cache: boolean 133 | /** How often to check cached GIFs after last request in milliseconds (default=`60_000` ms, `1` min) */ 134 | cacheCheckTime: number 135 | /** How long to cache GIFs in milliseconds (default=`900_000` ms, `15` min) */ 136 | cacheTime: number 137 | /** Cache type. `"code"` - store cache in code itself, `"fs"` - in filesystem, `"both"` - both types together (default=`"code"`)*/ 138 | cacheType: "code" | "fs" | "both" 139 | /** Use compression to store all cache in code in compressed format or not (default=`true`) */ 140 | compression: boolean 141 | /** Clear the stdout on server restart due to changes in config file (default=`true`) */ 142 | clearOnRestart: boolean 143 | /** Log errors during runtime (default=`true`) */ 144 | errors: boolean 145 | /** Show what log features are enabled (default=`false`) */ 146 | logFeatures: boolean 147 | /** Options for logging (all default=`false`): 148 | * `rest` - log requests and responses 149 | * `gif` - log gif creation time & when gif was in cache 150 | * `params` - log each gif request params 151 | * `cache` - log cache related things (perform, cleanup, info, etc.) 152 | * `watch` - log when server restarted due to changes in config file (`watch` option needs to be enabled) 153 | */ 154 | logOptions: LogOptions 155 | /** Store cache permanent (without checks for cache time) (default=`false`) */ 156 | permanentCache: boolean 157 | /** Do some output, or run server without any output (during runtime, not during parsing flags/config files) (default=`true`) */ 158 | quiet: boolean 159 | /** Server configuration (default values) 160 | * `port` = `3000` 161 | * `host` = `"localhost"` 162 | */ 163 | server: ServerOptions 164 | /** Include timestamps in all logging stuff (default=`false`) */ 165 | timestamps: boolean 166 | /** Format for the timestamps represented with a string with formating characters (default=`"h:m:s D.M.Y"`) 167 | * 168 | * | format | description | 169 | * |:------:|:-----------------------------------------------| 170 | * | u | microseconds | 171 | * | S | milliseconds | 172 | * | s | seconds | 173 | * | m | minutes | 174 | * | h | hours (24h format, 12:00, 13:00, 24:00, 01:00) | 175 | * | H | hours (12h format, 12 PM, 1 PM, 12 AM, 1 AM) | 176 | * | P | `AM`/`PM` indicator | 177 | * | d | day (first 3 letters of the day of the week) | 178 | * | D | day (of month) | 179 | * | M | month (number) | 180 | * | N | month (3 first letters of the month name) | 181 | * | Y | year | 182 | * 183 | * _Note!_ To escape some character that are listed in formating - use backslash symbol `\` before character (you would probably need second one, to escape the escape character like `\n`, `\t` or others depending on where you write the format). 184 | * _Note!_ `microseconds` are obtained with the high precision time in milliseconds from the script start time, which has a part after period, that indicates microseconds. So it's probably not syncronized with the computer's clock time, but it can be used as a timestamp in the time. 185 | * *Examples*: 186 | * | format | description | 187 | * |:--------------|:--------------------------------------------------------------------------------------------------------| 188 | * | "s:m:h D.M.Y" | `seconds:minutes:hours day(of month).month(number).year` | 189 | * | "Y N d m:h" | `year month(3 first letters of the month name) day(first 3 letters of the day of the week) minutes:hours` | 190 | * | "m:s:S.u" | `minutes:seconds:milliseconds.microseconds` | 191 | * | "s/m/h" | `seconds/minutes/hours` | 192 | * | "m:h" | `minutes:hours` | 193 | */ 194 | timestampFormat: string 195 | /** Show full errors on each error during runtime */ 196 | verboseErrors: boolean 197 | /** Log warnings during runtime (default=`true`) */ 198 | warnings: boolean 199 | /** Watch the configuration files, and rerun the server on change (default=`false`) */ 200 | watch: boolean 201 | } 202 | 203 | // Additional options for the server that serve for identification purposes and cannot be configured using the configuration file or flags 204 | export type AdditionalGlobalOptions = { 205 | /** Use the configuration files or not (changes after applying config file and flags) 206 | * 207 | * __Config option__ */ 208 | useConfig: boolean 209 | /** In alternate buffer or not (changes depending on the state of the program) 210 | * 211 | * __Config option__ */ 212 | inAlternate: boolean 213 | } 214 | 215 | // All options for the server, including special, that are not publicly accesed 216 | type AllGlobalConfigOptionsBase = BaseConfigOptions & AdditionalGlobalOptions 217 | 218 | // If you want to add more custom options - add them in `BaseConfigOptions` or `AdditionalGlobalOptions` if they need to be just for indication proposes and don't have public access 219 | export type AllGlobalConfigOptions = Readonly 220 | 221 | export type GlobalObjectOptions = FilterObjectProps> 222 | 223 | /** Options for global behaviour and settings */ 224 | export type GlobalOptions = { 225 | /** Version of the project */ 226 | readonly version: string 227 | /** State of the configuration 228 | * `"configuring"` - in process of configuring all runtime options 229 | * `"ready"` - when configuration is ready for usage (not in configuration phase) 230 | */ 231 | state: "configuring" | "ready" 232 | } & ApplyGlobalOptionPropTypeRecursively 233 | 234 | // ----- Log Options ----- 235 | 236 | type LogLevelToArrIndex = { 237 | 0: never 238 | 1: 0 239 | 2: 1 240 | 3: 2 241 | 4: 3 242 | 5: 4 243 | } 244 | 245 | export type GetLogOption = O extends LogLevel 246 | ? LogLevelToArrIndex[O] extends never 247 | ? undefined 248 | : (typeof logLevel)[LogLevelToArrIndex[O]] 249 | : O extends LogOptionShort 250 | ? { 251 | [K in keyof LogOptions]: K extends `${O}${string}` ? K : never 252 | }[keyof LogOptions] 253 | : O extends LogOptionLong 254 | ? O 255 | : never 256 | 257 | export type LogOption = LogString | LogLevel 258 | /** Options for logging (all default=`false`): 259 | * `rest` - log requests and responses 260 | * `gif` - log gif creation time & when gif was in cache 261 | * `params` - log each gif request params 262 | * `cache` - log cache related things (perform, cleanup, info, etc.) 263 | * `watch` - log when server restarted due to changes in config file (`watch` option needs to be enabled) 264 | */ 265 | export type LogOptions< 266 | R extends boolean = boolean, 267 | G extends boolean = boolean, 268 | P extends boolean = boolean, 269 | C extends boolean = boolean, 270 | W extends boolean = boolean, 271 | > = { 272 | /** Log requests and responses (default=`false`) */ 273 | rest: R 274 | /** Log gif creation time & when gif was in cache (default=`false`) */ 275 | gif: G 276 | /** Log request params (default=`false`) */ 277 | params: P 278 | /** Log cache related info (default=`false`) */ 279 | cache: C 280 | /** Inform when server restarts (`watch` option needs to be enabled) (default=`false`) */ 281 | watch: W 282 | } 283 | export type LogString = LogOptionLongCombination | LogOptionShortCombination 284 | export type LogStringOne = LogOptionLong | LogOptionShort 285 | export type LogLevel = 0 | 1 | 2 | 3 | 4 | 5 286 | 287 | export type LogOptionLongCombination = Combinations 288 | export type LogOptionShortCombination = Combinations 289 | export type LogOptionLong = keyof LogOptions 290 | export type LogOptionShort = { 291 | [S in LogOptionLong]: S extends `${infer C}${infer _}` ? C : never 292 | }[LogOptionLong] 293 | 294 | // ----- Server options ----- 295 | 296 | /** Server options (default values) 297 | * `port` = `3000` 298 | * `host` = `"localhost"` 299 | */ 300 | export type ServerOptions

= { 301 | /** Server port to run on (default=`3000`) */ 302 | port: P 303 | /** Server host to run on (default=`"localhost"`) */ 304 | host: H 305 | } 306 | 307 | // ----- Flag ----- 308 | 309 | export type FlagValue = "none" | "optional" | "required" 310 | export type FlagValueArray = ("required" | "optional")[] 311 | export type FlagValueForArray = "optional" | "required" 312 | export type FlagValueUnion = FlagValue | FlagValueArray | Readonly 313 | 314 | export type FlagParameterType = { 315 | required: `<${string}>` 316 | optional: `[${string}]` 317 | } 318 | 319 | type FlagHandlerParam = T extends FlagValue 320 | ? T extends "required" 321 | ? [value: string] 322 | : T extends "optional" 323 | ? [value?: string] 324 | : [] 325 | : T extends FlagValueArray 326 | ? { 327 | [K in keyof T]: T[K] extends "required" 328 | ? string 329 | : T[K] extends "optional" 330 | ? string | undefined 331 | : string 332 | } 333 | : [] 334 | 335 | type FlagParameter = T extends FlagValue 336 | ? T extends "required" 337 | ? FlagParameterType["required"] 338 | : T extends "optional" 339 | ? FlagParameterType["optional"] 340 | : T extends "none" 341 | ? "" 342 | : string 343 | : { 344 | [K in keyof T]: T[K] extends "required" 345 | ? FlagParameterType["required"] 346 | : T[K] extends "optional" 347 | ? FlagParameterType["optional"] 348 | : FlagParameterType["required"] | FlagParameterType["optional"] 349 | } 350 | 351 | export type Flag = { 352 | short?: string 353 | long: string 354 | value: T 355 | parameter: FlagParameter 356 | description: string 357 | extendedDescription: string 358 | handler: (...args: FlagHandlerParam) => void 359 | } 360 | 361 | // ----- Params ----- 362 | 363 | export type LogDependency = "info" | "error" | "warning" | LogLevel | LogOptionLongCombination 364 | 365 | export type ChechValidPetPetParams = { 366 | shift?: string 367 | resize?: string 368 | size?: string 369 | gifsize?: string 370 | fps?: string 371 | squeeze?: string 372 | objects?: string 373 | } 374 | 375 | export type Resolve = (value: T | PromiseLike) => void 376 | 377 | // ------ Logger ------ 378 | 379 | type AnyArgsFn = (...args: any[]) => T 380 | /** Custom loger */ 381 | export type Logger = { 382 | info: AnyArgsFn 383 | warning: AnyArgsFn 384 | error: AnyArgsFn 385 | level: (level: LogLevel, ...args: any[]) => void 386 | combination: (combination: LogString, ...args: any[]) => void 387 | } 388 | 389 | // ------ Gifs/Images ------ 390 | 391 | /** Object for configuring cache in code and filesystem */ 392 | export type Cache = { 393 | /** Cache configuration for GIFs */ 394 | gif: { 395 | /** Operations with queue */ 396 | queue: { 397 | /** Add the generation task to queue with fething avatar */ 398 | addWithAvatar: ( 399 | hash: Hash, 400 | params?: Partial, 401 | size?: number, 402 | ) => Promise 403 | /** Add the generation task to queue with existing avatar */ 404 | add: ( 405 | hash: Hash, 406 | gif: Uint8Array, 407 | params?: Partial, 408 | ) => Promise 409 | /** Check if generation task is in the queue */ 410 | has: (hash: Hash) => boolean 411 | /** Get the promise with GIF generation task */ 412 | get: (hash: Hash) => Promise | undefined 413 | } 414 | 415 | /** Cache configuration for GIFs in code */ 416 | code: { 417 | /** Sets the PetPet object to map and returns result of the operation as a boolean */ 418 | set: (petpet: PetPet) => boolean 419 | /** Returns the PetPet object from map */ 420 | get: (hash: Hash) => PetPet | undefined 421 | /** Checks if PetPet object exists in map */ 422 | has: (hash: Hash) => boolean 423 | /** Deletes the PetPet object from map and returns result of the operation as a booleana (`true` = success, `false` = failure) */ 424 | remove: (hash: Hash) => boolean 425 | /** Chechs if PetPet object with given hash exeeded cache time (`true` = exeeded, `false` = not exeeded) */ 426 | checkCacheTime: (hash: Hash) => boolean 427 | /** Array of all hashes in cache */ 428 | hashes: Array 429 | /** Clear the cache from all PetPet objects, and return a number of how many were deleted */ 430 | clear: () => number 431 | } 432 | /** Cache configuration for GIFs in filesystem */ 433 | fs: { 434 | /** Writes the GIF and JSON files to the `./cache/gif` directory and returns an always-resolving promise with result of the operation (`true` = success, `false` = failure) 435 | * @returns {AlwaysResolvingPromise} Always-resolving Promise with result of the operation (`true` = success, `false` = failure) */ 436 | set: (petpet: PetPet) => AlwaysResolvingPromise 437 | /** Reads the `./cache/gif` directory, checks if GIF file with given hash exists, and returns an always-resolving promise with the result 438 | * @returns {AlwaysResolvingPromise} Always-resolving Promise with result */ 439 | get: (hash: Hash) => AlwaysResolvingPromise 440 | /** Checks the `./cache/gif` directory for a GIF with given hash */ 441 | has: (hash: Hash) => boolean 442 | /** Removes the GIF and JSON files from `./cache/gif` directory and returns result of the operation as a boolean (`true` = success, `false` = failure) 443 | * @returns {boolean} result of the operation (`true` = success, `false` = failure) */ 444 | remove: (hash: Hash) => boolean 445 | /** Reands `./cache/gif` directory, checks if JSON file with given hash exists, reads the file, and checks if timestamp exeeded cache time (`true` = exeeded, `false` = not exeeded) 446 | * @returns {AlwaysResolvingPromise} Always-resolving Promise with the result (`true` = exeeded, `false` = not exeeded) */ 447 | checkCacheTime: (hash: Hash) => AlwaysResolvingPromise 448 | /** Reands `./cache/gif` directory, and checks if both `GIF` and `JSON` files with given hash exists (`true` = exists, `false` = one/both files does not exist) */ 449 | checkSafe: (hash: Hash) => boolean 450 | /** Array of all hashes in cache */ 451 | hashes: Array 452 | /** Clear the cache from all `GIF` and `JSON` files by pairs, and return a number of how many full pairs were deleted (pair: `GIF` + `JSON`) 453 | * @returns {number} Amount of deleted full `GIF + JSON` pairs (if one of the files doesn't exist - will not count for this pair) */ 454 | clear: () => number 455 | } 456 | } 457 | /** Cache configuration for avatars */ 458 | avatar: { 459 | /** Operations with queue */ 460 | queue: { 461 | /** Add the fetch task promise to the queue, and return this task */ 462 | add: (id: string, size?: number) => Promise 463 | /** Check if fetching task is in the queue */ 464 | has: (id: string) => boolean 465 | /** Get the promise with avatar fetching process */ 466 | get: (id: string) => Promise | undefined 467 | } 468 | /** Cache configuration for avatars in code */ 469 | code: { 470 | /** Sets the Avatar object to map and returns result of the operation as a boolean (`true` = success, `false` = failure)*/ 471 | set: (avatar: Avatar) => boolean 472 | /** Returns the Avatar object from map */ 473 | get: (id: string) => Avatar | undefined 474 | /** Checks if Avatar object exists in map */ 475 | has: (id: string) => boolean 476 | /** Deletes the Avatar object from map and returns result of the operation as a booleana (`true` = success, `false` = failure) */ 477 | remove: (id: string) => boolean 478 | /** Chechs if GIFs in code cache needs this avatar or not (`true` = needs, `false` = not needs) */ 479 | checkDependencies: (id: string) => boolean 480 | /** Array of all IDs in cache */ 481 | IDs: Array 482 | /** Clear the cache from all `Avatar` objects, and return a number of how many of them were deleted 483 | * @returns {number} Amount of deletet `Avatar` objects */ 484 | clear: () => number 485 | } 486 | /** Cache configuration for avatars in filesystem */ 487 | fs: { 488 | /** Writes the PNG files to the `./cache/avatar` directory and returns an always-resolving promise with result of the operation (`true` = success, `false` = failure) 489 | * @returns {AlwaysResolvingPromise} Always-resolving Promise with result of the operation (`true` = success, `false` = failure) */ 490 | set: (avatar: Avatar) => AlwaysResolvingPromise 491 | /** Reads the `./cache/avatar` directory, checks if PNG file with given id exists, and returns an always-resolving promise with the result 492 | * @returns {AlwaysResolvingPromise} Always-resolving Promise with result */ 493 | get: (id: string) => AlwaysResolvingPromise 494 | /** Checks the `./cache/avatar` directory, and if PNG with given id exists - returns an always-resolving promise as a result */ 495 | has: (id: string) => boolean 496 | /** Removes the PNG files from `./cache/avatar` directory and returns result of the operation as a booleana (`true` = success, `false` = failure) 497 | * @returns {boolean} result of the operation (`true` = success, `false` = failure) */ 498 | remove: (id: string) => boolean 499 | /** Reands `./cache/gif` directory, and checks if GIF file with given id in it's name exists (`true` = needs, `false` = not needs) 500 | * @returns {AlwaysResolvingPromise} Always-esolving Promise with the result (`true` = needs, `false` = not needs) */ 501 | checkDependencies: (id: string) => AlwaysResolvingPromise 502 | /** Array of all IDs in cache */ 503 | IDs: Array 504 | /** Clear the cache from all `PNG` files, and return a number of how many were deleted 505 | * @returns {number} Amount of deleted `PNG` files */ 506 | clear: () => number 507 | } 508 | } 509 | } 510 | 511 | export type Stats = { 512 | cache: { 513 | readonly type: AllGlobalConfigOptions["cacheType"] 514 | gif: { 515 | readonly inCache: number 516 | readonly processing: number 517 | } 518 | avatar: { 519 | readonly inCache: number 520 | readonly processing: number 521 | } 522 | } 523 | response: { 524 | average: { 525 | readonly fromScratch: number 526 | readonly fromCache: number 527 | } 528 | routes: { 529 | "/:id": { 530 | successful: number 531 | failed: number 532 | total: number 533 | } 534 | "/avatar/:id": { 535 | successful: number 536 | failed: number 537 | total: number 538 | } 539 | } 540 | successful: number 541 | failed: number 542 | total: number 543 | } 544 | } 545 | 546 | export type HashPart = string 547 | export type HashTwoParameters = `${string}x${string}` 548 | export type UID = string 549 | export type PetPetObject = PetPetParams["objects"] 550 | export type Hash = 551 | `${UID}-${HashTwoParameters}-${HashTwoParameters}-${HashPart}-${HashPart}-${HashPart}-${HashPart}-${PetPetObject}` 552 | 553 | export type PetPetType = { 554 | hash: Hash 555 | id: string 556 | lastSeen: number 557 | gif: Uint8Array 558 | } 559 | 560 | export type AvatarType = { 561 | avatar: Uint8Array 562 | id: string 563 | } 564 | 565 | export type PetPets = Map 566 | export type Avatars = Map 567 | export type AvatarQueue = Map> 568 | export type PetPetQueue = Map> 569 | export type PetPetParams = { 570 | /** Shift the base image by `X` pixels on the X axis (horizontal) (default=`0`px) */ 571 | shiftX: number 572 | /** Shift the base image by `Y` pixels on the Y axis (vertical) (default=`0`px) */ 573 | shiftY: number 574 | /** Size of the base image (positive integer) (default=`80`px) */ 575 | size: number 576 | /** Size of the GIF (positive integer) (default=`80`px) */ 577 | gifsize: PositiveNumber 578 | /** Desire FPS for GIF (default=`16`) 579 | * @see https://www.fileformat.info/format/gif/egff.htm */ 580 | fps: RangeType<1, 50> 581 | /** Resize image on X axis from center (default=`0`px) */ 582 | resizeX: number 583 | /** Resize image on Y axis from center (default=`0`px) */ 584 | resizeY: number 585 | /** Squeeze factor. How mush to squeeze the image in the middle of the animation (hand down) (default=`15`px) */ 586 | squeeze: number 587 | /** What objects to include in GIF: only `"hand"`, only `"avatar"`, or `"both"` (default=`"both"`) */ 588 | objects: "hand" | "avatar" | "both" 589 | } 590 | 591 | // ------- API ------- 592 | 593 | export type AvatarExtensionDiscord = "png" | "webp" | "gif" 594 | 595 | // API: ----- Shared ----- 596 | 597 | export type Images = { 598 | status: "loading" | "complete" | "no-content" | "cache-disabled" 599 | value: string[] 600 | } 601 | export type ImageResponse = { 602 | state: "completed" | "no-content" | "cache-disabled" 603 | value?: string[] 604 | } 605 | export type WSMessageType = "new-images" 606 | export type WSMessage = { 607 | type: T 608 | } & (T extends "new-images" ? {} : {}) 609 | -------------------------------------------------------------------------------- /src/functions.ts: -------------------------------------------------------------------------------- 1 | fileNotForRunning() 2 | 3 | import { stdin, stdout } from "process" 4 | import type { Context, MiddlewareHandler } from "hono" 5 | import fs from "fs" 6 | import { join } from "path" 7 | import type { 8 | AlwaysResolvingPromise, 9 | ANSIColor, 10 | ANSIRGB, 11 | ChechValidPetPetParams, 12 | GetLogOption, 13 | GlobalOptions, 14 | LogDependency, 15 | LogLevel, 16 | LogOptionLong, 17 | LogOptionLongCombination, 18 | LogOptionShort, 19 | LogStringOne, 20 | WSMessage, 21 | } from "./types" 22 | import { 23 | getGlobalOption, 24 | getLogOption, 25 | setState, 26 | ROOT_PATH, 27 | logLevel, 28 | resetGlobalOptionsHandler, 29 | setGlobalConfigOption, 30 | getGlobalConfigOption, 31 | } from "./config" 32 | import { TOML } from "bun" 33 | import { parseToml } from "./parseConfigFile" 34 | import { cache } from "./db" 35 | import { objectsTypes } from "./petpet" 36 | 37 | export var info = customLogTag("Info", cyan), 38 | error = customLogTag("Error", red), 39 | warning = customLogTag("Warning", yellow), 40 | /** NC = No Color ANSI escape code */ 41 | NC = "\u001b[0m" as const, 42 | compress = Bun.deflateSync, 43 | decompress = Bun.inflateSync 44 | 45 | /** Import this file in needed file if you don't want specific file to be run directly */ 46 | export function fileNotForRunning(): boolean { 47 | var file = process.argv[1].match(/[a-zA-Z0-9_\-]+\.ts$/)?.[0] 48 | if (file && (file === "index.ts" || file === "help.ts" || file === "explain.ts")) return false 49 | else 50 | import("./config").then(() => { 51 | print( 52 | error( 53 | `File ${green(`./src/${file}`)} is a library file and is not intended to be run directly`, 54 | ), 55 | ) 56 | process.exit() 57 | }) 58 | return true 59 | } 60 | 61 | export function customLogTag( 62 | tag: S, 63 | ansi: C, 64 | ): (arg: any) => string 65 | export function customLogTag string>( 66 | tag: S, 67 | colorFn: C, 68 | ): (arg: any) => string 69 | export function customLogTag(tag: S): (arg: any) => string 70 | export function customLogTag string)>( 71 | tag: S, 72 | ansi_colorFn?: C, 73 | ) { 74 | /** Formats the input string with following format: `"[timestamp (optional)][tag name] ${text}"` 75 | * @returns {string} Information with the tag, and optional formated timestamp (if cooresponding options are turned on) in formt of `"[timestamp][Tag name] {text}"` */ 76 | return function color(arg: any): string { 77 | var result = "", 78 | timestamps = memoize(getGlobalOption<"timestamps">, "timestamps"), 79 | date = memoize(formatDate) 80 | 81 | if (typeof ansi_colorFn === "string") 82 | result = `\x1b[${ansi_colorFn}m${timestamps.value ? `[${date.value}]` : ""}[${tag}]${NC}` 83 | else { 84 | result = `${timestamps.value ? `[${date.value}]` : ""}[${tag}]` 85 | if (typeof ansi_colorFn === "function") result = ansi_colorFn(result) 86 | } 87 | 88 | result += ` ${arg}` 89 | return result 90 | } 91 | } 92 | export function customColor(color: ANSIColor) { 93 | return function c(text: any) { 94 | return `\x1b[${color}m${text}${NC}` 95 | } 96 | } 97 | export function RGBToANSI< 98 | R extends number = number, 99 | G extends number = number, 100 | B extends number = number, 101 | >(rgb: readonly [R, G, B] | [R, G, B]): ANSIRGB { 102 | return `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` 103 | } 104 | 105 | export function red(text: any) { 106 | return `\x1b[31m${text}${NC}` 107 | } 108 | export function orange(text: any) { 109 | return `\x1b[38;5;208m${text}${NC}` 110 | } 111 | export function yellow(text: any) { 112 | return `\x1b[33m${text}${NC}` 113 | } 114 | export function green(text: any) { 115 | return `\x1b[32m${text}${NC}` 116 | } 117 | export function cyan(text: any) { 118 | return `\x1b[36m${text}${NC}` 119 | } 120 | export function purple(text: any) { 121 | return `\x1b[35m${text}${NC}` 122 | } 123 | export function gray(text: any) { 124 | return `\x1b[38;5;248m${text}${NC}` 125 | } 126 | export function string(text: any) { 127 | return `\x1b[38;2;220;130;70m${text}${NC}` 128 | } 129 | 130 | export function colorValue(value: any) { 131 | var result = "" 132 | if (isString(value)) { 133 | if (isStringNumber(value) || isStringBoolean(value)) result = yellow(value) 134 | else result = string(`'${value}'`) 135 | } else if (isNumber(value) || isBoolean(value)) result = yellow(value) 136 | else if (value === undefined) result = gray(undefined) 137 | else if (value === null) result = gray(null) 138 | else if (Array.isArray(value)) { 139 | result = `[${value.map(colorValue).join(`${gray(",")} `)}]` 140 | } 141 | 142 | return result 143 | } 144 | 145 | export function EXIT(isInBuffer = false) { 146 | isInBuffer && exitAlternateBuffer() 147 | process.exit() 148 | } 149 | 150 | /** Enters the alternate buffer, and returns a cleanup function that exits current session of the alternate buffer (listener attached) */ 151 | export function enterAlternateBuffer() { 152 | var command = "" 153 | if (stdout.isTTY) 154 | try { 155 | // set the terminal raw mode to true 156 | stdin.setRawMode(true) 157 | } catch (e) { 158 | print("Failed to enter alternate buffer:\n", e) 159 | process.exit() 160 | } 161 | try { 162 | // enter alternate buffer, and move cursor to the left-top position 163 | stdout.write("\x1b[?1049h\x1b[H") 164 | setGlobalConfigOption("inAlternate", true) 165 | } catch (e) { 166 | print("Failed to enter alternate buffer:\n", e) 167 | process.exit() 168 | } 169 | /** Listen to a specific command or a sequense of characters to exit */ 170 | var listener = (buffer: Buffer) => { 171 | // + c => SIGINT (interupt) 172 | if (buffer.length === 1 && buffer[0] === 0x03) EXIT(true) 173 | // + z => SIGTSTP (send to background) // not sure yet 174 | else if (buffer.length === 1 && buffer[0] === 0x1a) { 175 | // exitAlternateBuffer() 176 | process.kill(process.pid, "SIGTSTP") 177 | } else { 178 | // `q`, `:q`, `ZZ`, `:x` => exit the process completely 179 | var str = buffer.toString().trim() 180 | if (str === "q") EXIT(true) 181 | else if ((str === ":" || str === "Z") && command === "") command = str 182 | else if ((str === "q" || str === "x") && command === ":") EXIT(true) 183 | else if (str === "Z" && command === "Z") EXIT(true) 184 | else if (command !== "") command = "" 185 | } 186 | } 187 | stdin.on("data", listener) 188 | return () => { 189 | stdin.removeListener("data", listener) 190 | exitAlternateBuffer() 191 | } 192 | } 193 | 194 | /** Safely exit alternate buffer 195 | * Use ONLY if there are no other ways! Preferable to use the cleanup function from `enterAlternateBuffer` function to not mess up with them */ 196 | export function exitAlternateBuffer() { 197 | try { 198 | stdout.write("\x1b[?1049l") 199 | setGlobalConfigOption("inAlternate", false) 200 | } catch (e) { 201 | print("Failed to exit alternate buffer:\n", e) 202 | process.exit() 203 | } 204 | if (stdout.isTTY) 205 | try { 206 | // set the terminal raw mode to false 207 | stdin.setRawMode(false) 208 | } catch (e) { 209 | print("Failed to set terminal raw mode to false:\n", e) 210 | process.exit() 211 | } 212 | } 213 | 214 | export function formatDelta( 215 | delta: T, 216 | accurate = false as A, 217 | ) { 218 | var time = delta < 1000 ? delta : delta / 1000 219 | return accurate ? +time.toFixed(3) : Math.trunc(time) 220 | } 221 | 222 | export function formatDeltaValue(delta: T) { 223 | return `${formatDelta(delta, getGlobalOption("accurateTime"))}${delta < 1000 ? "ms" : "s"}` 224 | } 225 | 226 | /** @param {(message: string) => void} [fn=print] Log function that writes string to the stdout */ 227 | export function APILogger(fn: (message: string) => void = print): MiddlewareHandler { 228 | return async (c, next) => { 229 | var { method } = c.req, 230 | path = decodeURIComponent(c.req.url.match(/^https?:\/\/[^/]+(\/[^\?#]*)/)?.[1] ?? "/"), 231 | start = performance.now(), 232 | // memoization to not depend on options that changes during request 233 | timestamps = memoize(getGlobalOption<"timestamps">, "timestamps"), 234 | date = memoize(formatDate) 235 | timestamps.args 236 | 237 | // incoming request 238 | typeof fn === "function" && // who knows what type of data can be under the hood :) 239 | fn( 240 | `${timestamps.value ? `${green(`[${date.call}]`)} ` : ""}${green("<--")} ${method} ${path}`, 241 | ) 242 | 243 | return new Promise((resolve) => { 244 | next().then(() => { 245 | var status = c.res.status, 246 | end = performance.now(), 247 | elapsed = end - start, 248 | // first number of the response status code 249 | statuscategory = Math.floor(status / 100) || 0, 250 | colorFn: (text: any) => string = yellow 251 | 252 | // colors for coloring the status and arrow 253 | if (statuscategory === 7) colorFn = purple 254 | else if (statuscategory === 5) colorFn = red 255 | else if (statuscategory === 3) colorFn = cyan 256 | else if (statuscategory === 4 || statuscategory === 0) colorFn = yellow 257 | else if (statuscategory === 1 || statuscategory === 2) colorFn = green 258 | 259 | // outgoing response 260 | typeof fn === "function" && 261 | fn( 262 | `${timestamps.value ? `${green(`[${date.call}]`)} ` : ""}${colorFn("-->")} ${method} ${path} ${colorFn(status)} ${formatDeltaValue(elapsed)}`, 263 | ) 264 | resolve() 265 | }) 266 | }) 267 | } 268 | } 269 | 270 | export function chechCache() { 271 | var cacheType = getGlobalOption("cacheType"), 272 | toBeDeleted = 0, 273 | // if `cacheType` === "both", then do an extra check on if cache 274 | // in code and cache in filesystem are the same, 275 | // otherwise complete the missing one from the corresponding type 276 | toBeRepaired = 0 277 | new Promise((resolve) => { 278 | var waiting: Promise[] = [] 279 | if (cacheType === "code") { 280 | var hashes = cache.gif.code.hashes 281 | for (var hash of hashes) { 282 | if (cache.gif.code.checkCacheTime(hash)) { 283 | cache.gif.code.remove(hash) 284 | toBeDeleted++ 285 | } 286 | } 287 | } else if (cacheType === "fs") { 288 | } else { 289 | } 290 | 291 | Promise.all(waiting).then(resolve) 292 | }).then(() => { 293 | if (toBeDeleted) log("cache", info(`Found ${toBeDeleted} outdated GIFs in cache`)) 294 | if (toBeRepaired) log("cache", info(`Repaired ${toBeRepaired} GIFs`)) 295 | }) 296 | } 297 | 298 | /** Checks if given cache type is the current type. If current === `"both"` - return true, else compare */ 299 | export function isCurrentCacheType(type: T) { 300 | var ct = getGlobalOption("cacheType") 301 | // if specified type is not "both", if current type is "both" - return true, else strict compare 302 | return (ct === "both" && type !== "both") || ct === type 303 | } 304 | 305 | export function isLogfeatureEnabled(feature: LogOptionLong) { 306 | return getLogOption(feature) ?? false 307 | } 308 | 309 | /** Accepts a log option, and normalizes it to the expanded long option (see `logLevel` in `./src/config.ts:105`) 310 | * if option is string - tries to return it's full version, or undefined if not Found 311 | * if option is number - tries to access the `logLevel` by this index -1, or undefined if not found */ 312 | export function normalizeLogOption( 313 | option: O, 314 | ): GetLogOption | undefined { 315 | return ( 316 | typeof option === "number" 317 | ? option < 6 && option > 0 318 | ? logLevel[--option] 319 | : undefined 320 | : logLevel.find((e) => e[0] === option || e === option) 321 | ) as GetLogOption 322 | } 323 | 324 | /** Log things on different events in form of dependencies 325 | * `"info"` event works any time unless `quiet` global option is set 326 | * `"warning"` event trigers if `warnings` global option is set 327 | * `"error"` event trigers if `errors` global option is set 328 | * `LogLevel` event trigers if aproparate level of logging is set (maximum, see ) 329 | * `LogOptionLongCombination` event trigers if one of log options listed in combination is set 330 | */ 331 | export function log(dependencies: D, ...args: any[]): void 332 | export function log(dependency: D, ...args: any[]): void 333 | export function log(dependency: D, ...args: any[]) { 334 | var doLog = false 335 | if (!getGlobalOption("quiet")) { 336 | if (dependency === "info") doLog = true 337 | else if (dependency === "error") getGlobalOption("errors") && (doLog = true) 338 | else if (dependency === "warning") getGlobalOption("warnings") && (doLog = true) 339 | else if (typeof dependency === "number") { 340 | } else if (typeof dependency === "string") { 341 | if (dependency.includes(",")) { 342 | var deps = dependency.split(/,/g) as LogStringOne[] 343 | for (var dep of deps) { 344 | var opt = normalizeLogOption(dep) 345 | 346 | if (opt && getLogOption(opt)) { 347 | doLog = true 348 | break 349 | } 350 | } 351 | } else if (getLogOption(normalizeLogOption(dependency as LogStringOne)!)) doLog = true 352 | } 353 | doLog && print(...args) 354 | } 355 | } 356 | 357 | /** Formats the date with the given format string. Formating characters: 358 | * 359 | * | format | description | 360 | * |:------:|:-----------------------------------------------| 361 | * | u | microseconds | 362 | * | S | milliseconds | 363 | * | s | seconds | 364 | * | m | minutes | 365 | * | h | hours (24h format, 12:00, 13:00, 24:00, 01:00) | 366 | * | H | hours (12h format, 12 PM, 1 PM, 12 AM, 1 AM) | 367 | * | P | `AM`/`PM` indicator | 368 | * | d | day (first 3 letters of the day of the week) | 369 | * | D | day (of month) | 370 | * | M | month (number) | 371 | * | N | month (3 first letters of the month name) | 372 | * | Y | year | 373 | * 374 | * _Note!_ To escape some character that are listed in formating - use backslash symbol `\` before character (you would probably need second one, to escape the escape character like `\n`, `\t` or others depending on where you write the format). 375 | * _Note!_ `microseconds` are obtained with the high precision time in milliseconds from the script start time, which has a part after period, that indicates microseconds. So it's probably not syncronized with the computer's clock time, but it can be used as a timestamp in the time. 376 | * *Examples*: 377 | * | format | description | 378 | * |:--------------|:--------------------------------------------------------------------------------------------------------| 379 | * | "s:m:h D.M.Y" | `seconds:minutes:hours day(of month).month(number).year` | 380 | * | "Y N d m:h" | `year month(3 first letters of the month name) day(first 3 letters of the day of the week) minutes:hours` | 381 | * | "m:s:S.u" | `minutes:seconds:milliseconds.microseconds` | 382 | * | "s/m/h" | `seconds/minutes/hours` | 383 | * | "m:h" | `minutes:hours` | 384 | * 385 | * @returns formated string with substituted values from the date, escaping all `\` 386 | */ 387 | export function formatDate( 388 | date = new Date(), 389 | format = getGlobalOption("timestampFormat") as F, 390 | ) { 391 | var absolute = date.getTime() + (performance.now() % 1_000) 392 | return ( 393 | format 394 | // Microseconds 395 | .replace( 396 | /(?= 12 ? "PM" : "AM") 421 | // Removing all leading `\` for escaping characters 422 | .replace(/\\(?=\S)/g, "") 423 | ) 424 | } 425 | 426 | /** Returns `true` if at least one argument is nullable (`undefined`|`null`) */ 427 | export function hasNullable(...args: unknown[]) { 428 | return args.some((e) => e === undefined || e === null) 429 | } 430 | 431 | /** Memoize the function and arguments to it, and return an interactive object to interactive get certain properties from it 432 | * @field value - call the function for the first time, store result in cache, and on eacn access - return a cached result (`override` getter overrides the cached value) 433 | * @field call - call the function with given parameters, and return a value, not overriding the cache 434 | * @field override - call the function with arguments, override the value in cache, and return the value 435 | * @field fn - the given function 436 | * @field args - the given arguments */ 437 | export function memoize(fn: (...args: P) => T, ...args: Readonly

) { 438 | var result: T, 439 | hasResult = false 440 | 441 | /** Interactive object with different getters to interact with the given function, arguments, and their result */ 442 | return { 443 | /** call the function for the first time, store result in cache, and on eacn access - return a cached result (`override` getter overrides the cached value) */ 444 | get value() { 445 | if (!hasResult) { 446 | result = fn(...args) 447 | hasResult ||= true 448 | } 449 | return result 450 | }, 451 | /** call the function with given parameters, and return a value, not overriding the cache */ 452 | get call() { 453 | return fn(...args) 454 | }, 455 | /** call the function with arguments, override the value in cache, and return the value */ 456 | get override() { 457 | result = fn(...args) 458 | hasResult ||= true 459 | return result 460 | }, 461 | /** the given function */ 462 | get fn() { 463 | return fn 464 | }, 465 | /** the given arguments */ 466 | get args() { 467 | return args 468 | }, 469 | } 470 | } 471 | 472 | export function checkValidRequestParams( 473 | c: Context, 474 | { size, gifsize, shift, resize, fps, squeeze, objects }: ChechValidPetPetParams, 475 | ) { 476 | var badRequest = (message: string) => 477 | c.json( 478 | { 479 | ok: false as const, 480 | code: 400 as const, 481 | message, 482 | statusText: "Bad Request" as const, 483 | }, 484 | { status: 400 as const }, 485 | ) 486 | 487 | if (size && !/^\d+$/.test(size)) 488 | return badRequest("Invalid size parameter. Usage: '{Integer}'. Ex: '20', '90'") 489 | if (size && /^\d+$/.test(size) && +size < 1) 490 | return badRequest("Size parameter out of range. Use only positive values [1, +Infinity)") 491 | if (gifsize && !/^\d+$/.test(gifsize)) 492 | return badRequest("Invalid gifsize parameter. Usage: '{Integer}'. Ex: '100', '150'") 493 | if (gifsize && /^\d+$/.test(gifsize) && +gifsize < 1) 494 | return badRequest("Gifsize parameter out of range. Use only positive values [1, +Infinity)") 495 | if (shift && !/^-?\d+x-?\d+$/.test(shift)) 496 | return badRequest( 497 | "Invalid shift parameter. Usage: '{Integer}x{Integer}'. Ex: '-5x20', '15x10'", 498 | ) 499 | if ( 500 | shift && 501 | /^-?\d+x-?\d+$/.test(shift) && 502 | !shift 503 | .split("x") 504 | ?.map((e) => /-?\d+/.test(e) && !isNaN(+e)) 505 | .every((e) => e) 506 | ) 507 | return badRequest( 508 | "Invalid shift parameter value. Use only integers in range (-Infinity, +Infinity)", 509 | ) 510 | if (resize && !/^-?\d+x-?\d+$/.test(resize)) 511 | return badRequest( 512 | "Invalid resize parameter. Usage: '{Integer}x{Integer}'. Ex: '-5x20', '15x10'", 513 | ) 514 | if ( 515 | resize && 516 | /^-?\d+x-?\d+$/.test(resize) && 517 | !resize 518 | .split("x") 519 | ?.map((e) => /-?\d+/.test(e) && !isNaN(+e)) 520 | .every((e) => e) 521 | ) 522 | return badRequest( 523 | "Invalid resize parameter value. Use only integers in range (-Infinity, +Infinity)", 524 | ) 525 | if (fps && !/^\d?\d$/.test(fps)) 526 | return badRequest( 527 | "Invalid fps parameter. Usage: '{Integer}'. Ex: '10', '14' Please take notice about gif frame rate compatiability. Read 'https://www.fileformat.info/format/gif/egff.htm' for more information", 528 | ) 529 | if (fps && /^\d?\d$/.test(fps) && (+fps < 1 || +fps > 50)) 530 | return badRequest( 531 | "Invalid fps parameter value. Use only integers in range [1, 50] (recomended). Please take notice about gif frame rate compatiability. Read 'https://www.fileformat.info/format/gif/egff.htm' for more information", 532 | ) 533 | if (squeeze && !/^-?\d+$/.test(squeeze)) 534 | return badRequest("Invalid squeeze parameter. Usage: '{Integer}'. Ex: '10', '-20'") 535 | if (squeeze && /^-?\d+$/.test(squeeze) && isNaN(+squeeze)) 536 | return badRequest("Invalid squeeze parameter value. Use only integers") 537 | if (objects) { 538 | if (objects.includes(",") && !/^both|(hand|avatar),(?!\1)(hand|avatar)$/.test(objects)) 539 | return badRequest( 540 | `Invalid specified objects: '${objects}'. Use 'both', one of these: '${objectsTypes.join("', '")}' or listed objects in conbination separated by coma. Ex: 'hand,avatar', 'avatar', 'hand', 'both'`, 541 | ) 542 | else if (!/^both|(hand|avatar)$/.test(objects)) 543 | return badRequest( 544 | `Invalid specified object: '${objects}'. Use 'both', one of these: '${objectsTypes.join("', '")}' or listed objects in conbination separated by coma. Ex: 'hand,avatar', 'avatar', 'hand', 'both'`, 545 | ) 546 | } 547 | } 548 | 549 | export function updateObject>(original: T, source: T) { 550 | for (var key in source) if (Object.hasOwn(original, key)) original[key] = source[key] 551 | } 552 | 553 | export function isString(obj: unknown): obj is string { 554 | return typeof obj === "string" 555 | } 556 | 557 | export function isNumber(obj: unknown): obj is number { 558 | return typeof obj === "number" && !isNaN(obj) && Number.isFinite(obj) 559 | } 560 | export function isStringNumber(obj: unknown): obj is `${number}` { 561 | return typeof obj === "string" && /^-?\d+$/.test(obj) && isNumber(+obj) 562 | } 563 | 564 | export function isBoolean(obj: unknown): obj is boolean { 565 | return typeof obj === "boolean" 566 | } 567 | export function isStringBoolean(obj: unknown): obj is `${boolean}` { 568 | return typeof obj === "string" && /^(true|false)$/.test(obj) 569 | } 570 | export function isStringTrueBoolean(obj: `${boolean}`): obj is "true" { 571 | return typeof obj === "string" && /^true$/.test(obj) 572 | } 573 | 574 | export function hasConfigFile() { 575 | return getGlobalConfigOption("useConfig") && fs.readdirSync(ROOT_PATH).includes("config.toml") 576 | } 577 | 578 | /** Try to get the config file, and try to parse it 579 | * if everything went good - resolve `true`. If there was an error - resolve `false`. If config doesn't exist - resolve `true` */ 580 | export function getConfig(reload = false): AlwaysResolvingPromise { 581 | setState("configuring") 582 | reload && resetGlobalOptionsHandler(false) 583 | return new Promise((resolve) => { 584 | if (hasConfigFile()) 585 | Bun.file(join(ROOT_PATH, "config.toml")) 586 | .text() 587 | .then(TOML.parse) 588 | .then(parseToml, (e) => { 589 | verboseError( 590 | e, 591 | error("Error while parsing TOML config file:\n"), 592 | error("Error while parsing TOML config file"), 593 | ) 594 | }) 595 | .then( 596 | () => { 597 | setState("ready") 598 | resolve(true) 599 | }, 600 | (e) => { 601 | verboseError( 602 | e, 603 | error("Error while parsing toml configuration file:\n"), 604 | error("Error while parsing toml configuration file"), 605 | ) 606 | setState("ready") 607 | resolve(false) 608 | }, 609 | ) 610 | else { 611 | setState("ready") 612 | resolve(true) 613 | } 614 | }) 615 | } 616 | 617 | export function sameType(value1: T1, value2: T2) { 618 | return typeof value1 === typeof value2 619 | } 620 | 621 | /** Log the error message depending on `verboseErrors` global option, if enabled - first message only. Else second + error itself */ 622 | export function verboseError(error: Error, onError: any, offError: any) { 623 | if (getGlobalOption("verboseErrors")) log("error", onError, error) 624 | else log("error", offError) 625 | } 626 | 627 | export function parseLogOption( 628 | option: string, 629 | ): -1 | (LogOptionLong | undefined)[] | { duplicate: LogOptionLong } | { notFound: string } { 630 | var result: (LogOptionLong | undefined)[] = [] 631 | if (isStringNumber(option)) { 632 | var level = +option 633 | if (level < 0 || level > 6) return -1 634 | for (; level-- > 0; ) { 635 | var o = normalizeLogOption(level as LogLevel) 636 | if (o !== undefined) result.push(o) 637 | } 638 | } else { 639 | if (option.includes(",")) { 640 | var options = option.split(/,/g) as (LogOptionLong | LogOptionShort)[] 641 | for (var opt of options) { 642 | var final = normalizeLogOption(opt) 643 | if (result !== undefined && result.includes(final)) return { duplicate: final! } 644 | else result.push(final) 645 | } 646 | } else { 647 | var r = normalizeLogOption(option as LogOptionShort | LogOptionLong) 648 | if (r === undefined) return { notFound: option } 649 | result.push(r) 650 | } 651 | } 652 | return result 653 | } 654 | 655 | /** Print raw information to the stdout, depending on where the script is running (TTY or not, to include ANSI escape codes or not) 656 | * All arguments will be converted to string, joined with empty string (`""`), printed to the stdout with `\n` at the end to flush the buffer */ 657 | export function print(...args: any[]) { 658 | try { 659 | Bun.write( 660 | Bun.stdout, 661 | args 662 | .map( 663 | stdout.isTTY 664 | ? String // if TTY => print as is 665 | : // if not TTY => delete all ANSI escape codes 666 | (e) => String(e).replace(/\u001b\[([\d;]+m|(\d+)?[a-zA-Z])/g, ""), 667 | ) 668 | .join() + "\n", 669 | ) 670 | } catch (e) { 671 | console.log({ error: e }) 672 | } 673 | } 674 | 675 | export function formatObject(obj: Record) { 676 | return Object.entries(obj) 677 | .map(([k, v]) => `${gray(k)}: ${colorValue(v)}`) 678 | .join(`${gray(",")} `) 679 | } 680 | --------------------------------------------------------------------------------