├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── TestApp ├── .prettierignore ├── bun.lockb ├── fornax.config.ts ├── main.ts ├── package.json ├── src │ ├── client │ │ ├── app.component.ts │ │ ├── assets │ │ │ ├── logo.png │ │ │ └── logo.svg │ │ ├── components │ │ │ ├── emitting.component.css │ │ │ ├── emitting.component.html │ │ │ ├── emitting.component.ts │ │ │ ├── hello-world.component.ts │ │ │ └── other.component.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── routes.ts │ │ └── services │ │ │ └── api.service.ts │ └── server │ │ ├── controllers │ │ └── event.controller.ts │ │ └── models │ │ └── event.ts └── tsconfig.json ├── bun.lockb ├── core ├── BaseComponent.ts ├── Context.ts ├── Decorators.ts ├── EventEmitter.ts ├── LRUCache.ts ├── Models.ts ├── Parser.ts ├── Routing.ts ├── ServerDecorators.ts ├── ServerUtilities.ts ├── Template.ts ├── Utilities.ts ├── assets │ ├── favicon.ico │ ├── logo.png │ └── logo.svg ├── schematics │ ├── index.ts │ └── templates.toml ├── scripts │ ├── build.ts │ ├── cli.ts │ ├── client.ts │ ├── constants.ts │ ├── generate-imports.ts │ ├── global-styles.ts │ ├── live-reload.ts │ ├── load-config.ts │ └── server.ts └── types │ ├── global.d.ts │ └── zod-extensions.d.ts ├── index.ts ├── package.json ├── server.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [TBosak] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | #Test application 178 | TestApp/node_modules 179 | TestApp/dist -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tim Barani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |

