Skip to content

wompojs/seawomp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

seawomp

A Web Component–first meta-framework built on top of Wompo, powered by Bun. File-based routing, nested layouts, async loaders, server-side rendering with streaming Suspense, islands-first hydration, View Transitions, hover/visibility prefetch with modulepreload, server actions, API routes, SSG, and an optimized <seawomp-image> primitive with build-time WebP/AVIF variants.

Status

Pre-1.0. The dev server, build pipeline (with JS/CSS/HTML minification), API routes, image optimization, and client router are implemented and covered by bun test (37 tests across 8 files, ~280 ms).

Requirements

  • Bun ≥ 1.1 (Bun ships native TypeScript, the dev server, the bundler, and the test runner — no Node, no Vite, no separate transpiler.)

Quickstart — start a new seawomp project

bunx seawomp new my-app
cd my-app
bun run dev          # → http://localhost:5173

That's it. The scaffolder writes a complete starter (seawomp.config.ts, app/layout.ts, home + about pages, a loader-driven /posts page, an /api/health route, and a global CSS file with the <seawomp-image> resets), then runs bun install for you.

To skip the install step (e.g. in CI):

bunx seawomp new my-app --no-install

What you get

my-app/
├── package.json             # bun scripts: dev / build / start
├── tsconfig.json
├── seawomp.config.ts
├── public/
│   └── global.css           # served at /global.css; link it from layout/head
└── app/
    ├── layout.ts            # root layout with <seawomp-link> nav
    ├── page.ts              # /  (home, prerendered)
    ├── about/page.ts        # /about (prerendered)
    ├── posts/
    │   ├── loader.ts        # async loader → page receives `data`
    │   └── page.ts          # /posts
    └── api/
        └── health/route.ts  # GET /api/health

Run it

bun run dev          # → http://localhost:5173 (TS hot-reload, full-page reload on file change)
bun run build        # → .seawomp/ (minified JS, WebP/AVIF image variants, SSG HTML)
bun run build:vercel # → .seawomp/ with public assets copied into Vercel static output
bun run start        # → serves the production build via Bun.serve

Manual setup

If you'd rather scaffold by hand: see Configuration reference below for the config shape, and copy the layout / page snippets from the Pages and API routes sections.

Project layout

my-app/
├── seawomp.config.ts
├── public/                  # served as static assets (raster images optimized at build)
│   ├── global.css
│   └── images/
└── app/
    ├── layout.ts            # root layout — wraps every page
    ├── page.ts              # /  (home)
    ├── blog/
    │   ├── layout.ts        # /blog/* — wrapped by both layouts
    │   └── [id]/
    │       ├── page.ts      # /blog/:id
    │       └── loader.ts    # async data loader for the page
    ├── dashboard/
    │   ├── page.ts
    │   └── error.ts         # error boundary inherited by descendants
    └── api/
        ├── health/route.ts            # GET /api/health
        └── posts/[id]/route.ts        # GET|POST|PUT … /api/posts/:id

Page / layout files default-export a Wompo component. A loader exports loader(args). An API route exports verb-keyed handlers (GET, POST, …). Pages may export prerender: true, prerender: string[], or generateStaticPaths() to opt into SSG.

Pages

// app/blog/[id]/page.ts
import { html, defineWompo } from 'wompo';
import type { PageProps } from 'seawomp';

function PostPage({ params, data }: PageProps<{ body: string }, { id: string }>) {
	return html`
		<article>
			<h1>Post ${params.id}</h1>
			<p>${data.body}</p>
		</article>
	`;
}
defineWompo(PostPage, { name: 'post-page' });
export default PostPage;
// app/blog/[id]/loader.ts
import type { LoaderArgs } from 'seawomp';

export async function loader({ params }: LoaderArgs<{ id: string }>) {
	const r = await fetch(`https://api.example.com/posts/${params.id}`);
	return await r.json();
}

Per-page <head>export function head(props)

