Making own nuxt-like framework with bun

lowbytefox

LowByteFox

Posted on June 10, 2023

Making own nuxt-like framework with bun

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 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 seems to be the only one for bun so we will go with that.

It is true that Buchta has official 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

mkdir bux && cd bux
bun init
Enter fullscreen mode Exit fullscreen mode

Now, we will install needed dependencies

bun i elysia buchta vue
Enter fullscreen mode Exit fullscreen mode

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

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.

<script setup>
    // I need to be here
</script>

<template>
    <div>
        <h1>Hello, World!</h1>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

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

import Elysia from "elysia";

const app = new Elysia();

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Create file plugin.ts that will export basic function bux, like this

import Elysia from "elysia";

export function bux(app: Elysia) {

    return app;
}
Enter fullscreen mode Exit fullscreen mode

This is very basic Elysia plugin which we will plug into Elysia

import Elysia from "elysia";
+ import { bux } from "./plugin";
// ... 
- app.listen(3000);
+ app.use(bux);
Enter fullscreen mode Exit fullscreen mode

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.

export default {
    port: 3000,
    ssr: true,
}
Enter fullscreen mode Exit fullscreen mode

Let's load the config in our plugin using require

+    const config = require(process.cwd() + "/bux.config.ts").default;
    return app;
Enter fullscreen mode Exit fullscreen mode

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

sed -i "s/this.builder.prepare();/this.builder.prepare(this.config?.dirs ?? ['public']);/" node_modules/buchta/src/buchta.ts
Enter fullscreen mode Exit fullscreen mode

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

Under the config variable, add

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(() => {

});
Enter fullscreen mode Exit fullscreen mode

And outside of the plugin function, add

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;
}
Enter fullscreen mode Exit fullscreen mode

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

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);
Enter fullscreen mode Exit fullscreen mode

Explanation

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" } });
    });
}
Enter fullscreen mode Exit fullscreen mode

Will execute function route.func that will server render the page and return server-rendered HTML.

if (!config.ssr && "html" in route) {
    app.get(fixRoute(dirname(route.route)), (_: any) => {
         return new Response(route.html, { headers: { "Content-Type": "text/html" } });
    });
}
Enter fullscreen mode Exit fullscreen mode

If SSR is disabled, it will send the default CSR html template

if (!("html" in route)) {
    app.get(route.route, () => Bun.file(route.path));
    app.get(route.originalRoute, () => Bun.file(route.path));
}
Enter fullscreen mode Exit fullscreen mode

This will setup a route for everything else

Now, if you run

bun run index.ts
Enter fullscreen mode Exit fullscreen mode

And open up your web browser at localhost:3000/ You should see
Hello World Page

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

<style>
    @import url("/IBMPlexSans-Regular.ttf");
    body {
        margin: 0;
        padding: 0;
    }

    * {
        font-family: 'IBM Plex Sans', sans-serif;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

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

add counter.vue in there

<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>
Enter fullscreen mode Exit fullscreen mode

And open up pages/index.vue add import

import Counter from "/counter.vue";
Enter fullscreen mode Exit fullscreen mode

And add it under <h1>Hello, World!</h1>

<template>
    <div>
        <h1>Hello, World!</h1>
+        <Counter />
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

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

<script setup>
    // I am main layout
</script>

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

<style scoped>
    h3 {
        color: red;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Import it and replace div with the import

<script setup>
    import Counter from "/counter.vue";
+    import Main from "/main.vue";
</script>

<template>
-    <div>
+    <Main>
        <h1>Hello, World!</h1>
        <Counter />
-    </div>
+    </Main>
</template>
Enter fullscreen mode Exit fullscreen mode

After all of that, now your page should look like this
Complete basic vue page

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 METHODs can be found on elysia's docs

Create directory server
And add file hello.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");
}
Enter fullscreen mode Exit fullscreen mode

Information about the Context can be found here

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

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And call it in our plugin

buchta.earlyHook = earlyHook;
+ fsRouter(app);
Enter fullscreen mode Exit fullscreen mode

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

import { Context } from "elysia";

export default (ctx: Context) => {
    return {
        headers: ctx.headers,
        params: ctx.params
    };
}
Enter fullscreen mode Exit fullscreen mode

And as you can see
Route
Elysia responded with json object containing headers and params!

Let's quickly test our /api/hello

Route

And that worked too! When you look into the console. There should be something like

Before handle
{}
Enter fullscreen mode Exit fullscreen mode

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

You can learn more here

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

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

Let's get started
Install primevue with bun

bun i primevue
Enter fullscreen mode Exit fullscreen mode

and create file vue.config.ts for example

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';");
Enter fullscreen mode Exit fullscreen mode

This will setup both PrimeVue and ToastService it comes with

Import the file in bux.config.ts

+ import "./vue.config.ts";
Enter fullscreen mode Exit fullscreen mode

Open pages/index.vue and let's add primevue CSS and component imports

<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" />
Enter fullscreen mode Exit fullscreen mode

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

Congratulations! You have just set up primevue components!

💖 💪 🙅 🚩
lowbytefox
LowByteFox

Posted on June 10, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related