Fornax 6 | 7 | ![GitHub Repo stars](https://img.shields.io/github/stars/tbosak/fornax) 8 | ![NPM Downloads](https://img.shields.io/npm/dw/fornaxjs) 9 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/tbosak/fornax) 10 | ![GitHub last commit](https://img.shields.io/github/last-commit/tbosak/fornax) 11 | 12 |

13 |
14 |
15 | Fornax is a lightweight, opinionated, and highly customizable Bun-powered full-stack web framework designed to simplify building single-page applications with custom components, routing, and flexible styling options. 16 |
17 | 18 | **Key Features** 🔑 19 | - **Custom Components** 🧩: Define reusable UI elements using decorators and TypeScript classes. 20 | - **Routing Made Easy** 🗺️: Leverage a `` and a straightforward `routes.ts` configuration for SPA navigation. 21 | - **Flexible Styling Modes** 🎨: Choose between scoped and global styling for your components. 22 | - **TypeScript by Default** 💻: Enjoy type safety and clean code with TypeScript integration. 23 | 24 | --- 25 | 26 | ## Getting Started 🏁 27 | 28 | ### Prerequisites ✅ 29 | - **Bun** 🍞: Install Bun from [https://bun.sh/](https://bun.sh/) 30 | 31 | ### Installation ⚙️ 32 | Create a new Fornax project: 33 | ```bash 34 | bunx fnx generate project 35 | ``` 36 | OR just: 37 | ```bash 38 | bunx fnx 39 | ``` 40 | Then follow the prompts to generate from schematics. 41 | 42 | If adding Fornax to your existing Bun project: 43 | 44 | ```bash 45 | bun add fornaxjs 46 | ``` 47 | 48 | Create a `fornax.config.ts` in your project’s root to configure directories, ports, custom plugins (style-loader is included by default for css imports), and extra entry points: 49 | 50 | ```typescript 51 | export default { 52 | Client: { 53 | srcDir: "./src/client", 54 | distDir: "./dist", 55 | port: 5000, 56 | plugins: [], 57 | entryPoints: [], 58 | alternateStyleLoader: null, 59 | }, 60 | Server: { 61 | dir: "./src/server", 62 | port: 5500, 63 | }, 64 | }; 65 | 66 | ``` 67 | 68 | Adjust as needed. 69 | 70 | ### Project Structure 🗂️ 71 | 72 | A typical Fornax project might look like this: 73 | 74 | ``` 75 | project/ 76 | ├─ src/ 77 | | ├─ client/ 78 | │ | ├─ index.html 79 | │ | ├─ routes.ts 80 | │ | ├─ app.component.ts 81 | │ │ ├─ components/ 82 | │ │ │ ├─ some.component.ts 83 | │ │ │ ├─ other.component.ts 84 | │ │ ├─ assets/ 85 | | | ├─ services/ 86 | | ├─ server/ 87 | | | ├─ controllers/ 88 | | | | ├─ some.controller.ts 89 | | | ├─ models/ 90 | | | | ├─ some.ts 91 | ├─ fornax.config.ts 92 | └─ main.ts 93 | ``` 94 | 95 | - `index.html`: Your application’s HTML entry point. 96 | - `main.ts`: Dynamically generated entry that imports all components and routes. 97 | - `routes.ts`: Defines the application’s client-side routes. 98 | - `app/components/`: Store your custom components here. 99 | 100 | ### Running the Dev Server 🔧 101 | 102 | ```bash 103 | fnx dev 104 | ``` 105 | 106 | This starts: 107 | 108 | - Bun as a back-end/static server with watch mode. 109 | 110 | ### Building for Production 🏗️ 111 | 112 | ```bash 113 | fnx build 114 | ``` 115 | 116 | Outputs bundled files into the `dist` directory. 117 | 118 | ### Starting the App 🏃 119 | 120 | After building, start the server without watch mode: 121 | 122 | ```bash 123 | fnx start 124 | ``` 125 | 126 | Open `http://localhost:5000` to view your application. 127 | 128 | --- 129 | 130 | ## Styling Modes 🎨 131 | 132 | Fornax supports two style modes for your components: 133 | 134 | - **Scoped:** ` 10 | 11 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /TestApp/src/client/components/emitting.component.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | color: brown !important; 3 | } 4 | -------------------------------------------------------------------------------- /TestApp/src/client/components/emitting.component.html: -------------------------------------------------------------------------------- 1 |

Testing

2 | 3 |

4 | 7 | -------------------------------------------------------------------------------- /TestApp/src/client/components/emitting.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | BaseComponent, 4 | ViewChild, 5 | EventEmitter, 6 | Output, 7 | } from "fornaxjs"; 8 | import html from "./emitting.component.html" with { type: "text" }; 9 | import styles from "./emitting.component.css"; 10 | 11 | @Component({ 12 | selector: "app-emitting", 13 | template: html, 14 | style: styles, 15 | }) 16 | export class Emitting extends BaseComponent { 17 | @ViewChild("#clickMe") clickMe!: HTMLButtonElement; 18 | @Output() buttonClicked!: EventEmitter; 19 | txtHidden = false; 20 | listOfItems = ["Item 1", "Item 2", "Item 3"]; 21 | 22 | onInit(): void { 23 | this.clickMe?.addEventListener("click", this.handleClick.bind(this)); 24 | } 25 | 26 | handleClick(): void { 27 | this.txtHidden = !this.txtHidden; 28 | console.log("Woah!"); 29 | this.buttonClicked.emit("Woah!"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /TestApp/src/client/components/hello-world.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, BaseComponent, Context } from "fornaxjs"; 2 | import { ApiService } from "../services/api.service"; 3 | 4 | @Component({ 5 | selector: "hello-world", 6 | template: ` 7 | 8 |

Hello {{ name }}!

9 |

{{ apiResponse }}

10 | Star us on GitHub! 13 | `, 14 | style: ` 15 | img { width: 10em; margin-bottom: 2em; } 16 | h1 { color: blue !important; } 17 | p { font-family: simport { const } from './../../../node_modules/bun-types/bun.d'; 18 | ans-serif; } 19 | #bottom-corner { position: fixed; bottom: 0; right: 0; padding: 1em; background: #333; color: white; text-decoration: none; } 20 | `, 21 | }) 22 | export class HelloWorld extends BaseComponent { 23 | name = "World"; 24 | apiResponse = "Loading..."; 25 | names: string[] = ["World", "GitHub", "Reddit", "Friends"]; 26 | interval: any = setInterval(() => this.cycleNames(), 2000); 27 | 28 | cycleNames() { 29 | let name = this.names.shift() as string; 30 | this.names.push(name); 31 | this.name = name; 32 | } 33 | 34 | onInit(): void { 35 | const apiService: ApiService = Context.get("ApiService"); 36 | this.apiResponse = apiService.getData(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /TestApp/src/client/components/other.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, BaseComponent } from "fornaxjs"; 2 | // prettier-ignore 3 | @Component({ 4 | selector: "app-other", 5 | template: ` 6 |

Other Page

7 |

This is the other page, accessible at "/other".

8 | 9 | `, 10 | style: ` 11 | h1 { color: green !important; } 12 | p { font-family: sans-serif; } 13 | `, 14 | }) 15 | export class Other extends BaseComponent { 16 | logClick(event: CustomEvent): void { 17 | console.log("Route params:", this.params); // Debug log 18 | console.log("Button clicked! Event detail:", event.detail); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /TestApp/src/client/favicon.ico: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /TestApp/src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Welcome 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /TestApp/src/client/routes.ts: -------------------------------------------------------------------------------- 1 | import { addRouter } from "fornaxjs"; 2 | import { HelloWorld } from "./components/hello-world.component"; 3 | import { Other } from "./components/other.component"; 4 | 5 | export const routes: any[] = [ 6 | { path: "/", component: HelloWorld }, 7 | { path: "/other/:id", component: Other }, 8 | { path: "/test", component: Other, canActivate: myCustomGuard }, 9 | ]; 10 | 11 | addRouter("router-outlet", routes); 12 | 13 | function myCustomGuard(context: any, commands: any) { 14 | alert("You are not allowed here!"); 15 | return commands.redirect("/other/1"); 16 | } 17 | -------------------------------------------------------------------------------- /TestApp/src/client/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { Service } from "fornaxjs"; 2 | 3 | @Service("ApiService") 4 | export class ApiService { 5 | getData() { 6 | return "Welcome to Fornax!"; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /TestApp/src/server/controllers/event.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | ControllerBase, 5 | Post, 6 | type Context, 7 | } from "fornaxjs/server"; 8 | import { Event } from "../models/event"; 9 | 10 | @Controller("/events") 11 | export class EventController extends ControllerBase { 12 | @Post("/", { body: Event }, Event) 13 | async createEvent(ctx: Context) { 14 | const body = await ctx.req.json(); 15 | return this.Ok(ctx, body); 16 | } 17 | 18 | @Get("/:id", { params: Number }, Event) 19 | async getEvent(ctx: Context) { 20 | const id = ctx.req.param("id"); 21 | return this.Ok(ctx, { 22 | id, 23 | name: "Fornax Launch Party", 24 | startTime: "2023-12-21T15:30:00Z", 25 | attendees: 50, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /TestApp/src/server/models/event.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Model, 3 | String, 4 | Number, 5 | ISODate, 6 | OptionalISODate, 7 | } from "fornaxjs/server"; 8 | 9 | @Model() 10 | export class Event { 11 | @String({ example: "1", description: "Unique identifier for the event" }) 12 | id!: string; 13 | 14 | @String({ example: "Fornax Launch Party", description: "Event name" }) 15 | name!: string; 16 | 17 | @ISODate({ 18 | example: "2023-12-21T15:30:00Z", 19 | description: "Event start date and time", 20 | }) 21 | startTime!: string; 22 | 23 | @OptionalISODate({ 24 | example: "2023-12-22T15:30:00Z", 25 | description: "Event end date and time", 26 | }) 27 | endTime?: string; 28 | 29 | @Number({ example: 50, description: "Number of attendees expected" }) 30 | attendees!: number; 31 | } 32 | -------------------------------------------------------------------------------- /TestApp/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 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "typeRoots": ["./node_modules/fornaxjs/core/types"], 13 | // Bundler mode 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "verbatimModuleSyntax": true, 17 | "noEmit": true, 18 | 19 | // Best practices 20 | "strict": true, 21 | "skipLibCheck": true, 22 | "noFallthroughCasesInSwitch": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TBosak/fornax/0c331e5f2bb631a3205252762aa44da816fd1b3b/bun.lockb -------------------------------------------------------------------------------- /core/BaseComponent.ts: -------------------------------------------------------------------------------- 1 | import { Template } from "./Template"; 2 | import { Parser } from "./Parser"; 3 | import { toCamelCase, toKebabCase } from "./Utilities"; 4 | import { Binding, ComponentConfig } from "./Models"; 5 | import { globalStyles } from "./scripts/global-styles"; 6 | 7 | export class BaseComponent extends HTMLElement { 8 | public __config: ComponentConfig; 9 | template: Template; 10 | model: any; 11 | static inputs: string[] = []; 12 | static outputs: string[] = []; 13 | private _shadow: ShadowRoot; 14 | private renderScheduled: boolean = false; 15 | private reactivePropsCache = new Map(); 16 | private observer: IntersectionObserver; 17 | private _isConnected = false; 18 | private idleCallbackId: number | null = null; 19 | public params: Readonly>; 20 | 21 | connectedCallback() { 22 | this._isConnected = true; 23 | this._shadow = this.attachShadow({ mode: "open" }); 24 | this.initializeComponent(); 25 | 26 | // Observe visibility changes 27 | this.observer = new IntersectionObserver( 28 | (entries) => { 29 | entries.forEach((entry) => { 30 | this._isConnected = entry.isIntersecting; 31 | if (!entry.isIntersecting) { 32 | cancelIdleCallback(this.idleCallbackId); 33 | } 34 | }); 35 | }, 36 | { threshold: 0.1 }, 37 | ); 38 | 39 | this.observer.observe(this); 40 | 41 | requestAnimationFrame(() => { 42 | setTimeout(() => { 43 | if (typeof this.onInit === "function") { 44 | this.onInit(); 45 | } 46 | }, 0); 47 | }); 48 | } 49 | 50 | private async initializeComponent() { 51 | const globalCSS = 52 | this.__config.styleMode !== "scoped" ? await globalStyles : ""; 53 | const combinedStyles = 54 | this.__config.styleMode === "scoped" 55 | ? this.__config.style 56 | : `${this.__config.style || ""}\n${globalCSS}`; 57 | 58 | if (this.__config.template) { 59 | this.__config.style = combinedStyles; 60 | this.init(); 61 | } else { 62 | console.warn("Template is not defined for the component."); 63 | } 64 | } 65 | 66 | disconnectedCallback() { 67 | this._isConnected = false; 68 | 69 | // Cancel any pending requestIdleCallback 70 | if (this.idleCallbackId !== null) { 71 | cancelIdleCallback(this.idleCallbackId); 72 | this.idleCallbackId = null; 73 | } 74 | 75 | if (typeof this.onDestroy === "function") { 76 | this.onDestroy(); 77 | } 78 | } 79 | 80 | private init(): void { 81 | this.template = new Template(this.__config.template!); 82 | this.setupReactiveProperties(); 83 | this.render(true); 84 | } 85 | 86 | private extractTemplateProperties(template: string): string[] { 87 | if (this.reactivePropsCache.has(template)) { 88 | return this.reactivePropsCache.get(template)!; 89 | } 90 | 91 | const propertyRegex = /{{\s*([a-zA-Z0-9_]+)\s*}}/g; // Match {{ prop }} 92 | const ifDirectiveRegex = /\*if="([^"]+)"/g; // Match *if="condition" 93 | const forDirectiveRegex = /\*for="([^"]+)\s+of\s+([^"]+)"/g; // Match *for="item of collection" 94 | 95 | const matches = new Set(); 96 | 97 | // Extract properties from {{ }} bindings 98 | let match; 99 | while ((match = propertyRegex.exec(template)) !== null) { 100 | matches.add(match[1]); 101 | } 102 | 103 | // Extract properties from *if directives 104 | while ((match = ifDirectiveRegex.exec(template)) !== null) { 105 | const condition = match[1]; 106 | const conditionProps = condition.match(/[a-zA-Z0-9_]+/g); // Extract individual properties 107 | if (conditionProps) { 108 | conditionProps.forEach((prop) => matches.add(prop)); 109 | } 110 | } 111 | 112 | // Extract properties from *for directives 113 | while ((match = forDirectiveRegex.exec(template)) !== null) { 114 | const [, item, collection] = match; 115 | matches.add(item); // Add the item variable 116 | matches.add(collection); // Add the collection variable 117 | } 118 | 119 | const props = Array.from(matches); 120 | this.reactivePropsCache.set(template, props); 121 | return props; 122 | } 123 | 124 | private setupReactiveProperties(): void { 125 | if (!this.__config?.template) return; 126 | 127 | const reactiveProps = this.extractTemplateProperties( 128 | this.__config.template, 129 | ); 130 | const proto = Object.getPrototypeOf(this); 131 | 132 | reactiveProps.forEach((key) => { 133 | if (typeof this[key] !== "function" && !key.startsWith("__")) { 134 | let internalValue = this[key]; 135 | 136 | const descriptor = 137 | Object.getOwnPropertyDescriptor(this, key) || 138 | Object.getOwnPropertyDescriptor(proto, key); 139 | 140 | if (!descriptor || (!descriptor.get && !descriptor.set)) { 141 | Object.defineProperty(this, key, { 142 | get: () => internalValue, 143 | set: (newVal) => { 144 | if (internalValue !== newVal) { 145 | internalValue = newVal; 146 | this.setModel(); 147 | this.scheduleRender(); 148 | } 149 | }, 150 | configurable: true, 151 | enumerable: true, 152 | }); 153 | } else { 154 | // Respect existing getter and setter logic 155 | const originalGet = descriptor.get; 156 | const originalSet = descriptor.set; 157 | 158 | Object.defineProperty(this, key, { 159 | get: originalGet || (() => internalValue), 160 | set: (newVal) => { 161 | if (originalSet) { 162 | originalSet.call(this, newVal); 163 | } else { 164 | internalValue = newVal; 165 | } 166 | this.setModel(); 167 | this.scheduleRender(); 168 | }, 169 | configurable: true, 170 | enumerable: true, 171 | }); 172 | } 173 | } 174 | }); 175 | 176 | this.model = {}; 177 | this.setModel(); 178 | } 179 | 180 | private setModel() { 181 | for (const key of Object.keys(this)) { 182 | if (typeof this[key] !== "function" && !key.startsWith("__")) { 183 | this.model[key] = this[key]; 184 | } 185 | } 186 | } 187 | 188 | private scheduleRender(): void { 189 | if (!this._isConnected) return; // Prevent rendering if disconnected 190 | 191 | if (!this.renderScheduled) { 192 | this.renderScheduled = true; 193 | 194 | requestAnimationFrame(() => { 195 | // Cancel any previous idle task before scheduling a new one 196 | if (this.idleCallbackId !== null) { 197 | cancelIdleCallback(this.idleCallbackId); 198 | } 199 | 200 | this.idleCallbackId = requestIdleCallback(() => { 201 | this.processRender(); 202 | this.idleCallbackId = null; 203 | }); 204 | }); 205 | } 206 | } 207 | 208 | private processRender(): void { 209 | if (!this._isConnected) return; 210 | this.render(); 211 | this.renderScheduled = false; 212 | } 213 | 214 | private async render(initial: boolean = false): Promise { 215 | if (!this._shadow) { 216 | console.error("Shadow root is not attached."); 217 | return; 218 | } 219 | 220 | const parser = Parser.sharedInstance(); 221 | const renderResult = this.template.render(this.model, this) as [ 222 | string, 223 | Binding[], 224 | ]; 225 | const [templateString, bindings] = renderResult; 226 | 227 | const patchFn = parser.createPatch(templateString); 228 | 229 | const processChunk = () => { 230 | try { 231 | patchFn(this._shadow); 232 | } catch (error) { 233 | console.error("Error rendering component", error); 234 | } 235 | }; 236 | 237 | // Clear `_renderComplete` before starting a new render 238 | if (initial) { 239 | const sheet = new CSSStyleSheet(); 240 | sheet.replaceSync(this.__config.style || ""); 241 | this._shadow.adoptedStyleSheets = [sheet]; 242 | bindings.forEach(({ eventName, handlerName }) => { 243 | this.addEventListener(eventName, (event) => { 244 | const handler = this[handlerName]; 245 | if (typeof handler === "function") { 246 | handler.call(this, event); 247 | } else { 248 | console.warn( 249 | `Handler '${handlerName}' is not defined in component:`, 250 | this, 251 | ); 252 | } 253 | }); 254 | }); 255 | } 256 | 257 | processChunk(); 258 | if (typeof this.onRenderComplete === "function") { 259 | this.onRenderComplete(); 260 | } 261 | } 262 | 263 | static get observedAttributes() { 264 | return this.inputs.map(toKebabCase); 265 | } 266 | 267 | attributeChangedCallback(name: string, oldValue: string, newValue: string) { 268 | const propName = toCamelCase(name); 269 | if ((this.constructor as typeof BaseComponent).inputs.includes(propName)) { 270 | this[propName] = newValue; 271 | } 272 | } 273 | 274 | onInit(): void {} 275 | onDestroy(): void {} 276 | onRenderComplete(): void {} 277 | } 278 | -------------------------------------------------------------------------------- /core/Context.ts: -------------------------------------------------------------------------------- 1 | type ServiceFactory = () => T; 2 | 3 | export class Context { 4 | private static context = new Map>(); 5 | private static instances = new Map(); // Cache for service instances 6 | 7 | // Register a factory for a service 8 | static provide(key: string, factory: ServiceFactory): void { 9 | this.context.set(key, factory); 10 | } 11 | 12 | // Retrieve the service instance (lazy initialization) 13 | static get(key: string): T { 14 | // If the instance already exists, return it 15 | if (this.instances.has(key)) { 16 | return this.instances.get(key); 17 | } 18 | 19 | // Otherwise, create and cache the instance 20 | const factory = this.context.get(key); 21 | if (factory) { 22 | const instance = factory(); 23 | this.instances.set(key, instance); 24 | return instance; 25 | } 26 | 27 | throw new Error(`Service ${key} is not registered`); 28 | } 29 | 30 | // Optional: Remove a service instance 31 | static remove(key: string): void { 32 | this.instances.delete(key); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/Decorators.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "./EventEmitter"; 2 | import { ComponentConfig } from "./Models"; 3 | import { Context } from "./Context"; 4 | import "reflect-metadata"; 5 | 6 | //Client Decorators 7 | 8 | export function Component(config: ComponentConfig) { 9 | return function (target: T) { 10 | // Create a class that extends the original target and stores the config 11 | const customElementClass = class extends target { 12 | __config = config; 13 | 14 | constructor(...args: any[]) { 15 | super(...args); 16 | } 17 | }; 18 | 19 | // Define the custom element using the provided selector from config 20 | if (customElements.get(config.selector)) { 21 | // The custom element is defined 22 | } else { 23 | // The custom element is not defined 24 | customElements.define(config.selector, customElementClass); 25 | } 26 | target["selector"] = config.selector; 27 | // Return the newly extended and defined class 28 | return customElementClass; 29 | }; 30 | } 31 | 32 | export function Input() { 33 | return function (target: any, propertyKey: string) { 34 | // Store the list of inputs on the class constructor 35 | 36 | if (!target.constructor.inputs) { 37 | target.constructor.inputs = []; 38 | } 39 | target.constructor.inputs.push(propertyKey); 40 | 41 | // Define getter and setter to observe property changes 42 | 43 | let value = target[propertyKey]; 44 | 45 | Object.defineProperty(target, propertyKey, { 46 | get() { 47 | return value; 48 | }, 49 | set(newVal) { 50 | value = newVal; 51 | if (typeof target.render === "function") { 52 | target.scheduleRender(); 53 | } 54 | }, 55 | enumerable: true, 56 | configurable: true, 57 | }); 58 | }; 59 | } 60 | 61 | export function Output(eventName?: string): PropertyDecorator { 62 | return function (target: any, propertyKey: string | symbol): void { 63 | const actualEventName = eventName || propertyKey.toString(); 64 | 65 | Object.defineProperty(target, propertyKey, { 66 | get() { 67 | // Lazily initialize the Subject if it doesn't already exist 68 | if (!this[`__${String(propertyKey)}`]) { 69 | const emitter = new EventEmitter(); 70 | emitter.subscribe((value: any) => { 71 | // Dispatch the event when the Subject emits 72 | const event = new CustomEvent(actualEventName, { 73 | detail: value, 74 | bubbles: true, 75 | composed: true, // Allow crossing shadow DOM boundaries 76 | }); 77 | this.dispatchEvent(event); 78 | }); 79 | this[`__${String(propertyKey)}`] = emitter; 80 | } 81 | return this[`__${String(propertyKey)}`]; 82 | }, 83 | set(newValue) { 84 | throw new Error( 85 | `Cannot overwrite @Output property '${String(propertyKey)}'.` 86 | ); 87 | }, 88 | configurable: true, 89 | enumerable: true, 90 | }); 91 | }; 92 | } 93 | 94 | export function Service(key: string) { 95 | return function (constructor: T) { 96 | const serviceKey = key; 97 | 98 | // Register a factory for the service 99 | Context.provide(serviceKey, () => new constructor()); 100 | }; 101 | } 102 | 103 | export function ViewChild(selector: string): PropertyDecorator { 104 | return function (target: any, propertyKey: string | symbol): void { 105 | const originalConnectedCallback = target.connectedCallback; 106 | 107 | target.connectedCallback = function (...args: any[]) { 108 | if (originalConnectedCallback) { 109 | originalConnectedCallback.apply(this, args); 110 | } 111 | 112 | const attemptToFindElement = () => { 113 | const element = 114 | this.shadowRoot?.querySelector(selector) || 115 | this.querySelector(selector); 116 | 117 | if (element) { 118 | this[propertyKey] = element; // Direct assignment 119 | return; 120 | } 121 | 122 | console.warn( 123 | `@ViewChild: Element with selector '${selector}' not found. Retrying...` 124 | ); 125 | requestAnimationFrame(attemptToFindElement); 126 | }; 127 | 128 | // Defer query until the next frame 129 | requestAnimationFrame(attemptToFindElement); 130 | }; 131 | }; 132 | } 133 | 134 | export function ViewChildren(selector: string): PropertyDecorator { 135 | return function (target: any, propertyKey: string | symbol): void { 136 | const originalConnectedCallback = target.connectedCallback; 137 | 138 | target.connectedCallback = function (...args: any[]) { 139 | if (originalConnectedCallback) { 140 | originalConnectedCallback.apply(this, args); 141 | } 142 | 143 | const attemptToFindElement = () => { 144 | const element = 145 | this.shadowRoot?.querySelectorAll(selector) || 146 | this.querySelectorAll(selector); 147 | 148 | if (element) { 149 | this[propertyKey] = element; // Direct assignment 150 | return; 151 | } 152 | 153 | console.warn( 154 | `@ViewChild: Element with selector '${selector}' not found. Retrying...` 155 | ); 156 | requestAnimationFrame(attemptToFindElement); 157 | }; 158 | 159 | // Defer query until the next frame 160 | requestAnimationFrame(attemptToFindElement); 161 | }; 162 | }; 163 | } 164 | 165 | // General Purpose Decorators 166 | //TEST FUNCTIONALITY 167 | export function Memoize() { 168 | return function ( 169 | target: any, 170 | propertyKey: string, 171 | descriptor: PropertyDescriptor 172 | ) { 173 | const originalMethod = descriptor.value; 174 | const cacheKey = Symbol(`__memoize_${propertyKey}`); 175 | 176 | descriptor.value = function (...args: any[]) { 177 | const instance = this as { [key: symbol]: Map }; 178 | 179 | if (!instance[cacheKey]) { 180 | instance[cacheKey] = new Map(); 181 | } 182 | 183 | const cache = instance[cacheKey]; 184 | const key = JSON.stringify(args); 185 | 186 | if (cache.has(key)) { 187 | return cache.get(key); 188 | } 189 | 190 | const result = originalMethod.apply(this, args); 191 | cache.set(key, result); 192 | return result; 193 | }; 194 | 195 | return descriptor; 196 | }; 197 | } 198 | 199 | const timeouts = new WeakMap>(); 200 | 201 | //TEST FUNCTIONALITY 202 | export function Debounce(delay: number) { 203 | return function ( 204 | target: any, 205 | propertyKey: string, 206 | descriptor: PropertyDescriptor 207 | ) { 208 | const originalMethod = descriptor.value; 209 | 210 | descriptor.value = function (...args: any[]) { 211 | let instanceTimeouts = timeouts.get(this); 212 | 213 | if (!instanceTimeouts) { 214 | instanceTimeouts = new Map(); 215 | timeouts.set(this, instanceTimeouts); 216 | } 217 | 218 | if (instanceTimeouts.has(propertyKey)) { 219 | clearTimeout(instanceTimeouts.get(propertyKey)!); 220 | } 221 | 222 | // Set a new timeout 223 | const timeout = setTimeout(() => { 224 | originalMethod.apply(this, args); 225 | instanceTimeouts.delete(propertyKey); 226 | }, delay); 227 | 228 | instanceTimeouts.set(propertyKey, timeout); 229 | }; 230 | 231 | return descriptor; 232 | }; 233 | } 234 | 235 | //TEST FUNCTIONALITY 236 | export function Watch(propertyKey: string) { 237 | return function (target: any, methodName: string) { 238 | const privateKey = `__${propertyKey}`; 239 | 240 | const originalDescriptor = 241 | Object.getOwnPropertyDescriptor(target, propertyKey) || {}; 242 | 243 | Object.defineProperty(target, propertyKey, { 244 | get() { 245 | if (!Object.prototype.hasOwnProperty.call(this, privateKey)) { 246 | this[privateKey] = originalDescriptor.value ?? undefined; 247 | } 248 | 249 | if (originalDescriptor.get) { 250 | return originalDescriptor.get.call(this); 251 | } 252 | return this[privateKey]; 253 | }, 254 | set(value: any) { 255 | const oldValue = this[propertyKey]; 256 | 257 | if (originalDescriptor.set) { 258 | originalDescriptor.set.call(this, value); 259 | } else { 260 | this[privateKey] = value; 261 | } 262 | 263 | if (oldValue !== value) { 264 | this[methodName](); 265 | } 266 | }, 267 | configurable: true, 268 | enumerable: true, 269 | }); 270 | }; 271 | } 272 | -------------------------------------------------------------------------------- /core/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from "rxjs"; 2 | 3 | export class EventEmitter extends Subject { 4 | emit(value: T): void { 5 | this.next(value); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /core/LRUCache.ts: -------------------------------------------------------------------------------- 1 | export class LRUCache { 2 | private capacity: number; 3 | private cache: Map; 4 | 5 | constructor(capacity: number) { 6 | if (capacity <= 0) { 7 | throw new Error("LRU Cache capacity must be greater than 0"); 8 | } 9 | this.capacity = capacity; 10 | this.cache = new Map(); 11 | } 12 | 13 | get(key: K): V | undefined { 14 | if (!this.cache.has(key)) { 15 | return undefined; 16 | } 17 | const value = this.cache.get(key)!; 18 | // Move key to the end to mark it as recently used 19 | this.cache.delete(key); 20 | this.cache.set(key, value); 21 | return value; 22 | } 23 | 24 | set(key: K, value: V): void { 25 | if (this.cache.has(key)) { 26 | // Remove existing key to update its position 27 | this.cache.delete(key); 28 | } else if (this.cache.size >= this.capacity) { 29 | // Evict the least recently used (first) key 30 | const lruKey = this.cache.keys().next().value; 31 | this.cache.delete(lruKey); 32 | } 33 | // Insert the key as the most recently used 34 | this.cache.set(key, value); 35 | } 36 | 37 | has(key: K): boolean { 38 | return this.cache.has(key); 39 | } 40 | 41 | clear(): void { 42 | this.cache.clear(); 43 | } 44 | 45 | size(): number { 46 | return this.cache.size; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/Models.ts: -------------------------------------------------------------------------------- 1 | import type { BunPlugin } from "bun"; 2 | import { BaseComponent } from "./BaseComponent"; 3 | import type { Commands } from "@vaadin/router"; 4 | import { Observable, BehaviorSubject, switchMap, interval, map } from "rxjs"; 5 | import type { Context } from "../server"; 6 | 7 | export interface Binding { 8 | eventName: string; 9 | handlerName: string; 10 | } 11 | 12 | export interface Route { 13 | path: string; 14 | component: typeof BaseComponent; 15 | canActivate?: GuardFn | GuardFn[]; 16 | } 17 | 18 | export type GuardFn = ( 19 | context: any, 20 | commands: Commands 21 | ) => boolean | Promise; 22 | 23 | export interface FornaxConfig { 24 | Client: { 25 | srcDir: string; 26 | distDir: string; 27 | port: number; 28 | plugins: BunPlugin[]; 29 | entryPoints: string[]; 30 | alternateStyleLoader?: BunPlugin; 31 | }; 32 | Server: { 33 | dir: string; 34 | port: number; 35 | cors?: { 36 | origin: 37 | | string 38 | | string[] 39 | | ((origin: string, c: Context) => string | undefined | null); 40 | allowMethods?: string[]; 41 | allowHeaders?: string[]; 42 | maxAge?: number; 43 | credentials?: boolean; 44 | exposeHeaders?: string[]; 45 | }; 46 | }; 47 | } 48 | 49 | export class ComponentConfig { 50 | selector: string; 51 | templateUrl?: string; 52 | styleUrl?: string; 53 | style?: string; 54 | template?: string; 55 | styleMode?: "scoped" | "global" = "global"; 56 | } 57 | 58 | export interface ServiceOptions { 59 | singleton?: boolean; 60 | } 61 | 62 | //TEST FUNCTIONALITY 63 | export class Loop extends Observable { 64 | private subject: BehaviorSubject; 65 | private items$: BehaviorSubject; 66 | private rate$: BehaviorSubject; 67 | 68 | constructor(items: T[], rate: number) { 69 | const subject = new BehaviorSubject(items[0]); 70 | const items$ = new BehaviorSubject(items); 71 | const rate$ = new BehaviorSubject(rate); 72 | 73 | super((subscriber) => { 74 | subject.asObservable().subscribe(subscriber); // Connect the observable 75 | }); 76 | 77 | items$ 78 | .pipe( 79 | switchMap((items) => 80 | rate$.pipe( 81 | switchMap((rate) => 82 | interval(rate).pipe(map((index) => items[index % items.length])) 83 | ) 84 | ) 85 | ) 86 | ) 87 | .subscribe(subject); 88 | 89 | this.subject = subject; 90 | this.items$ = items$; 91 | this.rate$ = rate$; 92 | } 93 | 94 | add(item: T) { 95 | const updatedItems = [...this.items$.getValue(), item]; 96 | this.items$.next(updatedItems); 97 | } 98 | 99 | remove(item: T) { 100 | const updatedItems = this.items$.getValue().filter((i) => i !== item); 101 | this.items$.next(updatedItems); 102 | } 103 | 104 | setRate(newRate: number) { 105 | this.rate$.next(newRate); 106 | } 107 | 108 | stop() { 109 | this.subject.complete(); 110 | this.items$.complete(); 111 | this.rate$.complete(); 112 | } 113 | } 114 | 115 | //TEST FUNCTIONALITY 116 | export class ReactiveArray extends Observable { 117 | private array$: BehaviorSubject; 118 | 119 | constructor(initialArray: T[] = []) { 120 | const array$ = new BehaviorSubject(initialArray); 121 | super((subscriber) => { 122 | array$.asObservable().subscribe(subscriber); 123 | }); 124 | this.array$ = array$; 125 | } 126 | 127 | add(item: T) { 128 | const updated = [...this.array$.getValue(), item]; 129 | this.array$.next(updated); 130 | } 131 | 132 | remove(item: T) { 133 | const updated = this.array$.getValue().filter((i) => i !== item); 134 | this.array$.next(updated); 135 | } 136 | 137 | update(index: number, item: T) { 138 | const updated = [...this.array$.getValue()]; 139 | updated[index] = item; 140 | this.array$.next(updated); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /core/Parser.ts: -------------------------------------------------------------------------------- 1 | import * as parse5 from "parse5"; 2 | import { text, patch, elementOpen, elementClose } from "incremental-dom"; 3 | import { Htmlparser2TreeAdapterMap } from "parse5-htmlparser2-tree-adapter"; 4 | import { LRUCache } from "./LRUCache"; 5 | 6 | let instance_: Parser | null = null; 7 | 8 | export class Parser extends parse5.Parser { 9 | public patches: LRUCache< 10 | string, 11 | (domElement: Element, done?: Function) => void 12 | >; 13 | 14 | constructor(cacheCapacity: number = 100) { 15 | super(); 16 | this.patches = new LRUCache< 17 | string, 18 | (domElement: Element, done?: Function) => void 19 | >(cacheCapacity); 20 | } 21 | 22 | static sharedInstance(cacheCapacity?: number): Parser { 23 | if (!instance_) { 24 | instance_ = new Parser(cacheCapacity); 25 | } 26 | return instance_; 27 | } 28 | 29 | createPatch(source: string): (domElement: Node, done?: Function) => void { 30 | const cachedPatch = this.patches.get(source); 31 | if (cachedPatch) { 32 | return cachedPatch; 33 | } 34 | 35 | let html = String(source).replace(/\n/g, " ").replace(/\r/g, " "); 36 | const root = parse5.parseFragment(html); 37 | const nodes = root.childNodes; 38 | const stack: Function[] = []; 39 | const createInstruction = (fn: Function) => stack.push(fn); 40 | 41 | const partial = (domElement: Node, done?: Function) => { 42 | done = typeof done === "function" ? done : () => undefined; 43 | patch(domElement as unknown as Element, () => { 44 | stack.forEach((routine) => routine()); 45 | done(); 46 | }); 47 | }; 48 | 49 | function getAttribs(node: any): { [key: string]: string } { 50 | const attribs: { [key: string]: string } = {}; 51 | if (node.attrs && Array.isArray(node.attrs)) { 52 | node.attrs.forEach((attr) => { 53 | attribs[attr.name] = attr.value; 54 | }); 55 | } 56 | return attribs; 57 | } 58 | 59 | const traverse = ( 60 | node: any, 61 | parentId: string = "root", 62 | index: number = 0, 63 | ) => { 64 | const attribs = getAttribs(node); 65 | const id = attribs.id || `fx-${parentId}-child-${index}`; 66 | const kv: (string | number)[] = []; 67 | 68 | for (const key in attribs) { 69 | kv.push(key, attribs[key]); 70 | } 71 | 72 | const hasChildren = Boolean(node.childNodes && node.childNodes.length); 73 | 74 | if ( 75 | node.nodeName && 76 | node.nodeName !== "#text" && 77 | node.nodeName !== "#document-fragment" 78 | ) { 79 | createInstruction(() => elementOpen(node.nodeName, id, null, ...kv)); 80 | if (hasChildren) 81 | node.childNodes.forEach((child: any, idx: number) => 82 | traverse(child, id, idx), 83 | ); 84 | createInstruction(() => elementClose(node.nodeName)); 85 | } else if (node.nodeName === "#text" && node.value) { 86 | createInstruction(() => text(node.value)); 87 | } 88 | }; 89 | 90 | nodes.forEach((node, idx) => traverse(node, "root", idx)); 91 | this.patches.set(source, partial); 92 | return partial; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /core/Routing.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "@vaadin/router"; 2 | import { BaseComponent } from "./BaseComponent"; 3 | import type { Route } from "./Models"; 4 | import { Component, Output } from "./Decorators"; 5 | 6 | export class RouterOutlet extends BaseComponent { 7 | public router: Router | null = null; 8 | 9 | constructor(private routes: Route[]) { 10 | super(); 11 | } 12 | 13 | connectedCallback() { 14 | super.connectedCallback(); 15 | 16 | if (!this.router) { 17 | this.router = new Router(this); 18 | this.setRoutes(); 19 | } 20 | } 21 | 22 | private setRoutes() { 23 | const routes = this.routes.map((route) => ({ 24 | path: route.path, 25 | action: async (context, commands) => { 26 | if (route.canActivate) { 27 | const guards = Array.isArray(route.canActivate) 28 | ? route.canActivate 29 | : [route.canActivate]; 30 | 31 | for (const guard of guards) { 32 | const canContinue = await guard(context, commands); 33 | if (!canContinue) { 34 | return commands.prevent(); 35 | } else if (canContinue === true) { 36 | continue; 37 | } else { 38 | return canContinue; 39 | } 40 | } 41 | } 42 | 43 | // If we reach here, all guards returned true (or no guards exist) 44 | let component = new route.component(); 45 | const el = commands.component(component["__config"]["selector"]); 46 | el["params"] = context.params; 47 | return el; 48 | }, 49 | })); 50 | 51 | this.router.setRoutes(routes); 52 | } 53 | 54 | disconnectedCallback() { 55 | super.disconnectedCallback(); 56 | } 57 | } 58 | 59 | export function addRouter(selector: string, routes: any[]) { 60 | @Component({ 61 | selector, 62 | template: ``, 63 | }) 64 | class DynamicRouterOutlet extends RouterOutlet { 65 | constructor() { 66 | super(routes); 67 | } 68 | } 69 | return DynamicRouterOutlet; 70 | } 71 | -------------------------------------------------------------------------------- /core/ServerDecorators.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalAppInstance, ControllerBase } from "fornax-server"; 2 | import { z } from "@hono/zod-openapi"; 3 | import { mapTypeScriptTypeToOpenApi } from "./ServerUtilities"; 4 | 5 | // Server Decorators 6 | export const Get = (path: string, schemas: any, responseSchema: any) => 7 | Route("get", path, schemas, responseSchema); 8 | export const Post = (path: string, schemas: any, responseSchema: any) => 9 | Route("post", path, schemas, responseSchema); 10 | export const Put = (path: string, schemas: any, responseSchema: any) => 11 | Route("put", path, schemas, responseSchema); 12 | export const Delete = (path: string, schemas: any, responseSchema: any) => 13 | Route("delete", path, schemas, responseSchema); 14 | 15 | export function Controller(basePath: string) { 16 | const app = getGlobalAppInstance(); 17 | return function (constructor: { new (): ControllerBase }) { 18 | const instance = new constructor(); 19 | if (!(instance instanceof ControllerBase)) { 20 | throw new Error("Controllers must extend ControllerBase"); 21 | } 22 | app.registerController(basePath, instance); 23 | }; 24 | } 25 | 26 | export function Model() { 27 | return function (constructor: Function) { 28 | const app = getGlobalAppInstance(); 29 | const className = constructor.name; 30 | const properties = app.metadataRegistry.get(className) || {}; 31 | 32 | const zodShape: Record = {}; 33 | Object.entries(properties).forEach(([key, value]) => { 34 | const { type, openapi } = value; 35 | 36 | if (type.openapi && typeof type.openapi === "function") { 37 | zodShape[key] = type.openapi({ 38 | ...openapi, 39 | description: openapi?.description || `Property ${key}`, 40 | example: openapi?.example || null, 41 | }); 42 | } else { 43 | console.warn(`Property "${key}" is missing openapi support.`); 44 | } 45 | }); 46 | 47 | const schema = z.object(zodShape).openapi(className, { 48 | title: className, 49 | description: `Schema for ${className}`, 50 | }); 51 | 52 | app.registerOpenAPI(className, schema); 53 | app.registerModel(className, { schema }); 54 | }; 55 | } 56 | 57 | //TEST FUNCTIONALITY 58 | export function Middleware(middleware: (ctx: any, next: Function) => void) { 59 | return function ( 60 | target: any, 61 | propertyKey: string, 62 | descriptor: PropertyDescriptor 63 | ) { 64 | const originalHandler = descriptor.value; 65 | 66 | descriptor.value = async function (ctx: any) { 67 | await middleware(ctx, async () => await originalHandler.call(this, ctx)); 68 | }; 69 | }; 70 | } 71 | 72 | export function Property(type: z.ZodTypeAny, openapi: any = {}) { 73 | return function (target: any, key: string) { 74 | const app = getGlobalAppInstance(); 75 | if (!app.metadataRegistry.has(target)) { 76 | app.registerMetadata(target, {}); 77 | } 78 | 79 | const properties = app.metadataRegistry.get(target); 80 | if (!properties) return; 81 | properties[key] = { type, openapi }; 82 | app.registerMetadata(target, properties); 83 | }; 84 | } 85 | 86 | export function String(openapi: any = {}) { 87 | return defineProperty(z.string(), openapi); 88 | } 89 | 90 | export function OptionalString(openapi: any = {}) { 91 | return defineProperty(z.string().optional(), openapi); 92 | } 93 | 94 | export function Number(openapi: any = {}) { 95 | return defineProperty(z.number(), openapi); 96 | } 97 | 98 | export function OptionalNumber(openapi: any = {}) { 99 | return defineProperty(z.number().optional(), openapi); 100 | } 101 | 102 | export function Boolean(openapi: any = {}) { 103 | return defineProperty(z.boolean(), openapi); 104 | } 105 | 106 | export function OptionalBoolean(openapi: any = {}) { 107 | return defineProperty(z.boolean().optional(), openapi); 108 | } 109 | 110 | export function Array(itemType: z.ZodTypeAny, openapi: any = {}) { 111 | return defineProperty(z.array(itemType), openapi); 112 | } 113 | 114 | export function OptionalArray(itemType: z.ZodTypeAny, openapi: any = {}) { 115 | return defineProperty(z.array(itemType).optional(), openapi); 116 | } 117 | 118 | export function Enum(values: [string, ...string[]], openapi: any = {}) { 119 | return defineProperty(z.enum(values), { ...openapi, enum: values }); 120 | } 121 | 122 | export function OptionalEnum(values: [string, ...string[]], openapi: any = {}) { 123 | return defineProperty(z.enum(values).optional(), { 124 | ...openapi, 125 | enum: values, 126 | }); 127 | } 128 | 129 | export function ISODate(openapi: any = {}) { 130 | return defineProperty( 131 | z 132 | .string() 133 | .regex( 134 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})?$/, 135 | "Invalid date-time format" 136 | ) 137 | .refine((val) => !isNaN(Date.parse(val)), { message: "Invalid date" }), 138 | { ...openapi, format: "date-time" } 139 | ); 140 | } 141 | 142 | export function OptionalISODate(openapi: any = {}) { 143 | return defineProperty( 144 | z 145 | .string() 146 | .regex( 147 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})?$/, 148 | "Invalid date-time format" 149 | ) 150 | .refine((val) => !isNaN(Date.parse(val)), { message: "Invalid date" }) 151 | .optional(), 152 | { ...openapi, format: "date-time" } 153 | ); 154 | } 155 | 156 | export function NumberRange(min: number, max: number, openapi: any = {}) { 157 | return defineProperty(z.number().min(min).max(max), { 158 | ...openapi, 159 | minimum: min, 160 | maximum: max, 161 | }); 162 | } 163 | 164 | export function OptionalNumberRange( 165 | min: number, 166 | max: number, 167 | openapi: any = {} 168 | ) { 169 | return defineProperty(z.number().min(min).max(max).optional(), { 170 | ...openapi, 171 | minimum: min, 172 | maximum: max, 173 | }); 174 | } 175 | 176 | //TEST FUNCTIONALITY 177 | export function Auth( 178 | authLogic: (ctx: any) => Promise | void, 179 | openapi: any = { security: [{ bearerAuth: [] }] } 180 | ) { 181 | return function ( 182 | target: any, 183 | propertyKey: string, 184 | descriptor: PropertyDescriptor 185 | ) { 186 | const originalMethod = descriptor.value; 187 | 188 | descriptor.value = async function (ctx: any) { 189 | try { 190 | await authLogic(ctx); 191 | return await originalMethod.call(this, ctx); 192 | } catch (error: any) { 193 | return ctx.json( 194 | { error: error.message || "Unauthorized" }, 195 | error.status || 401 196 | ); 197 | } 198 | }; 199 | 200 | descriptor.value.openapi = openapi; 201 | }; 202 | } 203 | 204 | // Server Utilities 205 | export type HttpMethod = "get" | "post" | "put" | "delete"; 206 | 207 | export function Route( 208 | method: HttpMethod, 209 | path: string, 210 | schemas: { params?: any; body?: any; query?: any }, 211 | responseModel: any 212 | ) { 213 | return function ( 214 | target: any, 215 | propertyKey: string, 216 | descriptor: PropertyDescriptor 217 | ) { 218 | const app = getGlobalAppInstance(); 219 | const paramsSchema = app.modelRegistry.get(schemas?.params?.name) 220 | ? app.modelRegistry.get(schemas.params.name)?.schema 221 | : mapTypeScriptTypeToOpenApi(schemas.params); 222 | const bodySchema = app.modelRegistry.get(schemas?.body?.name) 223 | ? app.modelRegistry.get(schemas.body.name)?.schema 224 | : mapTypeScriptTypeToOpenApi(schemas.body); 225 | const querySchema = app.modelRegistry.get(schemas?.query?.name) 226 | ? app.modelRegistry.get(schemas.query.name)?.schema 227 | : mapTypeScriptTypeToOpenApi(schemas.query); 228 | const responseSchema = app.modelRegistry.get(responseModel?.name) 229 | ? app.modelRegistry.get(responseModel.name)?.schema 230 | : mapTypeScriptTypeToOpenApi(responseModel); 231 | 232 | const controllerName = target.constructor.name; 233 | const routes = app.routeRegistry.get(controllerName) || []; 234 | 235 | routes.push({ 236 | method, 237 | path, 238 | handler: propertyKey, 239 | schemas: { 240 | params: paramsSchema, 241 | body: bodySchema, 242 | query: querySchema, 243 | response: responseSchema, 244 | }, 245 | }); 246 | app.registerRoute(controllerName, routes); 247 | }; 248 | } 249 | 250 | export function getSchema(target: any): z.AnyZodObject { 251 | const app = getGlobalAppInstance(); 252 | const properties = app.metadataRegistry.get(target) || {}; 253 | const zodShape: Record = {}; 254 | 255 | Object.keys(properties).forEach((key) => { 256 | const { type, openapi } = properties[key]; 257 | const zodType = type.openapi(openapi); 258 | zodShape[key] = zodType; 259 | }); 260 | 261 | return z.object(zodShape); 262 | } 263 | 264 | export function defineProperty(type: any, openapi: any = {}) { 265 | return function (target: any, key: string) { 266 | const app = getGlobalAppInstance(); 267 | const className = target.constructor.name; 268 | 269 | if (!app.metadataRegistry.has(className)) { 270 | app.registerMetadata(className, {}); 271 | } 272 | 273 | const properties = app.metadataRegistry.get(className)!; 274 | properties[key] = { type, openapi }; 275 | app.registerMetadata(className, properties); 276 | }; 277 | } 278 | -------------------------------------------------------------------------------- /core/ServerUtilities.ts: -------------------------------------------------------------------------------- 1 | export function mapTypeScriptTypeToOpenApi(object: any): any { 2 | if (object === String) return { type: "string" }; 3 | if (object === Number) return { type: "number" }; 4 | if (object === Boolean) return { type: "boolean" }; 5 | 6 | if (Array.isArray(object)) { 7 | const itemType = object[0]; 8 | return { 9 | type: "array", 10 | items: mapTypeScriptTypeToOpenApi(itemType), 11 | }; 12 | } 13 | return object; 14 | } 15 | -------------------------------------------------------------------------------- /core/Template.ts: -------------------------------------------------------------------------------- 1 | import { Binding } from "./Models"; 2 | import { ensureObject, makeSafeObject } from "./Utilities"; 3 | 4 | export class Template { 5 | private static cache = new Map< 6 | string, 7 | (data: any, scope: any) => [string, Binding[]] 8 | >(); 9 | 10 | public source: string | Function | null = null; 11 | public render: (data?: any, scope?: any) => [string, Binding[]]; 12 | 13 | static createPartial( 14 | str: string, 15 | ): (data?: any, scope?: any) => [string, Binding[]] { 16 | if (typeof str !== "string") { 17 | throw new Error("Template source must be a string."); 18 | } 19 | 20 | // Check cache for the precompiled template 21 | if (Template.cache.has(str)) { 22 | return Template.cache.get(str)!; 23 | } 24 | 25 | // Parse bindings and preprocess the template 26 | const bindings: Binding[] = []; 27 | const preprocessedTemplate = Template.preprocessTemplate(str, bindings); 28 | 29 | // Compile the template into a reusable function 30 | const templateFunction = new Function( 31 | "data", 32 | "scope", 33 | "bindings", 34 | `'use strict'; ${preprocessedTemplate}`, 35 | ); 36 | 37 | const renderFunction = (data?: any, scope?: any): [string, Binding[]] => { 38 | data = ensureObject(data); 39 | scope = scope || this; 40 | const safeData = makeSafeObject(data); 41 | 42 | const renderedOutput = templateFunction(safeData, scope, bindings); 43 | return [renderedOutput, bindings]; 44 | }; 45 | 46 | // Cache the compiled template function 47 | Template.cache.set(str, renderFunction); 48 | return renderFunction; 49 | } 50 | 51 | static preprocessTemplate(template: string, bindings: Binding[]): string { 52 | let processedTemplate = template.replace(/`/g, "\\`"); 53 | 54 | // Handle "if" directive first 55 | processedTemplate = processedTemplate.replace( 56 | /<([a-zA-Z0-9-]+)[^>]*\s\*if="([^"]+)"[^>]*>([\s\S]*?)<\/\1>/g, 57 | (_, tagName, condition, content) => { 58 | const dataCondition = condition.replace( 59 | /\b([a-zA-Z_][a-zA-Z0-9_]*)\b/g, 60 | (match) => `data.${match}`, // Prefix variables with `data.` 61 | ); 62 | return `\${(${dataCondition}) ? \`<${tagName}>${content}\` : ''}`; 63 | }, 64 | ); 65 | 66 | // Handle "forEach" directive 67 | processedTemplate = processedTemplate.replace( 68 | /<([a-zA-Z0-9-]+)[^>]*\s\*for="([^"]+)\s+of\s+([^"]+)"[^>]*>([\s\S]*?)<\/\1>/g, 69 | (_, tagName, item, collection, content) => { 70 | const dataCollection = collection.replace( 71 | /\b([a-zA-Z_][a-zA-Z0-9_]*)\b/g, 72 | (match) => `data.${match}`, // Prefix collection with `data` 73 | ); 74 | 75 | const loopProcessedContent = content.replace( 76 | /\{\{\s*([^}]+)\s*\}\}/g, 77 | (_, expr) => 78 | expr.trim() === item.trim() 79 | ? `\${${item.trim()}}` 80 | : `\${data.${expr.trim()}}`, 81 | ); 82 | 83 | return `\${(${dataCollection}).map(${item} => \`<${tagName}>\${(() => \`${loopProcessedContent}\`)()}\`).join('')}`; 84 | }, 85 | ); 86 | 87 | // Replace remaining {{...}} bindings globally 88 | processedTemplate = processedTemplate.replace( 89 | /\{\{\s*([^}]+)\s*\}\}/g, 90 | (_, expr) => `\${data.${expr.trim()}}`, 91 | ); 92 | 93 | // Extract and preprocess event bindings (if applicable) 94 | processedTemplate = processedTemplate.replace( 95 | /\(([^)]+)\)="([^"]+)"/g, 96 | (_, eventName, handlerName) => { 97 | bindings.push({ eventName, handlerName }); 98 | return ""; // Remove the event binding from the template string 99 | }, 100 | ); 101 | 102 | return `return \`${processedTemplate}\`;`; 103 | } 104 | 105 | constructor(source: string | Function) { 106 | this.define(source); 107 | } 108 | 109 | define(source: string | Function): this { 110 | this.source = source; 111 | this.render = Template.createPartial(source as string); 112 | return this; 113 | } 114 | 115 | toString(): string { 116 | return String(this.source || ""); 117 | } 118 | 119 | valueOf(): string | Function | null { 120 | return this.source; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /core/Utilities.ts: -------------------------------------------------------------------------------- 1 | import { 2 | existsSync, 3 | mkdirSync, 4 | readdirSync, 5 | lstatSync, 6 | copyFileSync, 7 | readFileSync, 8 | } from "fs"; 9 | import path from "path"; 10 | import type { HttpMethod } from "./Models"; 11 | import { createRoute, z } from "@hono/zod-openapi"; 12 | import { 13 | metadataRegistry, 14 | modelRegistry, 15 | routeRegistry, 16 | } from "./scripts/constants"; 17 | 18 | export function ensureObject(o: any): object { 19 | return o != null && typeof o === "object" ? o : {}; 20 | } 21 | 22 | export function makeSafeObject(o: any, visited = new WeakSet()): any { 23 | if (o === null || typeof o !== "object") { 24 | return o; 25 | } 26 | 27 | if (visited.has(o)) { 28 | return undefined; 29 | } 30 | 31 | visited.add(o); 32 | 33 | if (Array.isArray(o)) { 34 | return o.map((item) => makeSafeObject(item, visited)); 35 | } 36 | 37 | const safeObj: any = {}; 38 | for (const key of Object.keys(o)) { 39 | const value = o[key]; 40 | if (typeof value !== "function") { 41 | safeObj[key] = makeSafeObject(value, visited); 42 | } 43 | } 44 | 45 | return safeObj; 46 | } 47 | 48 | export function copyFolderRecursiveSync(src, dest) { 49 | const exists = existsSync(dest); 50 | if (!exists) { 51 | mkdirSync(dest); 52 | } 53 | 54 | const files = readdirSync(src); 55 | 56 | for (const file of files) { 57 | const srcFilePath = path.join(src, file); 58 | const destFilePath = path.join(dest, file); 59 | 60 | const stat = lstatSync(srcFilePath); 61 | 62 | if (stat.isFile()) { 63 | copyFileSync(srcFilePath, destFilePath); 64 | } else if (stat.isDirectory()) { 65 | copyFolderRecursiveSync(srcFilePath, destFilePath); 66 | } 67 | } 68 | } 69 | 70 | export function throttle(callback: Function, limit: number) { 71 | let inThrottle: boolean; 72 | return function (...args: any[]) { 73 | if (!inThrottle) { 74 | callback(...args); 75 | inThrottle = true; 76 | setTimeout(() => (inThrottle = false), limit); 77 | } 78 | }; 79 | } 80 | 81 | export function toKebabCase(str: string): string { 82 | return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); 83 | } 84 | 85 | export function toCamelCase(str: string): string { 86 | return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); 87 | } 88 | 89 | export function getProjectInfo(): any { 90 | try { 91 | const packageJsonPath = path.join(process.cwd(), "package.json"); 92 | const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); 93 | return { 94 | title: packageJson.name || "Unknown Project", 95 | description: packageJson.description || "", 96 | version: packageJson.version || "0.0.0", 97 | }; 98 | } catch (error) { 99 | console.error("Error reading package.json:", error); 100 | return "Unknown Project"; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /core/assets/favicon.ico: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /core/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TBosak/fornax/0c331e5f2bb631a3205252762aa44da816fd1b3b/core/assets/logo.png -------------------------------------------------------------------------------- /core/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /core/schematics/index.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, rm, writeFileSync } from "fs"; 2 | import { resolve, join } from "path"; 3 | import { render } from "ejs"; 4 | import templates from "./templates.toml"; 5 | import { spawn } from "bun"; 6 | 7 | function writeFile(filePath: string, content: string) { 8 | const dir = resolve(filePath, ".."); 9 | if (!existsSync(dir)) { 10 | mkdirSync(dir, { recursive: true }); 11 | } 12 | writeFileSync(filePath, content, "utf-8"); 13 | } 14 | 15 | function renderTemplate( 16 | template: string, 17 | data: Record 18 | ): string | Promise { 19 | return render(template, data, { async: true, rmWhitespace: true }); 20 | } 21 | 22 | export async function generateComponent(name: string, destDir: string) { 23 | const componentDir = resolve(destDir, name); 24 | mkdirSync(componentDir, { recursive: true }); 25 | 26 | const schematics = ["html", "css", "ts"]; 27 | 28 | schematics.forEach(async (type) => { 29 | const content = await renderTemplate(templates.component[type], { name }); 30 | writeFile(join(componentDir, `${name}.component.${type}`), content); 31 | }); 32 | 33 | console.log(`Component "${name}" created at ${componentDir}`); 34 | } 35 | 36 | export async function generateController(name: string, destDir: string) { 37 | const controllersDir = resolve(destDir, "controllers"); 38 | const modelsDir = resolve(destDir, "models"); 39 | mkdirSync(controllersDir, { recursive: true }); 40 | mkdirSync(modelsDir, { recursive: true }); 41 | const schematics = [ 42 | { dir: modelsDir, type: "model" }, 43 | { dir: controllersDir, type: "controller" }, 44 | ]; 45 | 46 | schematics.forEach(async ({ dir, type }) => { 47 | const content = await renderTemplate(templates.controller[type], { name }); 48 | writeFile( 49 | join(dir, `${name}.${type === "controller" ? type + "." : ""}ts`), 50 | content 51 | ); 52 | }); 53 | 54 | console.log(`Component "${name}" created at ${destDir}`); 55 | } 56 | 57 | export async function generateProject(projectName: string, destDir: string) { 58 | const projectPath = resolve(destDir, projectName); 59 | const proc = spawn({ 60 | cmd: ["bun", "create", "tbosak/create-fornax", projectName], 61 | cwd: destDir, 62 | stdout: "inherit", 63 | stderr: "inherit", 64 | }); 65 | console.log(`Project "${projectName}" created at ${projectPath}`); 66 | } 67 | -------------------------------------------------------------------------------- /core/schematics/templates.toml: -------------------------------------------------------------------------------- 1 | [component] 2 | html = "
Hello, <%= name %> Component!
" 3 | css = """ 4 | /* Styles for <%= name %> */ 5 | div{ 6 | color: blue; 7 | } 8 | """ 9 | ts = """ 10 | import {Component, BaseComponent} from "fornaxjs"; 11 | import html from "./<%= name.toLowerCase() %>.component.html" with { type: "text" }; 12 | import styles from "./<%= name.toLowerCase() %>.component.css"; 13 | <%- \n %> 14 | @Component({selector: "app-<%= name %>", 15 | template: html, 16 | style: styles, 17 | }) 18 | <%- \n %> 19 | export class <%= name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %>Component extends BaseComponent { 20 | <%- \n %> 21 | onInit() { 22 | } 23 | <%- \n %> 24 | onDestroy() { 25 | } 26 | <%- \n %> 27 | } 28 | """ 29 | 30 | [controller] 31 | controller = """ 32 | import { 33 | Controller, 34 | Get, 35 | ControllerBase, 36 | Post, 37 | type Context, 38 | } from "fornaxjs/server"; 39 | import { <%= name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %> } from "../models/<%= name %>"; 40 | <%- \n %> 41 | @Controller("/<%= name %>") 42 | export class <%- name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %>Controller extends ControllerBase { 43 | @Post("/", { body: <%- name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %> }, <%- name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %>) 44 | async create<%- name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %>(ctx: Context) { 45 | const body = await ctx.req.json(); 46 | return this.Ok(ctx, body); 47 | } 48 | <%- \n %> 49 | @Get("/:id", { params: Number }, <%= name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %>) 50 | async get<%= name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %>(ctx: Context) { 51 | const id = ctx.req.param("id"); 52 | return this.Ok(ctx, {id}); 53 | } 54 | } 55 | """ 56 | model = """ 57 | import { Model, Number } from "fornaxjs/server"; 58 | <%- \n %> 59 | @Model() 60 | export class <%= name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %> { 61 | <%- \n %> 62 | @Number({ example: 1, description: "Unique identifier for the <%= name.replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) %>" }) 63 | id!: string; 64 | } 65 | """ 66 | -------------------------------------------------------------------------------- /core/scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { copyFileSync, readdirSync } from "fs"; 2 | import { loadConfig } from "./load-config"; 3 | import { join } from "path"; 4 | 5 | const config = loadConfig(); 6 | 7 | (async () => { 8 | readdirSync(config.Client.srcDir).forEach((file) => { 9 | if (file.endsWith(".html") || file.endsWith(".ico")) { 10 | copyFileSync( 11 | join(config.Client.srcDir, file), 12 | join(config.Client.distDir, file) 13 | ); 14 | } 15 | }); 16 | 17 | const initialLoad = true; 18 | const childProc = Bun.spawn( 19 | ["bun", `${__dirname}/generate-imports.ts`, String(initialLoad)], 20 | { 21 | cwd: process.cwd(), 22 | env: process.env, 23 | stdout: "inherit", 24 | stderr: "inherit", 25 | } 26 | ); 27 | await childProc.exited; 28 | })(); 29 | -------------------------------------------------------------------------------- /core/scripts/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import { spawnSync, spawn } from "bun"; 3 | import { resolve } from "path"; 4 | import { existsSync } from "fs"; 5 | import inquirer from "inquirer"; 6 | import { loadConfig } from "./load-config"; 7 | import { 8 | generateComponent, 9 | generateController, 10 | generateProject, 11 | } from "../schematics"; 12 | 13 | const config = loadConfig(); 14 | 15 | export async function runCommand( 16 | cmd: string, 17 | args: string[], 18 | options: any = {} 19 | ) { 20 | const proc = spawnSync({ 21 | cmd: [cmd, ...args], 22 | stdout: "inherit", 23 | stderr: "inherit", 24 | ...options, 25 | }); 26 | if (proc.exitCode !== 0) { 27 | console.error( 28 | `Command "${cmd} ${args.join(" ")}" failed with code ${proc.exitCode}` 29 | ); 30 | process.exit(proc.exitCode || 1); 31 | } 32 | } 33 | 34 | export async function runInBackground( 35 | cmd: string, 36 | args: string[], 37 | options: any = {} 38 | ) { 39 | const proc = spawn({ 40 | cmd: [cmd, ...args], 41 | stdout: "inherit", 42 | stderr: "inherit", 43 | ...options, 44 | }); 45 | return proc; 46 | } 47 | 48 | async function dev(options: { client?: boolean; server?: boolean }) { 49 | const procs: any[] = []; 50 | 51 | if (options.client ?? true) { 52 | console.log("Starting client in watch mode..."); 53 | const clientProc = await runInBackground("bun", [ 54 | `${__dirname}/client.ts`, 55 | "--watch", 56 | ]); 57 | procs.push(clientProc); 58 | } 59 | 60 | if (options.server ?? true) { 61 | const serverPath = resolve(__dirname, "./server.ts"); 62 | console.log("Starting server..."); 63 | const serverProc = Bun.spawn(["bun", serverPath], { 64 | cwd: process.cwd(), 65 | env: process.env, 66 | stdout: "inherit", 67 | stderr: "inherit", 68 | }); 69 | procs.push(serverProc); 70 | } 71 | 72 | process.on("SIGINT", () => { 73 | procs.forEach((proc) => proc.kill()); 74 | process.exit(0); 75 | }); 76 | 77 | process.on("SIGTERM", () => { 78 | procs.forEach((proc) => proc.kill()); 79 | process.exit(0); 80 | }); 81 | } 82 | 83 | async function build() { 84 | console.log("Building project..."); 85 | await runCommand("bun", ["run", `${__dirname}/build.ts`]); 86 | console.log("Build complete!"); 87 | } 88 | 89 | async function start(options: { 90 | client?: boolean; 91 | server?: boolean; 92 | menu?: boolean; 93 | }) { 94 | const procs: any[] = []; 95 | 96 | if (options.menu) { 97 | const answers = await inquirer.prompt([ 98 | { 99 | type: "list", 100 | name: "action", 101 | message: "What would you like to start?", 102 | choices: [ 103 | { name: "Start client", value: "client" }, 104 | { name: "Start server", value: "server" }, 105 | { name: "Start both", value: "both" }, 106 | ], 107 | }, 108 | ]); 109 | switch (answers.action) { 110 | case "client": 111 | options.client = true; 112 | options.server = false; 113 | break; 114 | case "server": 115 | options.client = false; 116 | options.server = true; 117 | break; 118 | case "both": 119 | options.client = true; 120 | options.server = true; 121 | break; 122 | } 123 | } 124 | 125 | if ((options.client ?? true) && !existsSync(resolve(config.Client.distDir))) { 126 | console.log("Dist directory not found. Running build..."); 127 | await build(); 128 | } 129 | 130 | if (options.client ?? true) { 131 | console.log("Starting client..."); 132 | const clientProc = await runInBackground("bun", [`${__dirname}/client.ts`]); 133 | procs.push(clientProc); 134 | } 135 | 136 | if (options.server ?? true) { 137 | const serverPath = resolve(__dirname, "./server.ts"); 138 | console.log("Starting server..."); 139 | const serverProc = Bun.spawn(["bun", serverPath], { 140 | cwd: process.cwd(), 141 | env: process.env, 142 | stdout: "inherit", 143 | stderr: "inherit", 144 | }); 145 | procs.push(serverProc); 146 | } 147 | 148 | process.on("SIGINT", () => { 149 | procs.forEach((proc) => proc.kill()); 150 | process.exit(0); 151 | }); 152 | 153 | process.on("SIGTERM", () => { 154 | procs.forEach((proc) => proc.kill()); 155 | process.exit(0); 156 | }); 157 | } 158 | 159 | async function generate() { 160 | const args = process.argv.slice(3); 161 | const [type, name] = args; 162 | 163 | const destMap = { 164 | component: resolve(config.Client.srcDir, "components"), 165 | controller: config.Server.dir, 166 | project: process.cwd(), 167 | }; 168 | 169 | const promptIfMissing = async () => { 170 | const answers = await inquirer.prompt([ 171 | { 172 | type: "list", 173 | name: "type", 174 | message: "What would you like to generate?", 175 | choices: ["Component", "Controller", "Project"], 176 | }, 177 | { 178 | type: "input", 179 | name: "name", 180 | message: "Enter the name:", 181 | }, 182 | ]); 183 | return { type: answers.type.toLowerCase(), name: answers.name }; 184 | }; 185 | 186 | const { type: resolvedType, name: resolvedName } = 187 | type && name ? { type, name } : await promptIfMissing(); 188 | 189 | const destDir = destMap[resolvedType.toLowerCase()]; 190 | if (!destDir) { 191 | console.error( 192 | `Unknown type "${resolvedType}". Use "component", "controller", or "project".` 193 | ); 194 | process.exit(1); 195 | } 196 | 197 | const generators = { 198 | component: generateComponent, 199 | controller: generateController, 200 | project: generateProject, 201 | }; 202 | 203 | const generator = generators[resolvedType.toLowerCase()]; 204 | if (!generator) { 205 | console.error(`Invalid type "${resolvedType}".`); 206 | process.exit(1); 207 | } 208 | 209 | await generator(resolvedName, destDir); 210 | console.log(`${resolvedType} "${resolvedName}" successfully created.`); 211 | } 212 | 213 | async function showMainMenu() { 214 | const answers = await inquirer.prompt([ 215 | { 216 | type: "list", 217 | name: "action", 218 | message: "What would you like to do?", 219 | choices: [ 220 | { name: "Start Client, Server, or Both", value: "start" }, 221 | { 222 | name: "Generate a Schematic", 223 | value: "generate", 224 | }, 225 | { name: "Build the Project", value: "build" }, 226 | { name: "Exit", value: "exit" }, 227 | ], 228 | }, 229 | ]); 230 | 231 | switch (answers.action) { 232 | case "start": 233 | await start({ client: true, server: true, menu: true }); 234 | break; 235 | case "generate": 236 | await generate(); 237 | break; 238 | case "build": 239 | await build(); 240 | break; 241 | case "exit": 242 | process.exit(0); 243 | } 244 | } 245 | 246 | (async function main() { 247 | const args = process.argv.slice(2); 248 | const command = args[0]; 249 | 250 | if (!command) { 251 | await showMainMenu(); 252 | return; 253 | } 254 | 255 | switch (command) { 256 | case "dev": 257 | await dev({ client: true, server: true }); 258 | break; 259 | case "build": 260 | await build(); 261 | break; 262 | case "start": 263 | await start({ client: true, server: true }); 264 | break; 265 | case "start:client": 266 | await start({ client: true, server: false }); 267 | break; 268 | case "start:server": 269 | await start({ client: false, server: true }); 270 | break; 271 | case "generate": 272 | await generate(); 273 | break; 274 | default: 275 | console.log( 276 | `Usage: fnx [dev|build|start|start:client|start:server|generate]` 277 | ); 278 | process.exit(1); 279 | } 280 | })(); 281 | -------------------------------------------------------------------------------- /core/scripts/client.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "bun"; 2 | import { existsSync, mkdirSync } from "fs"; 3 | import path from "path"; 4 | import { loadConfig } from "./load-config"; 5 | 6 | // Define the directory paths 7 | const config = loadConfig(); 8 | 9 | // Ensure the dist directory exists 10 | if (!existsSync(config.Client.distDir)) { 11 | mkdirSync(config.Client.distDir); 12 | } 13 | 14 | const buildProc = Bun.spawn(["bun", `${__dirname}/build.ts`], { 15 | stdout: "inherit", 16 | }); 17 | await buildProc.exited; 18 | const liveReloadProc = Bun.spawn(["bun", `${__dirname}/live-reload.ts`], { 19 | stdout: "inherit", 20 | }); 21 | 22 | // Function to serve static files 23 | async function serveStatic(filePath: string): Promise { 24 | try { 25 | const fileBuffer = await Bun.file(filePath).arrayBuffer(); 26 | const ext = path.extname(filePath).toLowerCase(); 27 | const contentType = getContentType(ext); 28 | const headers = new Headers(); 29 | headers.set("Content-Type", contentType); 30 | return new Response(fileBuffer, { headers }); 31 | } catch (error) { 32 | console.error(`Error serving file ${filePath}:`, error); 33 | return new Response("Internal Server Error", { status: 500 }); 34 | } 35 | } 36 | 37 | // Function to determine Content-Type based on file extension 38 | function getContentType(ext: string): string { 39 | switch (ext) { 40 | case ".html": 41 | return "text/html"; 42 | case ".js": 43 | return "application/javascript"; 44 | case ".ts": 45 | return "application/typescript"; 46 | case ".css": 47 | return "text/css"; 48 | case ".json": 49 | return "application/json"; 50 | case ".png": 51 | return "image/png"; 52 | case ".jpg": 53 | case ".jpeg": 54 | return "image/jpeg"; 55 | case ".gif": 56 | return "image/gif"; 57 | case ".wasm": 58 | return "application/wasm"; 59 | default: 60 | return "application/octet-stream"; 61 | } 62 | } 63 | 64 | // Start the server 65 | serve({ 66 | port: config.Client.port, 67 | async fetch(request) { 68 | const url = new URL(request.url); 69 | let pathname = url.pathname; 70 | 71 | // Prevent directory traversal attacks 72 | if (pathname.includes("..")) { 73 | return new Response("Forbidden", { status: 403 }); 74 | } 75 | 76 | // Define the path to the requested file in the primary directory 77 | let filePath = path.join(config.Client.distDir, pathname); 78 | 79 | // If the path is a directory, append 'index.html' 80 | if (pathname.endsWith("/")) { 81 | filePath = path.join(filePath, "index.html"); 82 | } 83 | 84 | // Check if the file exists in the primary directory 85 | if (existsSync(filePath) && path.extname(filePath)) { 86 | return await serveStatic(filePath); 87 | } 88 | 89 | // For SPA routes, serve 'index.html' 90 | const indexPath = path.join(config.Client.distDir, "index.html"); 91 | if (existsSync(indexPath)) { 92 | return await serveStatic(indexPath); 93 | } 94 | 95 | return new Response("Not Found", { status: 404 }); 96 | }, 97 | }); 98 | 99 | console.log(`Client is running at http://localhost:${config.Client.port}`); 100 | 101 | process.on("SIGINT", () => { 102 | console.log("Received SIGINT. Cleaning up..."); 103 | liveReloadProc.kill(); 104 | process.exit(0); 105 | }); 106 | 107 | process.on("SIGTERM", () => { 108 | console.log("Received SIGTERM. Cleaning up..."); 109 | liveReloadProc.kill(); 110 | process.exit(0); 111 | }); 112 | -------------------------------------------------------------------------------- /core/scripts/constants.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TBosak/fornax/0c331e5f2bb631a3205252762aa44da816fd1b3b/core/scripts/constants.ts -------------------------------------------------------------------------------- /core/scripts/generate-imports.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, rmSync, writeFileSync } from "fs"; 2 | import { basename, extname, join, resolve } from "path"; 3 | import { loadConfig } from "./load-config"; 4 | import { copyFolderRecursiveSync } from "../Utilities"; 5 | import styleLoader from "bun-style-loader"; 6 | 7 | const config = loadConfig(); 8 | const componentsDir = resolve(config.Client.srcDir, "./components"); 9 | const servicesDir = resolve(config.Client.srcDir, "./services"); 10 | const srcDir = resolve(config.Client.srcDir); 11 | const srcFiles = readdirSync(config.Client.srcDir); 12 | const distDir = resolve(config.Client.distDir); 13 | const componentFiles = readdirSync(componentsDir); 14 | const serviceFiles = readdirSync(servicesDir); 15 | 16 | try { 17 | const args = process.argv.slice(2); 18 | const initialLoad = args[0] === "true" || false; 19 | clearChunks(); 20 | 21 | const imports = 22 | getImportPaths(componentFiles, componentsDir) + 23 | "\n" + 24 | getImportPaths(srcFiles, srcDir) + 25 | "\n" + 26 | getImportPaths(serviceFiles, servicesDir); 27 | function getImportPaths(files: string[], dir: string): string { 28 | return files 29 | .filter((file: any) => { 30 | const ext = extname(file); 31 | return (ext === ".ts" || ext === ".tsx") && !file.endsWith("main.ts"); 32 | }) 33 | .map((file: any) => { 34 | const importPath = `${dir.replaceAll("\\", "/")}/${basename( 35 | file, 36 | extname(file) 37 | )}`; 38 | return `import "${importPath}";`; 39 | }) 40 | .join("\n"); 41 | } 42 | 43 | const code = imports; 44 | const entryFile = join(process.cwd(), "main.ts"); 45 | const routes = join(config.Client.srcDir, "routes.ts"); 46 | const extraEntryPoints = config.Client.entryPoints.map((entry) => 47 | resolve(process.cwd(), entry) 48 | ); 49 | 50 | writeFileSync(entryFile, code, "utf-8"); 51 | const styles = config.Client.alternateStyleLoader 52 | ? config.Client.alternateStyleLoader 53 | : styleLoader(); 54 | const build = await Bun.build({ 55 | entrypoints: [entryFile, routes, ...extraEntryPoints], 56 | outdir: distDir, 57 | target: "browser", 58 | splitting: true, 59 | minify: false, 60 | plugins: [styles].concat(config.Client.plugins), 61 | naming: { 62 | entry: "[name].[ext]", 63 | }, 64 | }); 65 | 66 | if (initialLoad) { 67 | copyFolderRecursiveSync(join(srcDir, "assets"), join(distDir, "assets")); 68 | } 69 | 70 | if (build.logs.length) { 71 | console.log(build.logs); 72 | } 73 | } catch (e) { 74 | console.error(e); 75 | } 76 | 77 | function clearChunks() { 78 | try { 79 | const files = readdirSync(distDir); 80 | const chunkRegex = /^chunk-.*\.js$/; 81 | for (const file of files) { 82 | if (chunkRegex.test(file)) { 83 | const filePath = join(distDir, file); 84 | rmSync(filePath, { force: true }); 85 | } 86 | } 87 | } catch (error) { 88 | console.error(`Error clearing chunks in ${distDir}:`, error); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /core/scripts/global-styles.ts: -------------------------------------------------------------------------------- 1 | const globalStyles = (async () => { 2 | const styleSheets = document.adoptedStyleSheets; 3 | let cssText = ""; 4 | const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')); 5 | //fetch any stylesheets from links that don't exist in styleSheets array hrefs 6 | const fetches = links 7 | .filter( 8 | (link) => 9 | !styleSheets.find( 10 | (sheet) => sheet.href === (link as HTMLLinkElement).href 11 | ) 12 | ) 13 | .map((link) => 14 | fetch((link as HTMLLinkElement).href).then((res) => res.text()) 15 | ); 16 | for (const sheet of styleSheets) { 17 | for (let i = 0; i < sheet.cssRules.length; i++) { 18 | cssText += sheet.cssRules[i].cssText + "\n"; 19 | } 20 | } 21 | await Promise.all(fetches).then((texts) => { 22 | cssText += texts.join("\n"); 23 | }); 24 | return cssText; 25 | })(); 26 | 27 | export { globalStyles }; 28 | -------------------------------------------------------------------------------- /core/scripts/live-reload.ts: -------------------------------------------------------------------------------- 1 | import { copyFileSync, existsSync, watch } from "fs"; 2 | import path from "path"; 3 | import { loadConfig } from "./load-config"; 4 | 5 | const config = loadConfig(); 6 | const srcDir = config.Client.srcDir; 7 | const distDir = config.Client.distDir; 8 | 9 | watch(srcDir, { recursive: true }, async (event, filename) => { 10 | if (!filename) return; // Sometimes filename may be null 11 | 12 | console.log( 13 | `File ${filename} changed (${event}) - updating dist...refresh browser to see changes.` 14 | ); 15 | 16 | // Construct full source path by joining srcDir and filename 17 | const srcFilePath = path.join(srcDir, filename); 18 | 19 | if ( 20 | filename.endsWith(".ts") || 21 | filename.endsWith(".js") || 22 | filename.endsWith(".jsx") || 23 | filename.endsWith(".tsx") || 24 | filename.includes(".component") 25 | ) { 26 | const initialLoad = false; 27 | const childProc = Bun.spawn( 28 | ["bun", `${__dirname}/generate-imports.ts`, String(initialLoad)], 29 | { 30 | cwd: process.cwd(), 31 | env: process.env, 32 | stdout: "inherit", 33 | stderr: "inherit", 34 | } 35 | ); 36 | await childProc.exited; 37 | } else { 38 | // Extract just the filename (no directories) 39 | const baseName = path.basename(filename); 40 | const destFilePath = path.join(distDir, baseName); 41 | 42 | // Check if source file actually exists before copying 43 | if (!existsSync(srcFilePath)) { 44 | console.error(`Source file ${srcFilePath} does not exist!`); 45 | return; 46 | } 47 | 48 | copyFileSync(srcFilePath, destFilePath); 49 | console.log(`Copied ${srcFilePath} to ${destFilePath}`); 50 | } 51 | }); 52 | -------------------------------------------------------------------------------- /core/scripts/load-config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { FornaxConfig } from "../Models"; 3 | import { existsSync } from "fs"; 4 | 5 | export function loadConfig(): FornaxConfig { 6 | const projectRoot = process.cwd(); 7 | const defaults: FornaxConfig = { 8 | Client: { 9 | srcDir: path.resolve(projectRoot, "./src/client"), 10 | distDir: path.resolve(projectRoot, "./dist"), 11 | port: 5000, 12 | plugins: [], 13 | entryPoints: [], 14 | }, 15 | Server: { 16 | dir: path.resolve(projectRoot, "./src/server"), 17 | port: 3000, 18 | }, 19 | }; 20 | 21 | const configPath = path.resolve(projectRoot, "fornax.config.ts"); 22 | if (existsSync(configPath)) { 23 | const cfg = require(configPath); 24 | return { ...defaults, ...cfg.default }; 25 | } 26 | 27 | console.warn("No fornax.config.ts found, using defaults."); 28 | return defaults; 29 | } 30 | -------------------------------------------------------------------------------- /core/scripts/server.ts: -------------------------------------------------------------------------------- 1 | import { getProjectInfo } from "../Utilities"; 2 | import { readdirSync } from "fs"; 3 | import path from "path"; 4 | import { loadConfig } from "../scripts/load-config"; 5 | import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; 6 | import { swaggerUI } from "@hono/swagger-ui"; 7 | import { cors } from "hono/cors"; 8 | import { trimTrailingSlash } from "hono/trailing-slash"; 9 | import { getGlobalAppInstance } from "fornax-server"; 10 | 11 | const info = getProjectInfo(); 12 | const config = loadConfig(); 13 | const app = getGlobalAppInstance(); 14 | 15 | if (isNaN(config.Server.port)) { 16 | console.error("Invalid port configuration"); 17 | process.exit(1); 18 | } 19 | 20 | async function loadConsumingProjectModules() { 21 | const loadModules = async (dir: string, type: string) => { 22 | const files = readdirSync(dir).filter( 23 | (file) => file.endsWith(".ts") || file.endsWith(".js") 24 | ); 25 | for (const file of files) { 26 | console.log(`Loading ${type}: ${file}`); 27 | await import(path.resolve(dir, file)); 28 | } 29 | }; 30 | 31 | const controllersDir = path.resolve( 32 | process.cwd(), 33 | config.Server.dir, 34 | "controllers" 35 | ); 36 | const modelsDir = path.resolve(process.cwd(), config.Server.dir, "models"); 37 | 38 | await loadModules(controllersDir, "controller"); 39 | await loadModules(modelsDir, "model"); 40 | } 41 | 42 | function generateOpenApiPaths() { 43 | const paths: Record = {}; 44 | app.controllerRegistry.forEach((controller, basePath) => { 45 | const routes = app.routeRegistry.get(controller.constructor.name) || []; 46 | routes.forEach(({ method, path, schemas }) => { 47 | const fullPath = `${basePath}${path.replace(/\/$/, "")}`; 48 | 49 | if (!paths[fullPath]) { 50 | paths[fullPath] = {}; 51 | } 52 | 53 | const operation: Record = { 54 | summary: `${method.toUpperCase()} ${fullPath} endpoint`, 55 | responses: { 56 | 200: { 57 | description: "Successful response", 58 | content: { 59 | "application/json": { 60 | schema: 61 | getMetadata(schemas?.response) !== null 62 | ? { 63 | $ref: `#/components/schemas/${ 64 | getMetadata(schemas?.response)?.title 65 | }`, 66 | } 67 | : schemas.response, 68 | }, 69 | }, 70 | }, 71 | }, 72 | }; 73 | if (schemas.params) { 74 | operation.parameters = operation.parameters || []; 75 | operation.parameters = [ 76 | { 77 | name: "params", 78 | in: "path", 79 | required: true, 80 | schema: 81 | getMetadata(schemas?.params) !== null 82 | ? { 83 | $ref: `#/components/schemas/${ 84 | getMetadata(schemas?.params)?.title 85 | }`, 86 | } 87 | : schemas.params, 88 | }, 89 | ]; 90 | } 91 | 92 | if (schemas.query) { 93 | operation.parameters = operation.parameters || []; 94 | operation.parameters.push({ 95 | name: "query", 96 | in: "query", 97 | required: false, 98 | schema: 99 | getMetadata(schemas?.query) !== null 100 | ? { 101 | $ref: `#/components/schemas/${ 102 | getMetadata(schemas?.query)?.title 103 | }`, 104 | } 105 | : schemas.query, 106 | }); 107 | } 108 | 109 | if (schemas.body) { 110 | operation.requestBody = { 111 | required: true, 112 | content: { 113 | "application/json": { 114 | schema: 115 | getMetadata(schemas?.body) !== null 116 | ? { 117 | $ref: `#/components/schemas/${ 118 | getMetadata(schemas?.body)?.title 119 | }`, 120 | } 121 | : schemas.body, 122 | }, 123 | }, 124 | }; 125 | } 126 | 127 | paths[fullPath][method.toLowerCase()] = operation; 128 | }); 129 | }); 130 | 131 | return paths; 132 | } 133 | 134 | function getMetadata(schema: any) { 135 | if (schema?._def?.openapi?.metadata) { 136 | return { 137 | title: schema._def.openapi.metadata.title, 138 | description: schema._def.openapi.metadata.description, 139 | }; 140 | } 141 | if (schema?._def?.openapi?._internal?.refId) { 142 | return { title: schema._def.openapi._internal.refId }; 143 | } 144 | 145 | return null; 146 | } 147 | 148 | async function main() { 149 | await loadConsumingProjectModules(); 150 | if (config.Server.cors) { 151 | app.hono.use(cors(config.Server.cors)); 152 | } 153 | app.hono.use(trimTrailingSlash()); 154 | app.controllerRegistry.forEach((controller, basePath) => { 155 | const routes = app.routeRegistry.get(controller.constructor.name) || []; 156 | routes.forEach((route) => { 157 | controller[route.method]( 158 | route.path, 159 | controller[route.handler].bind(controller) 160 | ); 161 | }); 162 | app.hono.route(basePath, controller); 163 | }); 164 | 165 | app.hono.get("/doc", async (ctx: any) => { 166 | const generator = new OpenApiGeneratorV3( 167 | app.hono.openAPIRegistry.definitions 168 | ); 169 | const spec = generator.generateDocument({ 170 | openapi: "3.0.0", 171 | info: { 172 | version: info.version, 173 | title: info.title || "API Documentation", 174 | description: info.description || "OpenAPI Specification", 175 | }, 176 | }); 177 | 178 | spec.paths = generateOpenApiPaths(); 179 | 180 | return ctx.json(spec); 181 | }); 182 | 183 | app.hono.get("/swagger", swaggerUI({ url: "/doc" })); 184 | 185 | console.log(`API is running at http://localhost:${config.Server.port}`); 186 | 187 | Bun.serve({ 188 | fetch: app.hono.fetch, 189 | port: config.Server.port, 190 | }); 191 | } 192 | 193 | if (import.meta.main) { 194 | main().catch((err) => { 195 | console.error("Failed to start server:", err); 196 | process.exit(1); 197 | }); 198 | } 199 | -------------------------------------------------------------------------------- /core/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module "*.css" { 7 | const content: string; 8 | export default content; 9 | } 10 | -------------------------------------------------------------------------------- /core/types/zod-extensions.d.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; 2 | import { ZodType, ZodTypeDef } from 'zod'; 3 | 4 | // declare module 'zod' { 5 | // interface ZodTypeDef { 6 | // openapi?: ZodOpenAPIMetadata; 7 | // } 8 | 9 | // interface ZodType { 10 | // openapi(metadata?: ZodOpenAPIMetadata): this; 11 | // } 12 | // } -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "./core/BaseComponent"; 2 | import { 3 | Component, 4 | Input, 5 | Output, 6 | Service, 7 | ViewChild, 8 | ViewChildren, 9 | } from "./core/Decorators"; 10 | import { EventEmitter } from "./core/EventEmitter"; 11 | import { RouterOutlet, addRouter } from "./core/Routing"; 12 | import { Context } from "./core/Context"; 13 | import { ReactiveArray, Loop } from "./core/Models"; 14 | export { 15 | BaseComponent, 16 | Component, 17 | Input, 18 | Output, 19 | ViewChild, 20 | ViewChildren, 21 | EventEmitter, 22 | RouterOutlet, 23 | Service, 24 | Context, 25 | addRouter, 26 | ReactiveArray, 27 | Loop, 28 | }; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fornaxjs", 3 | "version": "0.1.7", 4 | "type": "module", 5 | "bin": { 6 | "fnx": "bun run ./core/scripts/cli.ts --silent=false" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5.0.0" 10 | }, 11 | "dependencies": { 12 | "@asteasolutions/zod-to-openapi": "^7.3.0", 13 | "@hono/swagger-ui": "^0.5.0", 14 | "@hono/zod-openapi": "^0.18.3", 15 | "@types/bun": "latest", 16 | "@types/parse5": "^7.0.0", 17 | "@vaadin/router": "^2.0.0", 18 | "bun-style-loader": "^0.4.0", 19 | "ejs": "^3.1.10", 20 | "fornax-server": "latest", 21 | "hono": "^4.6.14", 22 | "incremental-dom": "^0.7.0", 23 | "inquirer": "^12.3.0", 24 | "parse5": "^7.2.1", 25 | "reflect-metadata": "^0.2.2", 26 | "rxjs": "^7.8.1" 27 | }, 28 | "exports": { 29 | ".": { 30 | "import": "./index.ts", 31 | "types": [ 32 | "./core.d.ts" 33 | ] 34 | }, 35 | "./server": { 36 | "import": "./server.ts", 37 | "default": "./server.ts" 38 | } 39 | }, 40 | "files": [ 41 | "./index.ts", 42 | "./server.ts", 43 | "./core", 44 | "./core/types/global.d.ts", 45 | "./build/release.d.ts" 46 | ], 47 | "types": "./core/types/global.d.ts", 48 | "sideEffects": true 49 | } 50 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import { ControllerBase } from "fornax-server"; 2 | import { getProjectInfo } from "./core/Utilities"; 3 | export * from "./core/ServerDecorators"; 4 | export { ControllerBase, getProjectInfo }; 5 | export type { Context } from "hono"; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "jsx": "react-jsx", 8 | "allowJs": true, 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "declaration": true, 12 | 13 | // Bundler mode 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "noEmit": true, 17 | 18 | // Best practices 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false, 26 | "baseUrl": ".", 27 | "paths": { 28 | "fornax": ["./"], 29 | } 30 | }, 31 | "include": ["./core/types/global.d.ts","./core/types/zod-extensions.d.ts", "./core/App.ts"], 32 | } 33 | --------------------------------------------------------------------------------