@noego/forge
v2.6.0
Published
Portable Svelte 5 frontend executor for NoEgo version 1 applications. Forge matches an executable frontend route, runs its middleware, controller adapter, and loaders, renders the view through its layout chain during SSR, and returns a Fetch API `Response
Readme
@noego/forge
Portable Svelte 5 frontend executor for NoEgo version 1 applications. Forge
matches an executable frontend route, runs its middleware, controller adapter,
and loaders, renders the view through its layout chain during SSR, and returns
a Fetch API Response.
@noego/app normally compiles the executable binding and generates the
browser hydration entry. Use Forge directly when implementing another build
tool or runtime host.
Installation
npm install @noego/forge svelteSvelte ^5.28.2 is a peer dependency.
Imports
import {
createFrontendApplication,
renderFrontendRoute,
matchFrontendRoute,
type ExecutableFrontendBindingV1,
} from '@noego/forge';The root export and @noego/forge/v1 are equivalent. Import the layout engine
component from @noego/forge/v1/RecursiveRender.svelte.
Create an application
createFrontendApplication converts an executable binding into the portable
NoEgo application shape:
(request: Request, context: NoegoRuntimeContext) => Promise<Response>import {
createFrontendApplication,
type ExecutableFrontendBindingV1,
} from '@noego/forge';
const binding: ExecutableFrontendBindingV1 = {
routes: [
{
path: '/users/{id}',
method: 'get',
view: UserView,
layouts: [AppLayout],
controller: null,
middleware: [],
// One loader for each layout, followed by the view loader.
loaders: [
async () => ({ navigation: [] }),
async ({ params }) => ({ userId: params.id }),
],
},
],
fallback: {
view: NotFoundView,
layouts: [AppLayout],
middleware: [],
},
middleware: {},
};
const application = createFrontendApplication(binding);
const response = await application(request, runtimeContext);Unmatched routes return the configured fallback with status 404. Without a matching route or fallback, the application returns a plain 404 response.
Executable binding
A frontend route contains:
pathand HTTPmethod- a Svelte
view - outer-to-inner
layouts - an optional executable
controller - middleware identifiers
- one loader per layout plus one loader for the view
The loader count must equal layouts.length + 1. Forge runs the loaders
concurrently after the controller adapter. Each loader receives:
interface FrontendExecutionInput {
request: Request;
context: NoegoRuntimeContext;
params: Record<string, string>;
query: URLSearchParams;
controller?: unknown;
}A loader returns the props for its corresponding layout or view:
export default async function load({ params }: FrontendExecutionInput) {
return { userId: params.id };
}The executable controller is a function that receives the same input and
returns controllerProps. When using @noego/app, the build tool creates
this adapter from the route's page-controller module.
Page controllers with @noego/app
The public PageController<Data, Input> interface describes the browser
controller shape produced by App:
interface PageController<Data, Input> {
data: Data;
input: Input;
initialize?(loadData: Record<string, unknown>): void | Promise<void>;
destroy?(): void;
}A route may reference a *.svelte.ts controller class. App constructs the
class for SSR and browser hydration, passes data and input to the view,
calls initialize(loadData) after hydration, and calls destroy() during
cleanup.
Middleware
Bindings map route middleware identifiers to portable functions:
const middleware = {
session: async (input, next) => {
const response = await next();
response.headers.set('x-rendered-by', 'forge');
return response;
},
};Middleware receives the frontend execution input and a next() function.
The current application executor requires the middleware chain to produce a
Response and rejects a middleware implementation that does not.
Rendering and the HTML shell
renderFrontendRoute:
- runs the controller adapter;
- runs the layout and view loaders;
- renders
RecursiveRenderwith Svelte's server renderer; - serializes loader data into
window.__INITIAL_DATA__; - inserts the result into the binding's HTML shell.
Without a custom shell, Forge uses assembleHtmlShell. A binding may provide:
shell({ head, css, app, data }) {
return `<!doctype html>
<html>
<head>${head}<style>${css}</style></head>
<body>
<div id="app">${app}</div>
<script>window.__INITIAL_DATA__=${data}</script>
<script type="module" src="/app.js"></script>
</body>
</html>`;
}Browser hydration with @noego/app
App generates the noego:frontend virtual module from the frontend route
document. The browser entry should mount Forge's route tree over the SSR
markup:
import { mountFrontend } from 'noego:frontend';
mountFrontend();The generated module reuses window.__INITIAL_DATA__ and hydrates
RecursiveRender; it falls back to a clean mount when hydration is not
possible.
JSON data requests (client-side navigation)
A request to a page route that prefers JSON gets the route's loader data
only — the exact object the HTML shell would embed as
window.__INITIAL_DATA__ — instead of a rendered page:
GET /users/42
Accept: application/json
200 content-type: application/json
{ "layout": [...], "view": {...} }Rules:
- The Accept header must prefer
application/jsonovertext/html(wantsLoaderData(request)is exported). Browser-style headers such astext/html,...,application/json;q=0.8keep the HTML render. - Middleware runs first either way — a middleware redirect (302) applies to data requests exactly as it does to page requests.
- An unmatched path answers through the fallback route with
404JSON.
This is the server half of client-side navigation: @noego/app's generated
noego:frontend module fetches page URLs this way on intercepted link
clicks and remounts the route with the returned data.
Client navigation: scroll and prefetch
Forge owns the client-side navigation behavior contract
(client-navigation.ts, consumed by @noego/app's generated runtime).
Scroll. A client-side navigation lands like a full page load: the
viewport scrolls to the top — or to the URL's #fragment element when one
exists. A link opts out with scroll="static", which swaps the page and
leaves the scroll position alone (useful for tabs, filters, and other
in-place page changes):
<a href="/settings/profile" scroll="static">Profile</a>Back/forward navigations never force a scroll — the browser restores the position itself.
Prefetch. A link may declare a prefetch trigger; the moment the
trigger fires, the destination's loader JSON (the same payload a click
would fetch) is requested ahead of time, so the navigation lands on warm
data:
<a href="/projects" prefetch="hover">Projects</a> <!-- on hover or focus -->
<a href="/projects" prefetch="click">Projects</a> <!-- on pointer-down -->on:prefixes are accepted:prefetch="on:click"≡prefetch="click".- On touch devices
touchstartwarms both kinds (there is no hover). - Prefetches are single-flight per URL, expire after 10 seconds, and are consumed by the next navigation to that URL; a failed prefetch is discarded so the real navigation retries from scratch.
- Only same-origin URLs that match a configured page route are prefetched.
The primitives (resolveScrollBehavior, applyNavigationScroll,
normalizePrefetchTrigger, createPrefetchCache,
installPrefetchListeners) are exported from @noego/forge/v1 for custom
runtimes.
Loaders and page controllers must not self-fetch during SSR
A loader or page controller executes inside the request currently rendering the page. It must not call the application's own HTTP surface with a relative URL or the rendering request's origin:
// Do not do this during SSR.
await fetch('/api/users');
await fetch(new URL('/api/users', input.request.url));Resolve the service or repository through the application's IoC container instead. External hosts are allowed, and browser event handlers may call the application API normally.
renderFrontendRoute installs this guard automatically.
LoaderSelfFetchError has code forge/self-fetch.
installSelfFetchGuard and withSelfFetchGuard are exported for runtime
hosts and tests.
Public API
The package exports:
createFrontendApplicationmatchFrontendRouterenderFrontendRoutewantsLoaderDataassembleHtmlShellRecursiveRenderthrough its Svelte subpath exportLoaderSelfFetchError,installSelfFetchGuard, andwithSelfFetchGuard- frontend binding, route, middleware, runtime-context, controller, and shell types
Node frontend application surface (@noego/forge/testing/frontend)
The fast frontend application slice (spec 18/18A): open a production page by
its operationId-based identity, drive the PageController's public
data/input/events contract, cross the real in-process backend through
an injected transport, and observe production navigation — no DOM, no
listener, no global patching. This subpath is Svelte-free and safe to import
in plain Node.
import { createFrontendSurface, trackTransport, FrontendNavigator } from '@noego/forge/testing/frontend';
const { frontend, navigator } = createFrontendSurface({ routes, root, pending: [inFlight] });
const page = await frontend.open({ page: 'connect.login' });
await frontend.act(() => page.input.submit());
frontend.current(); // the page production navigation landed on
await frontend.settle(); // fixed-point drain of owned work
frontend.errors; // unhandled loader/dispose/navigation failuresSemantics (Phase 0 decisions D-FAS-01/02/04/05):
open({ page })is required and validates the identity against the selected aperture; short names resolve only when unique, ambiguity lists qualified<module>.<pageId>candidates.- Navigation targets are production URLs (what
navigate('/path')emits) or page identities, resolved through the same catalog; a destination outside the aperture fails with a focused diagnostic instead of widening the test. act()/settle()drain framework-owned work only: tracked transport requests (trackTransport) and navigation transitions. Timer-based async is outside the guarantee and fails loudly (SettleNonConvergenceError).FrontendPageexposesidentity, livedata/input/eventsreferences,params, andclose()— never the controller instance.- Plain Node proves explicit controller behavior and
$state/$derivedreachable through it;$effect/component-root behavior belongs to the happy-dom and browser tiers.
Product entry: testApp(config).select({ client: { module | page | pages } })
.buildFrontend() in @noego/app/testing wires this surface up from the
production config — auto-imported controllers/loaders, a separate client IoC
root, the in-process Dinner transport with a browser-parity cookie jar, and
env.fetch/env.navigator for page dependencies.