Any page or layout may export a head function that returns Wompo html`` output (title, meta, link, …). It receives the same props as the component — including datafrom the adjacent loader — so dynamic routes like[id]/page.ts` can set per-record titles and meta:

// app/blog/[id]/page.ts (continued from above)
import { html, unsafelyRenderString } from 'wompo';

export function head({ params, data }: PageProps<{ title: string; excerpt: string }, { id: string }>) {
	return html`
		${unsafelyRenderString(`<title>${escapeHtml(data.title)}</title>`)}
		<meta name="description" content="${data.excerpt}">
		<meta property="og:title" content="${data.title}">
	`;
}
  • Runs on the server during SSR and SSG; the resulting tags are tagged data-seawomp-head.
  • On SPA navigation, the client router swaps every [data-seawomp-head] element with the new set — document.title updates without a full reload.
  • If head() returns a <title>, the default shell <title> (from seawomp.config.ts) is suppressed so only one is emitted.
  • Wompo escapes interpolated attribute values. For raw-text tags such as <title>, <script>, and <style>, use a small escaped helper with unsafelyRenderString.

Local Google Fonts

Use Font.google() from head(). In dev it emits the normal Google Fonts link; during seawomp build prerendered HTML is rewritten to local /_assets/fonts/ URLs.

import { html } from 'wompo';
import { Font } from 'seawomp';

export function head() {
	return html`${Font.google({ family: 'Inter', weights: [400, 600, 700], display: 'swap' })}`;
}

Static generation

Static pages can still use loaders. Mark a route as build-time safe with prerender = true; the loader runs during the build and the resulting HTML is written to .seawomp/static.

Dynamic and catch-all routes should export generateStaticPaths():

export function generateStaticPaths() {
	return ['/docs/introduction', '/it/docs/introduction'];
	// or: return [{ params: { slug: ['docs', 'introduction'] } }];
}

When siteUrl is configured, the build also writes sitemap.xml from the prerendered paths. Two ways to keep a page out of sitemap.xml (and sitemap.txt / llms.txt) while still prerendering it:

// app/admin/page.ts — explicit opt-out
export const prerender = true;
export const sitemap = false;
// or via robots meta: pages whose rendered <head> carries noindex are excluded automatically
export function head() {
	return html`<meta name="robots" content="noindex, nofollow">`;
}

Pages that are not prerendered (SSR-only) never appear in the sitemap.

Redirects and error responses

Configured redirects run before API/page routing and before production static assets:

export default defineConfig({
	redirects: [{ source: '/old/:slug*', destination: '/new/:slug*', status: 301 }],
});

Loaders and pages can interrupt rendering:

import { notFound, redirect } from 'seawomp';

export async function loader() {
	if (!session) throw redirect('/login', 302);
	if (!doc) throw notFound();
}

Add app/404.ts for custom 404 HTML and app/error.ts for loader/render failures. Both receive normal page props plus status; error.ts also receives error.

Route groups — (group)/ as a layout reset boundary

A directory whose name matches (name) is a route group. It organizes routes without contributing to the URL path and resets the inherited layout / error-boundary chain — its own layout.ts (if any) becomes a new root for the subtree:

app/
├── layout.ts            # site-wide root layout
├── page.ts              # /            → wrapped in app/layout.ts
├── about/page.ts        # /about       → wrapped in app/layout.ts
└── (docs)/
    ├── layout.ts        # fresh root, does NOT inherit app/layout.ts
    ├── error.ts         # fresh error boundary for the group
    └── docs/
        ├── page.ts      # /docs        → wrapped only in (docs)/layout.ts
        └── [slug]/page.ts # /docs/:slug → wrapped only in (docs)/layout.ts

Use it for marketing vs. app shells, docs vs. dashboard, or any section that needs a different chrome from the rest of the site.

API routes

Drop a route.ts anywhere under app/api/. Each file exports zero or more HTTP-verb handlers. The router takes a Fetch Request and returns a Fetch Response — there is no extra abstraction layer.

// app/api/posts/[id]/route.ts
import type { ApiHandler } from 'seawomp';

export const GET: ApiHandler<{ id: string }> = async ({ params }) => {
	const post = await db.posts.findById(params.id);
	return Response.json(post);
};

export const DELETE: ApiHandler<{ id: string }> = async ({ params }) => {
	await db.posts.remove(params.id);
	return new Response(null, { status: 204 });
};
File URL pattern
app/api/hello/route.ts /api/hello
app/api/users/[id]/route.ts /api/users/:id
app/api/posts/[...slug]/route.ts /api/posts/:slug*

Unsupported verbs against a matched route return 405 with the Allow header populated from the file's exports. API routes are matched before pages so they never get shadowed.

<seawomp-image> — optimized images

A first-class image component. Used as a Wompo component inside a page template, it is fully server-rendered — wrapper + placeholder + <img> with loading="lazy" / decoding="async" defaults and, in production, the complete srcset already in the HTML:

import { Image } from 'seawomp/components';

function Hero() {
	return html`<${Image} src="/images/hero.jpg" alt="Studio Mono hero" ratio="16/9" priority />`;
}

It can also be written as a bare custom-element tag; in that form SSR emits the tag as-is and the element builds its DOM on connect (reading window.__SEAWOMP_IMAGES for the srcset). Prefer the component form when the image should be visible to crawlers and the first paint:

<seawomp-image src="/images/hero.jpg" alt="Studio Mono hero" ratio="16/9" priority> </seawomp-image>
Attribute Meaning
src source URL (required)
alt a11y text (required; warns in dev when missing)
srcset hand-pin a srcset; otherwise auto-built from the build manifest
sizes passes through to <img sizes>
width/height numeric — used to reserve aspect-ratio space
ratio CSS aspect-ratio fallback (e.g. "4/3")
priority boolean — fetchpriority=high + loading=eager + decoding=sync
placeholder "blur" (default) or "none"
loading "eager" / "lazy" — overrides the priority default
decoding "sync" / "async" / "auto" — overrides the priority default
fetchpriority "high" / "low" / "auto" — overrides the priority default
crossorigin passes through to <img crossorigin>
referrerpolicy passes through to <img referrerpolicy>
usemap/ismap pass through to the inner <img>

Build-time optimization (WebP / AVIF / srcset)

seawomp build walks public/ and emits resized variants for every raster image (.jpg, .jpeg, .png, .webp). The sharp encoder installs automatically with seawomp (it's an optional dependency); if it's unavailable — unsupported platform, or installs run with optional deps omitted — the build skips image compression with a warning and serves the originals as-is. Add sharp to your own dependencies to pin its version, or set images.disabled: true to opt out entirely.

// seawomp.config.ts
export default defineConfig({
	// …
	images: {
		sizes: [640, 960, 1280, 1920],
		formats: ['avif', 'webp'],
	},
});

Variants land in .seawomp/static/_assets/img/. The build also writes the image map into the production manifest and registers it with the SSR pipeline, so prerendered/server-rendered HTML already carries the full srcset (crawlers, first paint and the LCP preload all use the optimized variants). The prod server additionally injects the map into <head> as window.__SEAWOMP_IMAGES for client-side renders — no markup change required. SVGs are passed through unchanged.

CSS (responsibility of the app)

The framework ships no CSS. Add this once to your global stylesheet:

.seawomp-image__wrap {
	position: relative;
	display: block;
	overflow: hidden;
}
.seawomp-image__placeholder {
	position: absolute;
	inset: 0;
	background: #e5e5e5;
	transition: opacity 240ms ease;
}
.seawomp-image__wrap img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	opacity: 0;
	transition: opacity 380ms ease;
}
.seawomp-image--loaded img {
	opacity: 1;
}
.seawomp-image--loaded .seawomp-image__placeholder {
	opacity: 0;
}

i18n — locale routing and translated paths

Set i18n in the config to enable locale URL routing: the default locale is unprefixed (/about), every other locale lives under its prefix (/it/about). The prefix is stripped before route matching, so one app/about/page.ts serves every locale; static prerender = true routes are emitted once per locale.

export default defineConfig({
	i18n: {
		locales: ['en', 'it'],
		defaultLocale: 'en',
		detectBrowserLocale: true, // optional: 307 from `/` to the browser's locale
		// Optional translated pathnames, keyed by canonical (default-locale) path:
		routes: {
			'/projects': { it: '/progetti' },
			'/about': { it: '/chi-siamo' },
		},
	},
});

With routes configured, /it/progetti serves the /projects page, <seawomp-link href="/projects"> resolves to /it/progetti under the Italian locale, prerendering emits it/progetti/index.html, and the untranslated /it/projects gets a permanent 308 redirect to the translated URL. Nested paths inherit the parent mapping (/projects/alpha/it/progetti/alpha), which covers dynamic segments too. Locales without an entry fall back to the canonical path.

Loaders receive the original URL (prefix included) via LoaderArgs.url; messages and the t() translator live in seawomp/i18n (getLocale, loadMessages, createTranslator, seoI18nHead for canonical/hreflang tags).

<seawomp-link> — client navigation with prefetch

Wraps an <a>, intercepts the click for SPA navigation, and prefetches the destination proactively. Already registered by seawomp/client — no import required.

<seawomp-link><a href="/work">Work</a></seawomp-link>

<seawomp-link prefetch="visible"><a href="/about">About</a></seawomp-link>

<seawomp-link prefetch="none" preload-modules="false">
	<a href="/expensive">Expensive</a>
</seawomp-link>
Attribute Default Effect
prefetch hover hover | visible (IntersectionObserver) | none
prefetch-delay 50 hover debounce in ms
preload-modules true inject <link rel="modulepreload"> for layout + page chunks

What happens on hover:

  1. After prefetch-delay ms, the destination URL is fetched (HTML) and cached.
  2. The layout + page modules are dynamically imported so component definitions are registered.
  3. <link rel="modulepreload"> is appended to <head> for each module URL.
  4. When the user actually clicks, navigate() reuses the cached HTML — no network round-trip.

Cached HTML expires after prefetchTtlMs (default 60 000 ms). Tune via:

import { setRouterOptions, clearPrefetchCache } from 'seawomp/client';
setRouterOptions({ prefetchTtlMs: 30_000 });

Islands

A component becomes an island by passing island: 'load' | 'idle' | 'visible' to defineWompo, or by placing client:load|idle|visible on the call site:

defineWompo(Counter, { name: 'my-counter', island: 'visible' });
// or in a template:
html`<${Counter} client:load start=${10} />`;

The server emits data-wompo-island + a <template data-wompo-props> carrying the initial props (devalue-encoded — supports Date, Map, Set, BigInt, cyclic refs). The hydrate runtime schedules each island per its mode.

Server actions

import { defineAction } from 'wompo/ssr';

export const addItem = defineAction(async (name: string) => {
	// … hit a DB, queue, etc.
	return { id: 1, name };
});

The framework exposes POST /_action/:id automatically. On the client, the wrapped function fetches that endpoint with the arguments encoded by devalue and parses the response.

Streaming Suspense

import { Suspense, html, defineWompo, useAsync } from 'wompo';

function Slow() {
	const data = useAsync(() => fetch('/api/slow').then((r) => r.text()), []);
	return html`<p>${data ?? ''}</p>`;
}
defineWompo(Slow, { name: 'slow-c' });

function Page() {
	return html`
    <${Suspense} fallback=${html`<i>Loading…</i>`}>
      <${Slow} />
    </${Suspense}>
  `;
}
defineWompo(Page, { name: 'page-c' });

The shell (with the fallback inside <wompo-boundary>) is flushed first; once the async work settles, an out-of-order <template data-wompo-resolve> chunk replaces the boundary in place.

CLI

seawomp new <name>  # scaffold a new project (writes templates + runs `bun install`)
seawomp dev         # Bun.serve in dev mode — full TS, HMR via WebSocket (full-page reload)
seawomp build       # Bun.build + lightningcss + sharp → .seawomp/ (minified JS, CSS, HTML; SSG)
seawomp build --target vercel
seawomp vercel-build
seawomp start       # Bun.serve in production mode (serves .seawomp/static/_assets/* statically)

seawomp new accepts --no-install to skip the post-scaffold install step.

Vercel deploy

Seawomp keeps hosting adapters separate from the core runtime. Local production still uses Bun.serve; Vercel uses a small Hono adapter that delegates to the same production handler.

// src/server.ts
import { createVercelApp } from 'seawomp/adapters/vercel';

export default createVercelApp();
{
	"$schema": "https://openapi.vercel.sh/vercel.json",
	"bunVersion": "1.x",
	"buildCommand": "bun run build:vercel",
	"outputDirectory": ".seawomp/static",
	"functions": {
		"src/server.ts": {
			"includeFiles": ".seawomp/**"
		}
	}
}

In a monorepo, keep wompo, seawomp, and the app in the same workspace and declare the app's dependencies with workspace:* while the packages are not published. seawomp does not need to be public on npm for Vercel, as long as Vercel can install it from the workspace or another private package source.

Configuration reference (seawomp.config.ts)

import { defineConfig } from 'seawomp/config';

export default defineConfig({
	appDir: 'app', // route file root (default 'app')
	publicDir: 'public', // static files root (default 'public')
	outDir: '.seawomp', // build output (default '.seawomp')
	port: 5173,
	title: 'My App',
	siteUrl: 'https://example.com',
	redirects: [
		{ source: '/old/:slug*', destination: '/new/:slug*', status: 301 },
	],
	i18n: {
		locales: ['en', 'it'],
		defaultLocale: 'en',
		detectBrowserLocale: false,
		routes: { '/projects': { it: '/progetti' } }, // translated pathnames (optional)
	},
	images: {
		sizes: [640, 960, 1280, 1920],
		formats: ['avif', 'webp'],
		disabled: false,
	},
	minify: {
		js: true, // Bun.build minify (defaults: prod=true, dev=false)
		css: true, // lightningcss
		html: true, // remove comments and whitespace between tags
	},
});

What Bun replaces vs. the previous Vite-based version

Concern Before Now
Dev HTTP server vite.createServer Bun.serve({ fetch, websocket })
TS / JSX transpile Vite plugin chain Bun.Transpiler (per-file) + Bun.build (per-dependency)
Module URLs /@fs/<abs> /_src/<abs> (transpiled) + /_dep/<spec> (bundled node_modules)
Hydrate entry virtual module generated string served at /_hydrate.js
Bundling Vite build Bun.build({ splitting: true, minify: true })
CSS minify lightningcss
HMR Vite WS WebSocket on /__seawomp_hmr — full-page reload on any source change
Test runner vitest bun test

What's not (yet) in the MVP

  • Incremental Static Regeneration
  • Module-level HMR (full-reload only today)
  • Built-in browser e2e suite (Playwright spec is sketched but not wired)

Repository layout

src/
├── cli.ts                    # bin — seawomp new|dev|build|start
├── scaffold.ts               # templates emitted by `seawomp new`
├── index.ts                  # public entry — re-exports types + defineConfig
├── config.ts                 # defineConfig, loadConfig, resolveConfig
├── types.ts                  # PageProps, LoaderArgs, ApiHandler, …
├── shared/paths.ts           # file-path → URL pattern, regex compiler
├── dev/
│   ├── server.ts             # Bun.serve dev entry
│   ├── source-server.ts      # /_src + /_dep transpile / bundle
│   ├── virtual.ts            # /_hydrate.js generator
│   └── hmr.ts                # WS reload broadcaster
├── server/
│   ├── handler.ts            # Fetch-API request handler (action → api → page → 404)
│   ├── render-page.ts        # layout composition + loader + renderToStream
│   ├── action-handler.ts     # POST /_action/:id
│   ├── api-router.ts         # scan + dispatch app/api/**/route.ts
│   ├── ssg.ts                # prerender pages flagged `prerender = true`
│   ├── manifest.ts           # route → asset map (build output)
│   ├── routes.ts             # `app/` scanner (page/layout/loader/error)
│   ├── html.ts               # document shell template
│   ├── static.ts             # public/ static-file server
│   └── index.ts              # public server entry
├── build/
│   ├── bundle.ts             # Bun.build orchestration (client + server)
│   ├── minify-css.ts         # lightningcss wrapper
│   ├── minify-html.ts        # safe head-only whitespace collapser
│   ├── images.ts             # sharp pipeline → WebP/AVIF/srcset
│   └── serve-prod.ts         # Bun.serve in prod mode
├── components/
│   ├── image.ts              # <seawomp-image>
│   ├── link.ts               # <seawomp-link>
│   └── index.ts              # barrel side-effect re-export
└── runtime/
    ├── index.ts              # public client entry
    ├── hydrate-entry.ts      # boots `wompo/hydrate` over the SSR'd document
    ├── router.ts             # navigate() + prefetch cache + modulepreload + View Transitions
    ├── actions.ts            # callAction proxy
    └── head.ts               # data-seawomp-head bookkeeping

License

MIT

About

Web Component-first meta-framework on top of Wompo SSR (file-based routing, islands, View Transitions). Powered by Bun.

Resources

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors