Making own nuxt-like framework with bun
Tags: bunjs, buchta, elysia, vue
Published: 2023-06-10 18:06:29

Do you like both bun and nuxt? I do, however, nuxt doesn't work on bun. So I've decided to make my own nuxt-like framework. It won't be the same as nuxt but it will feel similar to nuxt. It won't be near far advanced as nuxt but it will be enough for basic applications, not to mention you can extend it to your liking.
> You like Svelte? Follow the blog as well! Rewriting vue to svelte is really simple, instead of `vue`, use `svelte`. It should work the same!

## Let's get started!

I am not that good at making names so we will call it `bux`.

Before we move on, we should talk about dependencies.
Nuxt has around **600** dependencies. Yikes 😬
We are not going to follow that principle! First, we will need a backend server framework like `express.js`. However, express is very slow so [`elysia`](https://elysiajs.com) comes to the rescue! Okay, we've got our backend server, now we will need a frontend build tool that works natively on bun. [`buchta`](https://github.com/Fire-The-Fox/buchta) seems to be the only one for bun so we will go with that.

It is true that `Buchta` has [official Elysia integration](https://www.npmjs.com/package/buchta-elysia-integration), however, we will modify it to meet our needs.

And last but not least `vue`.

## Time to code

First, we will create an empty project
```shell
mkdir bux && cd bux
bun init
```

Now, we will install needed dependencies
```shell
bun i elysia buchta vue
```
After dependencies are installed, open up your favorite text editor.

### Frontend

What should we do next? First, we should look at [nuxt's directory structure](https://nuxt.com/docs/guide/directory-structure/pages)

The first directory we will look at is `pages`. Let's create a page at `/` that will display `Hello, World!`. Create a `pages` directory and file `index.vue` in the directory.
And we will write a basic vue template.
```vue
<script setup>
    // I need to be here
</script>

<template>
    <div>
        <h1>Hello, World!</h1>
    </div>
</template>
```
> Why also script? I haven't made a check whether it is there or not.

Now, we want to serve it to the user, so open `index.ts` and create a basic `Elysia` server
```ts
import Elysia from "elysia";

const app = new Elysia();

app.listen(3000);
```
Create file `plugin.ts` that will export basic function `bux`, like this
```ts
import Elysia from "elysia";

export function bux(app: Elysia) {

    return app;
}
```
This is very basic `Elysia` plugin which we will plug into `Elysia`
```diff
import Elysia from "elysia";
+ import { bux } from "./plugin";
// ... 
- app.listen(3000);
+ app.use(bux);
```
Why we would want to remove `app.listen`? We will call it after everything is done because `Elysia` won't register new routes after it has been executed.

Now, we will create a config file `bux.config.ts` similar to [nuxt](https://nuxt.com/docs/guide/directory-structure/nuxt.config).
```ts
export default {
    port: 3000,
    ssr: true,
}
```

Let's load the config in our plugin using `require`
```diff
+    const config = require(process.cwd() + "/bux.config.ts").default;
    return app;
```
> If you want, you can create a type for your config

Before we can continue, we need to patch `Buchta` first, I've forgotten to assign a part from Buchta's config.

Which would be
```shell
sed -i "s/this.builder.prepare();/this.builder.prepare(this.config?.dirs ?? ['public']);/" node_modules/buchta/src/buchta.ts
```

Now that `Buchta` is patched, we can create a basic setup

Under the `config` variable, add
```ts
if (existsSync(process.cwd() + "/.buchta/"))
    rmSync(process.cwd() + "/.buchta/", { recursive: true });
// this will prevent piling up files from previous builds

const buchta = new Buchta(false, {
    port: config.port,
    ssr: config.ssr,
    rootDir: process.cwd(),
    dirs: ["pages"],
    plugins: []
});

buchta.earlyHook = earlyHook;

buchta.setup().then(() => {

});
```
And outside of the plugin function, add
```ts
const extraRoutes = new Map<string, Function>();

// This is a hook for files that doesn't have a plugin like pngs
const earlyHook = (build: Buchta) => {
    build.on("fileLoad", (data) => {
        data.route = "/" + basename(data.path);
        const func = async (_: any) => {
            return Bun.file(data.path);
        }

        extraRoutes.set(data.route, func);
    })
}

// This function will fix route so elysia won't behave abnormally 
const fixRoute = (route: string, append = true) => {
    if (!route.endsWith("/") && append) {
        route += "/";
    }

    const matches = route.match(/\[.+?(?=\])./g);
    if (matches) {
        for (const match of matches) {
            route = route.replace(match, match.replace("[", ":").replace("]", ""));
        }
    }

    return route;
}
```

Now we will add the `vue` function call into the `plugins` of Buchta's config
> Import the vue function from "buchta/plugins/vue"

We've got the build system ready, now we need to serve the build to `Elysia`. To do that, we need to fight a little bit against typescript because I forgot to make a getter. In the `then` function call add
```ts
for (const [route, func] of extraRoutes) {
    // @ts-ignore ssh
    app.get(route, func);
}

// @ts-ignore I forgot
for (const route of buchta.pages) {
    if (route.func) {
        app.get(fixRoute(dirname(route.route)), async (_: any) => {
            return new Response(await route.func(dirname(route.route), fixRoute(dirname(route.route))),
                                { headers: { "Content-Type": "text/html" } });
        });
     } else {
        if (!config.ssr && "html" in route) {
            app.get(fixRoute(dirname(route.route)), (_: any) => {
                return new Response(route.html, { headers: { "Content-Type": "text/html" } });
            });
        }

        if (!("html" in route)) {
            app.get(route.route, () => Bun.file(route.path));
            app.get(route.originalRoute, () => Bun.file(route.path));
        }
    }
}
app.listen(config.port);
```

Explanation
```ts
if (route.func) {
    app.get(fixRoute(dirname(route.route)), async (_: any) => {
    return new Response(await route.func(dirname(route.route), fixRoute(dirname(route.route))),
                        { headers: { "Content-Type": "text/html" } });
    });
}
```
> Will execute function `route.func` that will server render the page and return server-rendered HTML.

```ts
if (!config.ssr && "html" in route) {
    app.get(fixRoute(dirname(route.route)), (_: any) => {
         return new Response(route.html, { headers: { "Content-Type": "text/html" } });
    });
}
```
> If SSR is disabled, it will send the default CSR html template

```ts
if (!("html" in route)) {
    app.get(route.route, () => Bun.file(route.path));
    app.get(route.originalRoute, () => Bun.file(route.path));
}
```
> This will setup a route for everything else

Now, if you run
```shell
bun run index.ts
```
And open up your web browser at `localhost:3000/` You should see
![Hello World Page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y3i4znyauyev511olk2y.png)

> Awesome!

If you want for example route `/:test/`
Simply go into the `pages` directory, and create directory `[test]` and `index.vue` inside.
> This is the first difference to nuxt!

The next directories will simply be `public` & `assets`.
All you need is to create both directories and add them inside of the `dirs` array of Buchta's config.

Into `assets` put your own `favicon.ico` for example and into `public` for example a font from Google Fonts. I used the `IBM Plex Sans` font.

So in my case, I just added
```vue
<style>
    @import url("/IBMPlexSans-Regular.ttf");
    body {
        margin: 0;
        padding: 0;
    }

    * {
        font-family: 'IBM Plex Sans', sans-serif;
    }
</style>
```

We of course would like to make our components, so make a `components` directory, and add it to `dirs`

add `counter.vue` in there
```vue
<script setup>
    import { ref } from "vue";
    const count = ref(0);
    const increment = () => {
        count.value++;
    };
</script>

<template>
    <div>
        <h3>{{ count }}</h3>
        <button @click="increment">Increment</button>
    </div>
</template>
```

And open up `pages/index.vue` add import
```js
import Counter from "/counter.vue";
```
And add it under `<h1>Hello, World!</h1>`
```diff
<template>
    <div>
        <h1>Hello, World!</h1>
+        <Counter />
    </div>
</template>
```

Stop and run the server again, and now when you open `localhost:3000/` you should see the header with the component!

For the `layouts` directory, do the same as you did with components. However, when you want to use it, you must import it first!

add `main.vue` in there
```vue
<script setup>
    // I am main layout
</script>

<template>
    <div>
        <slot></slot>
        <h3>Footer</h3>
    </div>
</template>

<style scoped>
    h3 {
        color: red;
    }
</style>
```
Import it and replace `div` with the import
```diff
<script setup>
    import Counter from "/counter.vue";
+    import Main from "/main.vue";
</script>

<template>
-    <div>
+    <Main>
        <h1>Hello, World!</h1>
        <Counter />
-    </div>
+    </Main>
</template>
```

After all of that, now your page should look like this
![Complete basic vue page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/smp1o0b9e8gx8zkmjzin.png)

### Backend

The last directory will be `server`, I'll make it not entirely nuxt-like but it will get its job done. Also, we will be merging `middleware` here.
> Don't add this directory into `dirs`

Just like in nuxt, the route with a specific method is being created as `routeName.METHOD.ts` where a list of `METHOD`s can be found on [elysia's docs](https://elysiajs.com/concept/route.html#custom-method)

Create directory `server`
And add file `hello.ts`
```ts
import { Context } from "elysia";

export default (ctx: Context) => {
    console.log(ctx.query);
    return "Hello, world!";
}
// Very simple middleware (https://elysiajs.com/concept/middleware.html)
export const beforeHandle = (_: any) => {
    console.log("Before handle");
}
```
Information about the `Context` can be found [here](https://elysiajs.com/concept/handler.html#context)

Now that we have created a basic route, let's connect it to Elysia.

Open the `plugin.ts` file and let's make the FS-based router
```ts
const fsRouter = (app: Elysia) => {
    const files = getFiles(process.cwd() + "/server");
    for (const file of files) {
        const path = file.replace(process.cwd() + "/server", "");
        const count = path.split(".").length - 1;
        if (count == 1) {
            const route = path.split(".")[0];
            const mod = require(file);
            const func = mod.default;
            app.get(fixRoute("/api" + route, false), func, mod);
        } else if (count == 2) {
            const [route, method, _ext ] = path.split(".");
            const mod = require(file);
            const func = mod.default;
            app[method](fixRoute("/api" + route, false), func, mod);
        }
    }
}
```

And call it in our plugin
```diff
buchta.earlyHook = earlyHook;
+ fsRouter(app);
```

You may get an error saying a function `getFiles` is missing, just import it like so `import { getFiles } from "buchta/src/utils/fs";`

What does the router do? It imports your file, uses the default exported function as the route function and everything else will be sent to Elysia, such as the `beforeHandle` function which will be executed before the route function.

> How to handle param routes?
It is very simple, just like in nuxt, create file `[hello].post.ts` which be registered as route `/api/:hello` with method `POST`. I opened Postman and sent a request on that route
Here you can find the code
```ts
import { Context } from "elysia";

export default (ctx: Context) => {
    return {
        headers: ctx.headers,
        params: ctx.params
    };
}
```

And as you can see
![Route](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oipdnzh2751zjby80ajp.png)
Elysia responded with json object containing headers and params!

Let's quickly test our `/api/hello`

![Route](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jllf23fcbhmcgidrhvep.png)

And that worked too! When you look into the console. There should be something like
```
Before handle
{}
```

> What about wildcards? Simply call the file `*.ts`.
> What about every method? `route.all.ts`

You can learn more [here](https://elysiajs.com/concept/route.html)

## All is done!

Congratulations! You made it all the way through!
Now enjoy your nuxt-like full-stack framework!

You can find the source code on [github](https://github.com/Fire-The-Fox/Own-Nuxt-Like-Framework)

If you liked this experience give `Elysia`, `Buchta` repositories a ⭐

> This part is for Vue plugins, svelte doesn't have  plugins so you can skip this

## Vue Plugins

Till `buchta` v0.6 is out, the Vue plugin has a temporary solution on how to use Vue plugins. Currently we will focus on 3rd party vue components [primevue](https://primevue.org/)

Let's get started
Install `primevue` with bun
```shell
bun i primevue
```
and create file `vue.config.ts` for example
```ts
import { App } from "buchta/plugins/vue";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";

App.use(PrimeVue, { ripple: true });
App.use(ToastService);

App.clientUse("PrimeVue", "{ ripple: true }", "import PrimeVue from 'primevue/config';");
App.clientUse("ToastService", "undefined", "import ToastService from 'primevue/toastservice';");
```

This will setup both PrimeVue and ToastService it comes with

Import the file in `bux.config.ts`
```diff
+ import "./vue.config.ts";
```

Open `pages/index.vue` and let's add primevue CSS and component imports
```diff
<script setup>
+    import "primevue/resources/primevue.min.css";
+    import "primevue/resources/themes/lara-light-indigo/theme.css";
     // ...
+    import Button from 'primevue/button';
+    import Toast from 'primevue/toast';
+    import { useToast } from 'primevue/usetoast';

+    const toast = useToast();
+    const showSuccess = () => {
+        toast.add({ severity: 'success', summary: 'Success Message', detail: 'Message Content', life: 3000 });
+    };
</script>
// in template under <Counter />
+        <Toast />
+        <Button label="Click" @click="showSuccess" />
```

Hold on! Before you restart the server, open `plugin.ts` and add the `css()` plugin into `plugins`. Import the function from `buchta/plugins/css`

And now when you save everything and restart the server and open `localhost:3000/` and click the indigo button a notification should show up.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bf45qx70a5ufjqftfbeh.png)

Congratulations! You have just set up primevue components!