Skip to content

hushkey-app/howl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

101 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐺 Howl

The full-stack Deno framework powering Hushkey

Howl is a backend-first, Deno-native full-stack framework built on top of Fresh. It was created to power Hushkey — a multi-vertical platform for foreigners living in Japan — and is open-sourced under MIT for others to use.


Why Howl

Fresh is excellent. But building a production platform on top of it revealed gaps that required workarounds:

  • No native cookie API on ctx
  • Response headers set in middleware didn't propagate to page renders
  • No React ecosystem compatibility without Vite
  • No first-class typed endpoint system with Zod validation
  • No auto-generated OpenAPI spec
  • No role-based access control built in

Howl solves all of these natively.


Packages

Import Description
@hushkey/howl Core runtime — routing, context, islands, SSR
@hushkey/howl/dev Build pipeline — esbuild, HMR
@hushkey/howl/plugins Official plugins — Tailwind v4, typed http client gen
@hushkey/howl/api Endpoint contracts — defineApi, Zod validation, OpenAPI

Project structure

my-app/
├── client/
│   ├── pages/
│   │   ├── _app.tsx        ← root shell (html/head/body)
│   │   ├── _layout.tsx     ← shared UI layout (nav, sidebar, etc.)
│   │   └── index.tsx
│   └── islands/
│       └── counter.island.tsx
├── server/
│   ├── main.ts             ← app entrypoint
│   ├── middleware/
│   │   └── _index.middleware.ts
│   └── apis/
│       └── public/
│           └── ping.api.ts
├── static/
│   └── style.css
├── howl.config.ts          ← State type + defineApi factory
├── dev.ts                  ← dev/build entrypoint
└── deno.json

Quick start

howl.config.ts

import { defineConfig, memoryCache, redisCache, tryCache } from "@hushkey/howl/api";
import { Redis } from "ioredis";

const redis = new Redis(Deno.env.get("REDIS_URL") ?? "redis://localhost:6379");

export interface State {
  userContext?: UserContext;
}

export interface UserContext {
  user?: { id: string; roles: Role[] };
}

export const roles = ["user", "admin"] as const;
export type Role = typeof roles[number];

// defineConfig returns a pre-typed defineApi — import it in your .api.ts files
// so you don't need explicit <State, Role> type params everywhere.
export const { defineApi, config: apiConfig } = defineConfig<State, Role>({
  roles,
  // memory-first, Redis fallback — swap primary/fallback freely
  cache: tryCache(memoryCache({ maxSize: 1000 }), redisCache(redis)),
  checkPermissionStrategy: (ctx, allowedRoles) => {
    const user = ctx.state.userContext?.user;
    if (!user) return ctx.json({ message: "Unauthorized" }, { status: 401 });
    if (!allowedRoles.some((r) => user.roles.includes(r))) {
      return ctx.json({ message: "Forbidden" }, { status: 403 });
    }
    // return nothing = allow
  },
});

server/main.ts

import { Howl, staticFiles } from "@hushkey/howl";
import { preactEngine } from "@hushkey/howl-preact";
import type { State } from "../howl.config.ts";
import { apiConfig } from "../howl.config.ts";
import { middleware } from "./middleware/_index.middleware.ts";

// Page rendering is a registered engine (no implicit default) — Preact here.
export const app = new Howl<State>({ logger: true, engines: { preact: preactEngine() } });

app.use(staticFiles());
app.configure(middleware);
app.fsApiRoutes(apiConfig); // crawls server/apis/, registers all .api.ts
app.fsClientRoutes(); // crawls client/pages/, mounts all routes

export default { app };

app.configure(fn) returns this synchronously when fn is sync, and Promise<this> when fn is async — so you can await app.configure(async (a) => { await db(); }) for boot-time async work and keep chaining sync calls below it.

dev.ts

import { HowlBuilder } from "@hushkey/howl/dev";
import { tailwindPlugin } from "@hushkey/howl/plugins";
import { app } from "./server/main.ts";
import type { State } from "./howl.config.ts";

const builder = new HowlBuilder<State>(app, {
  root: import.meta.dirname ?? "",
  importApp: () => app,
  outDir: "dist",
  serverEntry: "./server/main.ts",
  clientEntry: "./client/pages/_app.ts",
});

tailwindPlugin(builder.getBuilder("default")!);

if (Deno.args.includes("build")) {
  await builder.build();
} else {
  await builder.listen();
}

client/pages/_app.tsx — root HTML shell

import type { RouteConfig } from "@hushkey/howl";
import type { FunctionComponent, JSX } from "preact";

export const config: RouteConfig = {};

export default function App({ Component }: { Component: FunctionComponent }): JSX.Element {
  return (
    <html>
      <head>
        <title>My App</title>
        <link rel="stylesheet" href="/style.css" />
      </head>
      <body>
        <Component />
      </body>
    </html>
  );
}

client/pages/_layout.tsx — shared UI layout (nav, sidebar, etc.)

import type { FunctionComponent, JSX } from "preact";

export default function ({ Component }: { Component: FunctionComponent }): JSX.Element {
  return (
    <>
      <div class="navbar bg-base-200">
        <div class="navbar-start">
          <a href="/" class="btn btn-ghost text-xl">🐺 Howl</a>
        </div>
        <div class="navbar-end">
          <a href="/" class="btn btn-ghost btn-sm">Home</a>
          <a href="/docs" class="btn btn-ghost btn-sm">Docs</a>
        </div>
      </div>
      <main>
        <Component />
      </main>
    </>
  );
}

Note: @hushkey/howl/runtime must point to shared.ts, not client/mod.ts. client/mod.ts imports partials.ts which calls document.addEventListener at module level — it crashes server-side. shared.ts exports Partial, IS_BROWSER, asset, and Head safely for both server and client.

Partial-nav and non-HTML responses: when a f-client-nav link points to a non-HTML resource (a file download, an image, a Content-Disposition: attachment response, etc.), the client SPA detects the non-HTML Content-Type and falls back to a full browser navigation instead of trying to apply the response as a partial. API routes are not the intended target for <a href> — use fetch() for those — but the same fallback applies if you accidentally link to one.

Link prefetching

