@dokebi/app
v0.4.5
Published
Multiple apps per project for Dokebi: pick the app and template by request domain, with EJS and Inertia skeletons.
Maintainers
Readme
@dokebi/app 0.4.4
An optional plugin for several apps in one project that picks the app and template by request domain. An optional package for Node >=24.0.0 and core ^0.4.0 (public on npm). The design is D-0030.
Install
dokebi add @dokebi/appThe required plugins dokebi-web, dokebi-controller and dokebi-template-ejs are installed in the same change (result autoInstalled), and the first app src/apps/main and domains: { main.<project>.localhost: { app: main } } are created (the project's own development host, from the package.json name or the folder name: two projects on one machine never share its cookies; dokebi app link main <host> adds another). template-ejs comes along at install time because the first app is an EJS skeleton. At run time app collects view engines (ejs, inertia) and realtime transports (websocket, sse) by name from @dokebi/web's contribution points and never imports those packages (D-0063 §5). Errors are AppError (VIEW_ENGINE_MISSING, REALTIME_UNAVAILABLE and so on, AI.md §6). To add more apps:
dokebi app add shop --domain a.com --domain www.a.com
dokebi app link shop b.com --template christmas # add a domain to an existing app
dokebi app unlink b.com
dokebi app list
dokebi app remove shop # remove the domains pointing at the app first; the folder staysDomain links are recorded in both dokebi.yaml and dokebi.example.yaml. When an app uses Inertia pages, WebSocket or SSE, run dokebi add @dokebi/template-inertia, dokebi add @dokebi/websocket or dokebi add @dokebi/sse separately.
Folder layout
It follows D-0031. dokebi app add creates the app folder.
src/apps/<app>/
app.ts export default defineApp({ ... })
routes.ts routes
controllers/ defineController tokens (this app only)
models/ plain code (not plugins)
views/default/ the default template (required). EJS apps: home.ejs; Inertia apps: Layout.vue, Pages/Home.vue
views/<template>/ only the files that change; missing files are looked up in default, then shared src/shared/views
views/app.js (Inertia apps only) Vite entry: finds per-template pages and layouts in the same order
sockets/ this app's WebSocket routes (defineSocket), created when needed
src/shared/ controllers, models, modules (dokebi-module) and views shared by several appsdokebi app add <app> --inertiacreates an Inertia skeleton and prepares the build (vite.config.ts, thebuildscript, Vite and Vue dev dependencies,dist/in.gitignore). Runnpm run buildafterwards.- With
--ssr(D-0039) it also creates the server entryviews/ssr.js,inertia.ssr: { bundle: 'dist/ssr/<app>/ssr.js' }in app.ts and the project'svite.ssr.config.ts, andbuildruns both the browser and the server build (a previously generated script with onlyvite buildbecomes both; a script you wrote yourself is kept and reported asBUILD_SCRIPT_KEPT). The browser entryviews/app.jshydrates the server-rendered page withcreateSSRApp. Layouts are chosen with thelayoutoption instead of changing the page module (requests with different templates render on the server at the same time). - @dokebi/template-inertia serves the build files under
/build/. Details are in the CLI docs.
The app name is the folder name. The generated registration code (src/dokebi.generated.ts) imports ./apps/<app>/app.ts for every app listed in .dokebi/state.json and passes them to this plugin as entry { apps: { <app>: ... } }.
Configuration
dokebi-app:
domains:
a.com: { app: shop } # no template = default
b.com: { app: shop, template: christmas }
c.com: { app: admin }
s.com: { app: shop, seal: true } # seal every WebSocket frame of this host (below)
unknownDomain: reject # default; or a key of domains (answered with those settings)
apps: # optional; keys are defined app names (otherwise the start fails with UNKNOWN_APP)
shop: { realtime: { namespaces: [front] } } # default [<app>]
admin: { realtime: { namespaces: [admin, front] } } # see "Realtime namespaces" belowThe host from dokebi-web's requestHost() (X-Forwarded-Host from a trusted proxy) is compared exactly, without the port and a trailing dot (a.com. is a.com) and in lower case. unknownDomain answers only a Host that is a host name, an IP address or empty; any other Host header (a.com/x, [email protected], markup) is refused like an unknown host. Behind a proxy, list the proxy addresses in dokebi-web trustProxy. There are no wildcards or path prefixes yet. The start refuses unknown apps, missing template folders and a missing views/default/.
defineApp
import { defineApp } from '@dokebi/app';
import { dependency as db } from '@dokebi/db';
export default defineApp({
requires: [db], // plugins and shared modules this app uses
controllers: [productController],
views: new URL('./views/', import.meta.url),
inertia: { vite: { manifest: 'dist/.vite/manifest.json', entry: 'src/apps/shop/views/app.js', base: '/build/' } },
modules: { '/board': board }, // mounts a feature module under a prefix (D-0032)
shared: (request, page) => ({ menu: MENU }), // data merged into every EJS page of this app
routes(route, app) {
route.get('/', (request, reply, page) =>
page.view.reply(reply, 'home', app.controller(productController).list(), { layout: 'layouts/main' }),
);
},
sockets: (app) => [chatSocket(app)], // results of defineSocket(...)
events: (app) => [ordersStream(app)], // @dokebi/sse routes (SseRoute)
setup(app) {}, start(app) {}, drain(app) {},
});- A handler's
pageis{ app: { name, template, domain }, module, base, view, inertia, sealed }. In app routesmoduleis undefined andbaseis''.viewis an EJS renderer that looks in<app>/<template>→<app>/default→ shared src/shared/views; layouts and$includefollow the same order.inertiauses the app's document settings (Vite entry and so on) and addsappto the shared props. - Error pages (D-0074): errors on an app's hosts (unknown routes included) go through the app first. An Inertia app answers Inertia validation errors with the redirect back (
errors) and other errors withinertia.errorPage(for exampleError); for a browser page an EJS app rendersviews/<template>/errors/<status>.ejs, elseerrors/error.ejs(same lookup as pages, withstatus,title,error,message,hint,fields), else @dokebi/web's small page. JSON clients always get JSON. Theapp addskeletons includeerrors/404.ejsanderrors/error.ejs(EJS) orPages/Error.vuewitherrorPage: 'Error'(Inertia). Refuse input withthrow validationError({ name: 'Already taken' })from @dokebi/web. - HTML forms come back (0.4.1, D-0074 addendum 1): with @dokebi/session registered, an HTML form post refused with 422
VALIDATION_FAILEDflasheserrorsandold(without password-like fields) and answers 303 to theRefererof the same host, and every EJS page getsdata.errors,data.oldanddata.flash(always objects, the flash read once per request), so the form shows<%= data.old.title ?? '' %>anddata.errors.title. Without a session, or without a same-host Referer, the refused form getserrors/422.ejs(elseerrors/error.ejs) withfields,errorsandold. A Referer whose path reads as another host (//other.host/…) is no way back (0.4.2). Another 4xx whosedata.formErrorsis a{ field: message }record goes back the same way (0.4.3, D-0076 §2.5;_formis the whole-form key, which @dokebi/csrf uses for an expired token). Scripts (fetch, XHR, Bearer, JSON, Inertia) keep their JSON. Needs @dokebi/web ^0.4.3. - Mounting modules (D-0032):
modules: { '/prefix': module }, where the module was made withdefineModule.- The module's
routesare registered with this app's route constraint and the prefix; handlers getpage.moduleandpage.base. - The module's
socketsare registered under the prefix. Their tenant is<namespace>:<tenant>, as for app sockets. - Several apps can mount the same module under different prefixes. A mounted module's API is readable with
app.get(module)without listing it inrequires. - The prefix is
/or/a/b(lower-case letters, digits,.,_,~,-). - Module pages look for views in this order, so module pages render inside the app layout:
<app>/<template>/<module><app>/default/<module>- the module's
<template> - the module's
default - the app's views
- shared views
- The module's
shared(request, { app }): merged under the data of every EJS page this app renders (app routes and module routes). On overlapping keys the page data wins. Put app data the layout needs, such as the menu or the user, here. It corresponds to Inertia's shared props.getandgetDependencyof the app context (app) allow only the plugins listed inrequires. Controller factories have the same limit.- A plugin several apps require is checked against every app's version range (core gets their semver AND, which keeps each range's prerelease rule); ranges that cannot meet stop
createPluginwithDEPENDENCY_CONFLICT, naming both apps. - App routes are registered with a Fastify route constraint (
dokebiApp), so several apps can own the same path. Shared routes without the constraint (health checks, module APIs) answer on every domain. - The app's WebSocket routes (
sockets) and SSE routes (events, @dokebi/sse) use the same constraint. The hub tenant is<namespace>:<tenant>and the default namespace is the app name, so without settings it stays<app>:<tenant>, one per app. The session'sidentity.tenantandstream.tenanthold this value. - Sockets and SSE routes take a route id like app routes (D-0140):
config: { id: 'chat' }ondefineSocketor theSseRoute.dokebi describelists it, dokebi-web's route hooks and the refusal logs (routeId) see it, and an id used twice in one app (routes, sockets, streams and those of its modules together) fails the start withDUPLICATE_ROUTE_ID.
Realtime namespaces (0.2.0, D-0061)
Presence lists, broadcasts and SSE publishing are divided by namespace, not by app. apps.<app>.realtime.namespaces (1 to 32, default [<app>]) are the spaces the app may use. Apps with the same namespace share presence, broadcasts and chat (this is not the namespace of the hub settings).
- One connection = one namespace. When a socket's or SSE route's
authenticatereturnsnamespace, the connection joins that space (it must be in the app's list), otherwise the first in the list. Outside the list: 401 and warnNAMESPACE_FORBIDDEN.namespacedoes not stay in the session identity. - Server code:
app.realtime(namespace?)gives the handle of that space (NAMESPACE_FORBIDDENoutside the list). A target's tenant is the authenticated tenant without the prefix; leaving it out means every tenant of the space.
const front = app.realtime('front');
await front.list(); // connections on every server (tenant, member, rooms…)
await front.list({ tenant: 'shop-1', room: 'vip' });
await front.send({ tenant: 'shop-1', member: 'u42' }, 'chat.message', { text: 'Hello' });
await front.send({}, 'notice', { text: 'Maintenance' }); // the whole space
await front.kick({ tenant: 'shop-1', member: 'u42' });
const stop = front.onPresence(({ tenant, joined, left }) => {});
await front.publish({ tenant: 'shop-1' }, 'order.changed', { id: 7 }); // @dokebi/ssesenduses the same{ type, data }frame as defineSocket broadcasts, at most once (D-0057), sealed per socket for sealed sockets. Without the needed plugin it throwsREALTIME_UNAVAILABLE.- The admin app example (watching two front spaces and chatting with users) is in AI.md §4 "Realtime namespaces".
Seal hosts (0.2.0, D-0059 §4)
With domains.<host>.seal: true (default false), app sockets connected through that host seal every frame, and the host's HTTP data (REST JSON requests and responses, the Inertia first page and visits, SSE data) is sealed too (the dokebi-web seal slot, D-0062). Route seal: false and dokebi-web sealExclude are plain exceptions; EJS HTML stays as it is. app fills a socket's route.seal from the request host; the actual sealing and the static files under /dokebi/seal/* (browser loader, socket bundle, wasm) are the @dokebi/seal plugin's job. This public package looks that plugin up only by name (dokebi-seal).
- Pages get
sealed:page.sealedin handlers,sealedin EJS data (the app'ssharedand page data win),sealedin Inertia shared props. A sealed page preloads the seal loader (wasm) and connects with the seal bridge. A client given the bridge offers bothdokebi.sealanddokebi.plain, so on a plain hostdokebi.plainis picked and it runs in plain text. - Refused at start (fail closed): a
seal: truedomain without thedokebi-sealplugin isSEAL_PROVIDER_MISSING. An app on a sealed domain that has sockets with @dokebi/websocket older than 0.3 gets the same code. (The warnSEAL_WITHOUT_SOCKETSfor sealed apps without sockets was removed by D-0062: HTTP is sealed too.) - The rules of sealed connections (4415, 4400, hello, broadcasts) follow "Sealing per host" in the @dokebi/websocket README.
Plugin routes on an app's hosts (D-0132)
Routes that other plugins add on dokebi-web answer on every host unless they are bound to apps. dokebi-app gives web its domain map, so a route with app and any plugin option <plugin>.app (dokebi-mcp.app: mcp, dokebi-oauth.app: studio, dokebi-oauth.resources.<id>.app) answers on exactly that app's hosts, dev hosts and unknownDomain included; other hosts get the app's 404 like an unknown route. See examples/53-app-hosts.
API
createPlugin returns the API { names, resolve(request) }. resolve returns the request's app, template and domain, or undefined for an unknown Host. APP_CONSTRAINT ('dokebiApp'), hostOf(request) and withoutPort(host) are public too.
Service composition
Use Controller -> Service -> Model for application data. Controllers handle HTTP input/output;
services provide data and coordinate workflows through models; models own persistence.
Generated app/module starters include this chain. Keep services free of request/reply and
rendering objects, and reuse them from jobs. Compose dependencies once during setup.
App-specific services belong in src/apps/<app>/services, cross-app services in
src/shared/services, and module services in src/shared/modules/<module>/services.
Shared services must not import app-specific code. These are ordinary application classes,
not a new framework plugin or service registry. Existing projects are not moved automatically.
