26 | {#if images.status === "complete"}
27 | {#each images.value as image, idx}
28 | {
29 | var end = performance.now()
30 | console.log(`Took ${(end - start).toFixed(2)} ms to render whole page to last image`)
31 | } : undefined} />
32 | {/each}
33 | {:else if images.status === "loading"}
34 |
Loading images...
35 | {:else if images.status === "no-content"}
36 |
No images provided
37 | {:else if images.status === "cache-disabled"}
38 |
Cache is disabled. No images
39 | {/if}
40 |
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
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