Links inside an f-client-nav boundary are prefetched on intent — when the pointer hovers (after a brief ~65 ms dwell so quick pass-overs don't fire) or a touch / keyboard-focus signals intent. AOT routes pre-import() their JS chunk; SSR routes pre-fetch their partial response. The eventual click reuses the warmed result, so navigation feels instant — the same idea as Hotwired Turbo / instant.page.

It's on by default and respects the user's data-saver preference (Save-Data / prefers-reduced-data). Opt a link or whole subtree out with f-prefetch="false":

<a href="/huge-report" f-prefetch="false">Report</a>
<nav f-prefetch="false">  </nav>   {/* opt out an entire region */}

client/pages/index.tsx

import type { Context } from "@hushkey/howl";
import type { State } from "../../howl.config.ts";

export default function Index(ctx: Context<State>) {
  return <h1>Hello, {ctx.state.userContext?.user?.id}</h1>;
}

Endpoint contracts

Each .api.ts file is a self-contained, typed endpoint contract: method, roles, Zod-validated query params / request body / responses, optional caching. No wiring needed — drop the file and it's live.

server/apis/public/ping.api.ts

import { defineApi } from "../../../howl.config.ts"; // pre-typed, no <State, Role> needed
import { z } from "zod";

export default defineApi({
  name: "Ping",
  directory: "public",
  method: "GET",
  // path is optional — auto-generated as /api/public/ping
  roles: [],
  caching: { ttl: 5 },
  query: z.object({
    page: z.string().optional(),
    limit: z.string(),
  }),
  responses: {
    200: z.object({ ok: z.boolean(), message: z.string() }),
  },
  handler: (ctx) => {
    const { limit } = ctx.query(); // typed: { page?: string; limit: string }
    const page = ctx.query("page"); // typed: string | undefined
    return {
      statusCode: 200,
      ok: true,
      message: `pong 🐺 — page ${page ?? 1}, limit ${limit}`,
    };
  },
});

server/apis/private/users/get-me.api.ts

import { defineApi } from "../../../../howl.config.ts";
import { z } from "zod";

export default defineApi({
  name: "Get Me",
  directory: "private/users",
  method: "GET",
  roles: ["user", "admin"], // typed — autocomplete works
  responses: {
    200: z.object({ data: z.any() }),
  },
  handler: async (ctx) => ({
    statusCode: 200,
    data: ctx.state.userContext, // ctx.state typed as State
  }),
});

With typed request body:

import { defineApi } from "../../../howl.config.ts";
import { z } from "zod";

const body = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export default defineApi({
  name: "Sign In",
  directory: "authentication",
  method: "POST",
  roles: [],
  requestBody: body,
  responses: {
    200: z.object({ data: z.object({ token: z.string() }) }),
    401: z.object({ message: z.string() }),
  },
  handler: async (ctx) => {
    const { email, password } = ctx.req.body; // fully typed
    return { statusCode: 200, data: { token: "jwt..." } };
  },
});

The OpenAPI spec is generated automatically. Expose it on any route you choose, with whatever auth middleware you need:

import { getApiSpecs } from "@hushkey/howl/api";

// public
app.get("/api/docs", (ctx) => ctx.json(getApiSpecs()));

// or gated behind a role
app.get("/api/docs", requireRole("admin"), (ctx) => ctx.json(getApiSpecs()));

getApiSpecs() returns null before the server starts, and the fully typed OpenAPIV3_1.Document once routes are registered — query params, request body, path params, roles, and responses all included.


Caching

Response caching is configured once in howl.config.ts and applied per-endpoint via caching: { ttl }.

Three adapters ship out of the box:

Adapter Use case
memoryCache() Default. In-process LRU, zero deps
redisCache(client) Shared cache across instances. Accepts any ioredis-compatible client
kvCache(kv) Deno KV — globally consistent on Deno Deploy, SQLite-backed locally
tryCache(primary, fallback) Tries primary first, falls back on miss or error

All built-in adapters expose an atomic incr(key, ttl) op which the rate limiter uses to count requests safely under concurrent load on shared backends. Custom adapters that omit incr fall back to a non-atomic read-modify-write path — safe only when the backend isn't shared.

import { memoryCache, redisCache, tryCache } from "@hushkey/howl/api";
import { Redis } from "ioredis";

const redis = new Redis(Deno.env.get("REDIS_URL"));

// Redis-first, memory fallback
cache: tryCache(redisCache(redis), memoryCache({ maxSize: 1000 }));

// with timeout — falls back if primary doesn't respond within 150ms
cache: tryCache(redisCache(redis), memoryCache(), { timeoutMs: 150 });

// two Redis nodes (e.g. regional primary + global fallback)
cache: tryCache(redisCache(redisSG), redisCache(redisUS));

redisCache attaches an error listener automatically so ioredis reconnection events don't become unhandled crashes — errors are logged via console.warn so they remain visible. Implement CacheAdapter to plug in any other backend.

Atomic rate limiting

Rate limit counters are written via cache.incr(key, ttl). Redis maps this to INCR + EXPIRE (atomic server-side); Deno KV uses an atomic().check().set() CAS loop; the in-memory adapter is trivially atomic. Custom adapters without incr fall back to read-modify-write — don't use that on a shared backend.

Rate limit identifier

Counters key on whatever getRateLimitIdentifier(ctx) returns on HowlApiConfig — Howl doesn't assume a State shape. Falls back to the client IP when unset or undefined.

defineConfig({
  getRateLimitIdentifier: (ctx) => ctx.state.user?.id,
});

Error envelope

API errors are returned as { error, correlationId } plus an X-Howl-Correlation-Id response header. The full route descriptor is logged server-side only — it is no longer leaked on the wire.

Response redaction is your job

Howl does not auto-mutate response payloads. The previous "redact any field named password" behaviour was security theatre (apiKey, token, secret, pwd, etc. all leaked) and has been removed. Strip sensitive fields in your handler before returning.


Context extensions

// Cookies — first class, append semantics preserved
ctx.cookies.set("token", jwt, { httpOnly: true, sameSite: "Strict" });
ctx.cookies.get("token");
ctx.cookies.delete("session");

// Response headers — auto-merged into all responses including page renders
ctx.headers.set("X-Request-Id", crypto.randomUUID());

// Query params
const search = ctx.query("q");
const all = ctx.query();

React ecosystem

React libs work transparently — no configuration needed:

// client/islands/ToastIsland.island.tsx
import { toast, Toaster } from "sonner";
import { useState } from "preact/hooks";
import { ClientOnly } from "@hushkey/howl";

export default function ToastIsland() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <ClientOnly>{() => <Toaster />}</ClientOnly>
      <button
        onClick={() => {
          setCount((c) => c + 1);
          toast.success(`${count + 1}`);
        }}
      >
        Click
      </button>
    </div>
  );
}

