Skip to content

Frontend Controller

Asena's @FrontendController lets you serve HTML pages using Bun's native HTML import feature. Routes are registered directly with Bun.serve() and bypass the middleware chain entirely, providing zero-overhead static page serving.

Quick Start

typescript
import { FrontendController } from '@asenajs/asena/decorators';
import { Page } from '@asenajs/asena/decorators/http';

@FrontendController('/ui')
export class AppFrontendController {

  @Page('/')
  public home() {
    return import('./pages/home.html');
  }

}

With this setup, visiting /ui serves the home.html page directly through Bun's native HTML bundler.

Bun HTML Imports

Bun supports importing .html files natively. The import() expression returns an HTMLBundle that Bun can serve with automatic bundling of linked CSS, JS, and other assets. See Bun HTML Imports for details.

@FrontendController Decorator

Marks a class as a frontend page controller.

typescript
import { FrontendController } from '@asenajs/asena/decorators';

// String shorthand
@FrontendController('/ui')

// Full options
@FrontendController({
  path: '/ui',
  name: 'DashboardFrontend', // Optional: component name for IoC
})
ParameterTypeRequiredDescription
pathstringYesBase URL path for all pages
namestringNoComponent name for IoC registration

@Page Decorator

Defines a page route within a @FrontendController. Each @Page method returns a Bun HTML import.

typescript
import { Page } from '@asenajs/asena/decorators/http';

@Page('/')          // Serves at {basePath}/
@Page('/settings')  // Serves at {basePath}/settings
@Page('/about')     // Serves at {basePath}/about

The method decorated with @Page must return the result of an HTML import():

typescript
@Page('/dashboard')
public dashboard() {
  return import('./pages/dashboard.html');
}

How It Works

  1. @FrontendController('/ui') stores the base path in component metadata
  2. @Page('/') collects sub-routes for each page method
  3. During bootstrap, Asena registers these routes directly with Bun.serve()'s routes option
  4. Requests are served by Bun's native HTTP server — no middleware, no adapter overhead

Middleware Bypass

Frontend Controller routes bypass the entire middleware chain. This means:

  • No CORS middleware
  • No authentication middleware
  • No rate limiting
  • No logging middleware

This is by design — HTML pages are served as static assets with zero overhead. If you need middleware processing for your routes, use a regular @Controller instead.

Multiple Pages

Serve multiple pages from a single controller:

typescript
import { FrontendController } from '@asenajs/asena/decorators';
import { Page } from '@asenajs/asena/decorators/http';

@FrontendController('/app')
export class DashboardController {

  @Page('/')
  public home() {
    return import('./pages/home.html');
  }

  @Page('/settings')
  public settings() {
    return import('./pages/settings.html');
  }

  @Page('/about')
  public about() {
    return import('./pages/about.html');
  }

}

This registers:

  • /apphome.html
  • /app/settingssettings.html
  • /app/aboutabout.html

HTML File Structure

Your HTML files are standard HTML with Bun's bundling support:

html
<!-- src/frontend/pages/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My App</title>
  <link rel="stylesheet" href="./styles.css">
</head>
<body>
  <div id="app">
    <h1>Welcome to My App</h1>
  </div>
  <script type="module" src="./app.ts"></script>
</body>
</html>

Bun automatically bundles linked CSS, JavaScript, and TypeScript files.

FrontendController vs Controller

Feature@FrontendController@Controller
PurposeServe HTML pagesHandle API requests
MiddlewareNo (bypassed)Yes (full chain)
Served byBun.serve() routes directlyHTTP adapter (Hono/Ergenecore)
PerformanceZero overheadAdapter overhead
Use caseStatic pages, SPAsREST APIs, dynamic content

Production Build

When you run asena build, HTML import paths are automatically rewritten so they resolve correctly from the output directory. However, you must configure include in your asena.config.ts to copy the HTML files to the build output.

Required Configuration

typescript
import { defineConfig } from '@asenajs/asena-cli';

export default defineConfig({
  sourceFolder: 'src',
  rootFile: 'src/index.ts',
  include: ['src/frontend/pages'], 
  buildOptions: {
    outdir: 'dist',
  },
});

The include option copies files and directories into the output directory during build. Without it, your HTML files won't exist in the production build and imports will fail at runtime.

Don't add *.html to external

Do not add '*.html' to buildOptions.external. The CLI handles HTML imports automatically — marking them as external yourself will prevent the path rewriting from working.

typescript
// ❌ Bad: Breaks HTML import path rewriting
buildOptions: {
  external: ['*.html'],
}

// ✅ Good: Let the CLI handle HTML imports
buildOptions: {
  // No *.html in external
}

How It Works

  1. asena build bundles your code into a single output file (e.g., dist/index.asena.js)
  2. The HTML build plugin detects .html imports and rewrites paths relative to the project root
  3. include copies the HTML files to the output directory preserving their structure
  4. At runtime, import('./src/frontend/pages/home.html') resolves correctly from dist/

Example Project Structure

my-app/
├── src/
│   ├── frontend/
│   │   ├── AppFrontendController.ts
│   │   └── pages/
│   │       ├── home.html
│   │       ├── settings.html
│   │       └── app.ts
│   └── index.ts
├── asena.config.ts          # include: ['src/frontend/pages']
└── dist/                    # After build:
    ├── index.asena.js
    └── src/frontend/pages/  # Copied by include
        ├── home.html
        ├── settings.html
        └── app.ts

Best Practices

1. Use for Static Pages

typescript
// ✅ Good: Static HTML pages
@FrontendController('/ui')
export class AppFrontend {
  @Page('/')
  home() { return import('./pages/home.html'); }
}

// ❌ Bad: Don't use for API routes
@FrontendController('/api')
export class ApiFrontend {
  @Page('/users')
  users() { return import('./pages/users.html'); } // Should be a Controller
}

2. Organize Pages in a Dedicated Directory

src/
├── controllers/      # API controllers
├── frontend/
│   ├── AppFrontendController.ts
│   └── pages/
│       ├── home.html
│       ├── settings.html
│       ├── styles.css
│       └── app.ts
└── services/

3. Consider Auth Requirements

typescript
// ✅ Good: Public pages via FrontendController
@FrontendController('/public')
export class PublicPages {
  @Page('/')
  landing() { return import('./pages/landing.html'); }
}

// ✅ Good: Protected pages should handle auth client-side
// or use a @Controller with middleware for SSR

SPA Authentication

For single-page applications, handle authentication on the client side. The SPA can call your authenticated API endpoints (@Controller with auth middleware) while the initial HTML page is served without middleware.


Next Steps:

Released under the MIT License.