Three escape hatches for browser-only code, ordered from coarse to fine:

Tool Scope Use when
export const howl = { ssr: false } Whole island The component itself can't SSR (Mapbox, WebGL, libs that touch window on import)
<ClientOnly>{() => <X />}</ClientOnly> One nested element Most of the island SSRs fine but one child crashes (sonner <Toaster />)
import { IS_SERVER, IS_BROWSER } from "@hushkey/howl" One branch in code Need a different value or skip a side-effect on the server
// One nested widget
<ClientOnly>{() => <ThirdPartyWidget />}</ClientOnly>;

// Inline guard
const stored = IS_BROWSER ? localStorage.getItem("prefs") : null;

For islands that opt out of SSR you can render a layout-matching placeholder so the page doesn't shift while the JS loads:

export const howl = {
  ssr: false,
  skeleton: () => <div class="h-64 bg-base-200 animate-pulse rounded" />,
};

The skeleton receives the same props as the island and is replaced by the real component on hydration.

Default islands (ssr: true, no directive) hydrate against their SSR output — no flash, no wipe. The hydrate switch is automatic; nothing to configure.


File-system conventions (build-time enforcement)

Island files must be named *.island.tsx — both inside islands/ directories and inside (_islands) route groups. The crawler now throws on mismatch instead of warning; this surfaced silent hydration bugs in prior releases.

Vue islands (experimental). Top-level *.island.vue files are recognised by the crawler and handled by the optional @hushkey/howl-vue package — author an island as a Vue SFC and drop it in with <VueIsland name="…" />. It uses Howl's existing esbuild toolchain (no Vite). Requires vuePlugin() in your builder; see the package README.

Pluggable render engines — Preact, Vue & React. Page rendering is a registered engine — there is no implicit default. The framework is split into four packages: @hushkey/howl (core + the Preact runtime) · @hushkey/howl-preact · @hushkey/howl-vue · @hushkey/howl-react. Select an engine on the app — new Howl({ engines: { preact: preactEngine() } }) (or vue: vueEngine() / react: reactEngine()) — plus the matching builder plugin (preactPlugin() / vuePlugin() / reactPlugin()). The shared backend — routing, APIs, middleware, client-nav + prefetch, AOT/SSG, deno compile — is reused unchanged; only the component renderer differs, and all three engines use the same client-nav / client-prefetch attributes. Each engine also backs ctx.renderToString(component, props?) — render a standalone template to an HTML string (emails, notifications) in whatever engine you picked, no page shell.

Programmatic navigation. The Vue and React engines expose a router from @hushkey/howl-{vue,react}/routernavigate(to, { replace?, scroll? }), navigate(-1) for back/forward, useNavigate(), and a reactive useRoute() ({ href, path, query, params, hash, route }). It drives the same client-nav swap path as link clicks and falls back to a full load before hydration. In dev each engine also ships a route inspector: Vue populates Vue DevTools' built-in Routes tab (via a vue-router-shaped $router shim — no vue-router dependency), and React auto-mounts an in-app floating Howl Routes panel (React DevTools has no plugin-tab API). See the engine READMEs.

If a client entry with page routes is configured but no engine is registered, the build throws (telling you to select one). Backend-only apps (no client entry) are unaffected. Demos: examples/vuety · examples/reacty.

Howl#handler() is built lazily on first call and cached per listener. Registering routes after handler() has been built throws — wire everything up before requesting the handler.


AOT and SSG pages

Two filename prefixes opt a page into client-side navigation and/or build-time prerendering. Direct URL hits always get SSR'd HTML (good for SEO and first-paint); the prefix changes how subsequent navigation works.

Prefix Mode First paint Client nav to this page
(none) SSR Renderer runs per request Partial-nav fetches the partial fragment
__page.tsx AOT Renderer runs per request Dynamic-imports a client chunk, no server hit
___page.tsx SSG Prerendered HTML served from snapshot (no JS run) Dynamic-imports a client chunk, no server hit

__ builds an ESM chunk per page that contains everything that would appear inside the active <Partial> markers on an SSR response — inner layouts (if any) plus the page. Files above the partial in the chain (the _app.tsx shell and any outer _layout.tsx) are not bundled: they're already in the DOM on first paint and stay there across AOT navs, so layout-level islands (navbars, sidebars) keep their state across page changes. On click, the chunk is import()-ed and rendered into the active <Partial> outlet. ___ additionally runs the handler at build time, captures the HTML, and bakes it into the production snapshot so request-time renders are skipped entirely.

AOT chunks need a <Partial> in _app.tsx or the layout chain to mount into. The boundary is detected by a static scan of each file's source for the literal Partial identifier in JSX (<Partial …>) or h/jsx-call form (h(Partial, …)). Aliased imports ({ Partial as P }) are not detected — use the literal Partial name. When no <Partial> is found in an AOT page's chain, chunk emission is silently skipped: the route still SSRs (or serves prerendered HTML for ___-prefixed SSG pages), and in-app navigation to it falls through to a full document load. This makes it safe to keep __/___ prefixes when you intentionally don't use f-client-nav / <Partial>.

AOT navigation honours f-client-nav the same way SSR partial nav does. Drop the attribute from <body> (or set it to "false") and clicks on AOT links fall through to full document navigation — same behaviour as SSR routes. Use f-client-nav="false" on a nested element to opt out a single subtree (e.g. external dashboards) while leaving the rest of the app on SPA-style routing.

// pages/__dashboard.tsx — dynamic SSR, client-navigable
export default function Dashboard(ctx) {
  return <p>Hello, {ctx.state.user?.name}</p>;
}
// pages/___about.tsx — prerendered once at build time
import { Head } from "@hushkey/howl/runtime";

export default function About() {
  return (
    <>
      <Head>
        <title>About</title>
      </Head>
      <p>Static content.</p>
    </>
  );
}

SSG limits and gotchas:

  • The build invokes the handler with an empty ctx — no req, no cookies, no per-user state. Anything user-specific must stay on the dynamic SSR path.
  • Dynamic params (e.g. /properties/:id) are not yet enumerated at build time — a getStaticPaths API is on the roadmap. SSG-flagged routes with params fall through to dynamic SSR with a build-time warning.
  • Build IDs rotate per build, so AOT chunks are served with Cache-Control: public, max-age=31536000, immutable in production.
  • Every SSR response for an AOT/SSG route injects two globals: window.__HOWL_AOT__ (the route → chunk URL map) and window.__HOWL_USER_STATE__ (the snapshot of ctx.state at SSR time).

Conventions

Convention Path
Root HTML shell client/pages/_app.tsx
Shared UI layout client/pages/_layout.tsx
Pages client/pages/
Islands client/islands/
Endpoint contracts server/apis/**/*.api.ts
Middleware server/middleware/
Static files static/
Config howl.config.ts
Build output dist/
OpenAPI spec getApiSpecs() from @hushkey/howl/api
Client runtime import @hushkey/howl/runtimeshared.ts

Built-in logger

const app = new Howl<State>({
  logger: true, // timestamps + PID on all console output
  debug: true, // enables console.debug
});

Powered by Hushkey

Howl is the framework behind Hushkey — a platform helping foreigners navigate housing, jobs, and daily life in Japan.

Every feature was built to solve a real production problem.


License

MIT — see LICENSE

Built with 🐺 by Leo Termine and the Hushkey team.

About

A truly fullstack framework, deno native, Vue & React native support, no-vite

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors