@finesoft/front 0.1.75 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +2 -411
  2. package/dist/browser.d.mts +2 -0
  3. package/dist/browser.mjs +1 -0
  4. package/dist/index.d.mts +2 -1248
  5. package/dist/index.mjs +54 -3557
  6. package/dist/server-data-DGbiKzMS.d.mts +1249 -0
  7. package/dist/start-app-BdXBCcor.mjs +2 -0
  8. package/docs/01-getting-started.md +230 -0
  9. package/docs/02-routing-and-controllers.md +197 -0
  10. package/docs/03-middleware.md +214 -0
  11. package/docs/04-rendering-and-hydration.md +271 -0
  12. package/docs/05-i18n.md +243 -0
  13. package/docs/06-http-client.md +286 -0
  14. package/docs/07-di-container.md +264 -0
  15. package/docs/08-observability.md +290 -0
  16. package/docs/09-server-and-deployment.md +242 -0
  17. package/docs/10-features-platform-pwa.md +238 -0
  18. package/docs/README.md +72 -0
  19. package/docs/advanced/custom-action-handler.md +248 -0
  20. package/docs/advanced/custom-adapter.md +264 -0
  21. package/docs/advanced/custom-event-recorder.md +318 -0
  22. package/docs/advanced/inline-proxy-codegen.md +200 -0
  23. package/docs/advanced/multi-tenant-scopes.md +330 -0
  24. package/docs/engineering/ci-release-flow.md +244 -0
  25. package/docs/engineering/project-structure.md +296 -0
  26. package/docs/engineering/testing.md +317 -0
  27. package/docs/pitfalls/container-scope-leak.md +215 -0
  28. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  29. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  30. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  31. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  32. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  33. package/docs/zh/01-getting-started.md +230 -0
  34. package/docs/zh/02-routing-and-controllers.md +197 -0
  35. package/docs/zh/03-middleware.md +214 -0
  36. package/docs/zh/04-rendering-and-hydration.md +271 -0
  37. package/docs/zh/05-i18n.md +243 -0
  38. package/docs/zh/06-http-client.md +286 -0
  39. package/docs/zh/07-di-container.md +264 -0
  40. package/docs/zh/08-observability.md +287 -0
  41. package/docs/zh/09-server-and-deployment.md +242 -0
  42. package/docs/zh/10-features-platform-pwa.md +238 -0
  43. package/docs/zh/README.md +72 -0
  44. package/docs/zh/advanced/custom-action-handler.md +248 -0
  45. package/docs/zh/advanced/custom-adapter.md +264 -0
  46. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  47. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  48. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  49. package/docs/zh/engineering/ci-release-flow.md +244 -0
  50. package/docs/zh/engineering/project-structure.md +296 -0
  51. package/docs/zh/engineering/testing.md +317 -0
  52. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  53. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  54. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  55. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  56. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  57. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  58. package/package.json +12 -3
@@ -0,0 +1,2 @@
1
+ const e={FLOW:`flow`,EXTERNAL_URL:`externalUrl`,COMPOUND:`compound`};function t(t){return t.kind===e.FLOW}function n(t){return t.kind===e.EXTERNAL_URL}function r(t){return t.kind===e.COMPOUND}function i(t,n){return{kind:e.FLOW,url:t,presentationContext:n}}function a(t){return{kind:e.EXTERNAL_URL,url:t}}var o=class{handlers=new Map;onAction(e,t){if(this.handlers.has(e)){console.warn(`[ActionDispatcher] kind="${e}" already registered, skipping`);return}this.handlers.set(e,t)}removeAction(e){return this.handlers.delete(e)}async perform(e,t=0){if(r(e)){if(t>=32)throw Error(`[ActionDispatcher] CompoundAction recursion depth exceeded (max 32)`);for(let n of e.actions)await this.perform(n,t+1);return}let n=this.handlers.get(e.kind);if(!n){console.warn(`[ActionDispatcher] No handler for kind="${e.kind}"`);return}await n(e)}},s=class{controllers=new Map;register(e){this.controllers.set(e.intentId,e)}async dispatch(e,t){let n=this.controllers.get(e.id);if(!n)throw Error(`[IntentDispatcher] No controller for "${e.id}". Registered: [${Array.from(this.controllers.keys()).join(`, `)}]`);return n.perform(e,t)}has(e){return this.controllers.has(e)}},c=class e{registrations=new Map;resolutionStack=new Set;parent;children=new Set;register(e,t,n=!0){return this.registrations.set(e,{factory:t,singleton:n}),this}resolve(e){let t=this.registrations.get(e);if(!t){if(this.parent)return this.parent.resolve(e);throw Error(`[Container] No registration for key: "${e}"`)}if(t.singleton){if(t.instance===void 0){if(this.resolutionStack.has(e))throw Error(`[Container] Circular dependency detected: ${[...this.resolutionStack,e].join(` → `)}`);this.resolutionStack.add(e);try{t.instance=t.factory()}finally{this.resolutionStack.delete(e)}}return t.instance}return t.factory()}has(e){return this.registrations.has(e)||(this.parent?.has(e)??!1)}createScope(){let t=new e;return t.parent=this,this.children.add(t),t}dispose(){let e=Array.from(this.children);for(let t of e)t.dispose();this.children.clear();for(let e of this.registrations.values())e.instance=void 0;this.registrations.clear(),this.parent&&=(this.parent.children.delete(this),void 0)}};const l=new Set([`ar`,`arc`,`dv`,`fa`,`ha`,`he`,`khw`,`ks`,`ku`,`ps`,`ur`,`yi`]);function u(e){let t=e.split(`-`)[0].toLowerCase();return l.has(t)}function d(e){return u(e)?`rtl`:`ltr`}function f(e){return{lang:e,dir:d(e)}}function p(e,t){return{language:e,region:t,bcp47:t?`${e}-${t}`:e,dir:d(e)}}function m(e){document.documentElement.lang=e.lang,document.documentElement.dir=e.dir}function h(e,t){let n=e.split(`?`)[0].match(/^\/([^/]+)(\/.*)?$/);if(!n)return null;let r=n[1],i=t.find(e=>e.toLowerCase()===r.toLowerCase());return i?{locale:i,strippedUrl:n[2]||`/`}:null}async function g(e,t){if(!e||!t)return;let n=await _();if(n)return n(e,t)}async function _(){if(typeof globalThis.__FINESOFT_I18N_LOADER__==`function`)return globalThis.__FINESOFT_I18N_LOADER__;if(typeof __FINESOFT_I18N_LOADER_SPECIFIER__==`string`)try{let e=await import(__FINESOFT_I18N_LOADER_SPECIFIER__),t=typeof e==`function`?e:typeof e?.loadMessages==`function`?e.loadMessages:void 0;return t?(globalThis.__FINESOFT_I18N_LOADER__=t,t):void 0}catch{return}}async function v(e){let{locale:t,loadMessages:n,context:r}=e;if(!(!t||!r))return n?n(t,r):g(t,r)}function y(e,t){let n=Object.entries(e);if(n.length===0)return;if(typeof n[0][1]==`string`)return e;let r=e[t];if(!r)return;let i={};for(let[e,t]of Object.entries(r)){if(typeof t==`string`){i[e]=t;continue}for(let[n,r]of Object.entries(t))i[`${e}.${n}`]=r}return i}function b(e,t){return t?e.replace(/\{(\w+)\}/g,(e,n)=>{let r=t[n];return r===void 0?`{${n}}`:String(r)}):e}function x(e){return e===1?`one`:`other`}function S(e,t){return`${e}.${t}`}var C=class{locale;messages;pluralRule;fallback;constructor(e){this.locale=e.locale,this.messages=e.messages,this.pluralRule=e.pluralRule??x,this.fallback=e.fallback??(e=>e)}t(e,t){let n=this.messages[e];return n===void 0?this.fallback(e):b(n,t)}plural(e,t,n){let r=S(e,this.pluralRule(t)),i={count:t,...n};return this.t(r,i)}},w=class{constructor(e){this.factories=e}loggerFor(e){return new T(this.factories.map(t=>t.loggerFor(e)))}},T=class{constructor(e){this.loggers=e}debug(...e){return this.callAll(`debug`,e)}info(...e){return this.callAll(`info`,e)}warn(...e){return this.callAll(`warn`,e)}error(...e){return this.callAll(`error`,e)}callAll(e,t){for(let n of this.loggers)n[e](...t);return``}},E=class{category;constructor(e){this.category=e}};const ee={"*":4,debug:4,info:3,warn:2,error:1,off:0,"":0};let D,O;function te(){if(globalThis.localStorage===void 0)return{};let e;try{e=globalThis.localStorage.getItem(`onyxLog`)}catch{return{}}if(!e)return{};if(e===O&&D)return D;O=e;let t={},n=e.split(`,`);for(let e of n){let[n,r]=e.trim().split(`=`);if(!n||r===void 0)continue;let i=ee[r.toLowerCase()]??void 0;i!==void 0&&(n===`*`?t.defaultLevel=i:(t.named??={},t.named[n]=i))}return D=t,t}function k(e,t){let n=te();if(n.defaultLevel===void 0&&!n.named)return!0;let r=ee[t]??4;return n.named?.[e]===void 0?n.defaultLevel===void 0?!0:r<=n.defaultLevel:r<=n.named[e]}function ne(){D=void 0,O=void 0}var A=class extends E{debug(...e){return k(this.category,`debug`)&&console.debug(`[${this.category}]`,...e),``}info(...e){return k(this.category,`info`)&&console.info(`[${this.category}]`,...e),``}warn(...e){return k(this.category,`warn`)&&console.warn(`[${this.category}]`,...e),``}error(...e){return console.error(`[${this.category}]`,...e),``}},re=class{loggerFor(e){return new A(e)}};const j={debug:0,info:1,warn:2,error:3};var M=class extends E{minPriority;report;constructor(e,t){super(e),this.minPriority=j[t.minLevel??`warn`],this.report=t.report}debug(...e){return this.maybeReport(`debug`,e),``}info(...e){return this.maybeReport(`info`,e),``}warn(...e){return this.maybeReport(`warn`,e),``}error(...e){return this.maybeReport(`error`,e),``}maybeReport(e,t){if(!(j[e]<this.minPriority))try{this.report(e,this.category,t)}catch(e){console.error(`[ReportingLogger] report callback threw:`,e)}}},N=class{options;constructor(e){this.options=e}loggerFor(e){return new M(e,this.options)}},P=class{prefix;constructor(e=`Metrics`){this.prefix=e}record(e,t){console.info(`[${this.prefix}:${e}]`,t??``)}async flush(){}destroy(){}};function F(e){let t=e??(typeof navigator<`u`?navigator.userAgent:``),n=t.toLowerCase();return{os:ie(n),browser:ae(n),engine:oe(n),isMobile:/mobile|android|iphone|ipad|ipod/i.test(t),isTouch:typeof navigator<`u`&&`maxTouchPoints`in navigator?navigator.maxTouchPoints>0:!1}}function ie(e){return/iphone|ipad|ipod/.test(e)?`ios`:/android/.test(e)?`android`:/macintosh|mac os x/.test(e)?`macos`:/windows/.test(e)?`windows`:/linux/.test(e)?`linux`:`unknown`}function ae(e){return/edg\//.test(e)?`edge`:/opr\/|opera/.test(e)?`opera`:/samsungbrowser/.test(e)?`samsung`:/chrome|crios/.test(e)&&!/edg\//.test(e)?`chrome`:/firefox|fxios/.test(e)?`firefox`:/safari/.test(e)&&!/chrome/.test(e)?`safari`:`unknown`}function oe(e){return/applewebkit/.test(e)&&!/chrome/.test(e)?`webkit`:/applewebkit/.test(e)&&/chrome/.test(e)?`blink`:/gecko\//.test(e)?`gecko`:`unknown`}const I={LOGGER:`logger`,LOGGER_FACTORY:`loggerFactory`,NET:`net`,STORAGE:`storage`,FEATURE_FLAGS:`featureFlags`,METRICS:`metrics`,FETCH:`fetch`,EVENT_RECORDER:`eventRecorder`,LOCALE:`locale`,PLATFORM:`platform`,TRANSLATOR:`translator`};var se=class{store=new Map;get(e){return this.store.get(e)}set(e,t){this.store.set(e,t)}delete(e){this.store.delete(e)}},ce=class{flags;providers=[];constructor(e={}){this.flags=e}addProvider(e){this.providers.push(e)}isEnabled(e){for(let t=this.providers.length-1;t>=0;t--)if(this.providers[t].isEnabled(e))return!0;return this.flags[e]===!0}getString(e){for(let t=this.providers.length-1;t>=0;t--){let n=this.providers[t].getString?.(e);if(n!==void 0)return n}let t=this.flags[e];return typeof t==`string`?t:void 0}getNumber(e){for(let t=this.providers.length-1;t>=0;t--){let n=this.providers[t].getNumber?.(e);if(n!==void 0)return n}let t=this.flags[e];return typeof t==`number`?t:void 0}},le=class{record(e,t){console.info(`[Metrics:${e}]`,t??``)}recordPageView(e,t){this.record(`PageView`,{page:e,...t})}recordEvent(e,t){this.record(`Event`,{name:e,...t})}};function L(e,t={}){let{_resolvedMessages:n}=t,{fetch:r=globalThis.fetch?.bind(globalThis),featureFlags:i={},featureFlagsProviders:a=[],reportCallback:o,eventRecorder:s,locale:c,platform:l}=t,u=new re,d=o?new w([u,new N({report:o})]):u;e.register(I.LOGGER_FACTORY,()=>d),e.register(I.LOGGER,()=>d.loggerFor(`framework`)),e.register(I.NET,()=>({fetch:(e,t)=>r(e,t)})),e.register(I.STORAGE,()=>new se);let p=new ce(i);for(let e of a)p.addProvider(e);if(e.register(I.FEATURE_FLAGS,()=>p),e.register(I.METRICS,()=>new le),e.register(I.EVENT_RECORDER,()=>s??new P),c&&e.register(I.LOCALE,()=>f(c)),c&&n){let t=y(n,c);t&&e.register(I.TRANSLATOR,()=>new C({locale:c,messages:t}))}e.register(I.PLATFORM,()=>l??F(typeof navigator<`u`?navigator.userAgent:void 0)),e.register(I.FETCH,()=>r)}function R(e){return Object.assign(Object.create(null),e)}var z=class{routes=[];add(e,t,n){let r=typeof n==`string`?{renderMode:n}:n??{},i=[],a=e.split(/(\/:[\w]+\??)/).map(t=>{let n=t.match(/^\/:(\w+)(\?)?$/);if(n){if(i.includes(n[1]))throw Error(`[Router] Duplicate parameter name ":${n[1]}" in pattern "${e}"`);return i.push(n[1]),n[2]?`(?:/([^/]+))?`:`/([^/]+)`}return t.replace(/[.+?^${}()|[\]\\]/g,`\\$&`)}).join(``);return this.routes.push({pattern:e,intentId:t,regex:RegExp(`^${a}/?$`),paramNames:i,renderMode:r.renderMode,beforeGuards:r.beforeGuards,afterGuards:r.afterGuards}),this}resolve(e){let{path:t,queryParams:n}=this.parseUrl(e);for(let r of this.routes){let a=t.match(r.regex);if(a){let t=R(n);return r.paramNames.forEach((e,n)=>{let r=a[n+1];r&&(t[e]=r)}),{intent:{id:r.intentId,params:t},action:i(e),renderMode:r.renderMode,beforeGuards:r.beforeGuards,afterGuards:r.afterGuards}}}return null}getRoutes(){return this.routes.map(e=>`${e.pattern} → ${e.intentId}`)}parseUrl(e){try{let t=new URL(e,`http://localhost`),n=R(Object.fromEntries(t.searchParams));return{path:t.pathname,queryParams:n}}catch{return{path:e.split(`?`)[0].split(`#`)[0],queryParams:R()}}}};async function B(e,t){for(let n of e){let e=await n(t);if(e.kind!==`next`)return e}return{kind:`next`}}async function V(e,t){for(let n of e){let e=await n(t);if(e.kind!==`next`)return e}return{kind:`next`}}const H=new WeakMap;function U(e){return W(e,new Set,0)}function W(e,t,n){if(e==null)return String(e);if(typeof e!=`object`)return JSON.stringify(e);let r=H.get(e);if(r!==void 0)return r;if(n>50)return`"[Max Depth]"`;if(t.has(e))return`"[Circular]"`;t.add(e);try{let r;if(Array.isArray(e))r=`[`+e.map(e=>W(e,t,n+1)).join(`,`)+`]`;else{let i=Object.keys(e).sort(),a=[];for(let r of i){let i=e[r];i!==void 0&&a.push(JSON.stringify(r)+`:`+W(i,t,n+1))}r=`{`+a.join(`,`)+`}`}return H.set(e,r),r}finally{t.delete(e)}}var G=class e{intents;constructor(e){this.intents=e}static fromArray(t){let n=new Map;for(let e of t)if(e.intent&&e.data!==void 0){let t=U(e.intent);n.set(t,e.data)}return new e(n)}static empty(){return new e(new Map)}get(e){let t=U(e),n=this.intents.get(t);if(n!==void 0)return this.intents.delete(t),n}has(e){return this.intents.has(U(e))}get size(){return this.intents.size}},K=class e{container;intentDispatcher;actionDispatcher;router;prefetchedIntents;beforeGuards=[];afterGuards=[];_logger;constructor(e,t){this.container=e,this.intentDispatcher=new s,this.actionDispatcher=new o,this.router=new z,this.prefetchedIntents=t}static create(t={}){let n=new c;L(n,t);let r=new e(n,t.prefetchedIntents??G.empty());return t.setupRoutes?.(r.router),r}getLogger(){return this._logger??=this.container.resolve(I.LOGGER)}async dispatch(e){let t=this.getLogger(),n=this.prefetchedIntents.get(e);return n===void 0?(t.debug(`[Framework] dispatch intent: ${e.id}`,e.params),this.intentDispatcher.dispatch(e,this.container)):(t.debug(`[Framework] re-using prefetched intent response for: ${e.id}`,e.params),n)}async perform(e){return this.getLogger().debug(`[Framework] perform action: ${e.kind}`),this.actionDispatcher.perform(e)}routeUrl(e){return this.router.resolve(e)}didEnterPage(e){this.container.resolve(I.METRICS).recordPageView(e.pageType,{pageId:e.id,title:e.title})}getLocale(){return this.container.has(I.LOCALE)?this.container.resolve(I.LOCALE):void 0}getTranslator(){return this.container.has(I.TRANSLATOR)?this.container.resolve(I.TRANSLATOR):void 0}getPlatform(){return this.container.resolve(I.PLATFORM)}onAction(e,t){this.actionDispatcher.onAction(e,t)}registerIntent(e){this.intentDispatcher.register(e)}beforeLoad(e){this.beforeGuards.push(e)}afterLoad(e){this.afterGuards.push(e)}runBeforeLoad(e,t){return B(t?.length?[...this.beforeGuards,...t]:this.beforeGuards,e)}runAfterLoad(e,t){return V(t?.length?[...this.afterGuards,...t]:this.afterGuards,e)}dispose(){this.container.dispose()}},q=class extends Error{constructor(e,t,n){super(`HTTP ${e}: ${t}`),this.status=e,this.statusText=t,this.body=n,this.name=`HttpError`}},ue=class{baseUrl;defaultHeaders;fetchFn;requestInterceptors;responseInterceptors;constructor(e){this.baseUrl=e.baseUrl,this.defaultHeaders=e.defaultHeaders??{},this.fetchFn=e.fetch??globalThis.fetch.bind(globalThis),this.requestInterceptors=[...e.requestInterceptors??[]],this.responseInterceptors=[...e.responseInterceptors??[]]}useRequestInterceptor(e){return this.requestInterceptors.push(e),this}useResponseInterceptor(e){return this.responseInterceptors.push(e),this}async get(e,t){return this.request(`GET`,e,{params:t})}async post(e,t,n){return this.request(`POST`,e,{body:t,params:n})}async put(e,t,n){return this.request(`PUT`,e,{body:t,params:n})}async del(e,t){return this.request(`DELETE`,e,{params:t})}async request(e,t,n){let r=this.buildUrl(t,n?.params),i={...this.defaultHeaders,...n?.headers},a={method:e,headers:i};n?.body!==void 0&&(Object.keys(i).some(e=>e.toLowerCase()===`content-type`)||(i[`Content-Type`]=`application/json`),a.body=JSON.stringify(n.body));for(let e of this.requestInterceptors)a=await e(r,a);let o=await this.fetchFn(r,a);for(let e of this.responseInterceptors)o=await e(o,r);if(!o.ok){let e=await o.text().catch(()=>void 0);throw new q(o.status,o.statusText,e)}try{return await o.json()}catch(e){throw e instanceof SyntaxError?new q(o.status,`Invalid JSON response`,await o.text().catch(()=>void 0)):e}}buildUrl(e,t){let n=this.baseUrl.endsWith(`/`)?this.baseUrl.slice(0,-1):this.baseUrl,r=e.startsWith(`/`)?e:`/${e}`,i=new URL(`${n}${r}`,`http://placeholder`);if(t)for(let[e,n]of Object.entries(t))i.searchParams.set(e,n);return this.baseUrl.startsWith(`http`)?i.toString():`${i.pathname}${i.search}`}},de=class{fallback(e,t){throw t}async perform(e,t){let n=e.params??{};try{return await this.execute(n,t)}catch(e){return this.fallback(n,e instanceof Error?e:Error(String(e)))}}};function fe(...e){return t=>e.reduce((e,t)=>t(e),t)}function pe(...e){return async t=>{let n=t;for(let t of e)n=await t(n);return n}}function me(e){return t=>t.map(e)}function he(e,t,n){let r=new Set;for(let i of t){i.controller&&!r.has(i.intentId)&&(e.registerIntent(i.controller),r.add(i.intentId));let t={renderMode:i.renderMode,beforeGuards:i.beforeLoad,afterGuards:i.afterLoad};if(e.router.add(i.path,i.intentId,t),n?.locales?.length){let n=i.path===`/`?`/:locale`:`/:locale${i.path}`;e.router.add(n,i.intentId,t)}}}var ge=class{map=new Map;capacity;constructor(e){if(e<1)throw Error(`[LruMap] capacity must be >= 1, got ${e}`);this.capacity=e}get(e){if(!this.map.has(e))return;let t=this.map.get(e);return this.map.delete(e),this.map.set(e,t),t}set(e,t){if(this.map.has(e))this.map.delete(e);else if(this.map.size>=this.capacity){let e=this.map.keys().next().value;e!==void 0&&this.map.delete(e)}this.map.set(e,t)}has(e){return this.map.has(e)}delete(e){return this.map.delete(e)}get size(){return this.map.size}clear(){this.map.clear()}};function _e(e){return e!=null}function ve(e){return e==null}function ye(){return typeof window>`u`?`browser`:document.referrer.startsWith(`android-app://`)?`twa`:window.matchMedia(`(display-mode: standalone)`).matches||`standalone`in window.navigator&&window.navigator.standalone===!0?`standalone`:`browser`}function be(e){return e.replace(/^https?:\/\//,``)}function xe(e){try{let t=new URL(e);return t.pathname+t.search+t.hash}catch{return e}}function Se(e){return e.split(`?`)[0]}function Ce(e){return e.split(`?`)[0].split(`#`)[0]}function we(e,t){if(!t)return e;let n=new URLSearchParams;for(let[e,r]of Object.entries(t))r!==void 0&&n.set(e,r);let r=n.toString();return r?`${e}?${r}`:e}function J(){return typeof crypto<`u`&&crypto.randomUUID?crypto.randomUUID():`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.replace(/[xy]/g,e=>{let t=Math.random()*16|0;return(e===`x`?t:t&3|8).toString(16)})}function Y(e){let t=new Map;if(!e)return t;for(let n of e.split(`;`)){let e=n.indexOf(`=`);if(e===-1)continue;let r=n.slice(0,e).trim(),i=n.slice(e+1).trim();r&&t.set(r,i)}return t}function Te(e){let{url:t,intent:n,container:r,request:i}=e,a=new URL(t,`http://localhost`),o=Y(i?.headers.get(`cookie`)??``);return{url:t,path:a.pathname,params:n.params??{},intent:n,isServer:!0,container:r,getCookie:e=>o.get(e),getHeader:e=>i?.headers.get(e)??void 0}}function X(e){let{url:t,intent:n,container:r}=e;return{url:t,path:new URL(t,window.location.origin).pathname,params:n.params??{},intent:n,isServer:!1,container:r,getCookie:e=>Y(document.cookie).get(e),getHeader:()=>void 0}}function Ee(){return{kind:`next`}}function De(e,t=302){return{kind:`redirect`,url:e,status:t}}function Oe(e){return{kind:`rewrite`,url:e}}function ke(e=403,t=`Forbidden`){return{kind:`deny`,status:e,message:t}}var Ae=class{recorders;constructor(e){this.recorders=e}record(e,t){for(let n of this.recorders)n.record(e,t)}async flush(){let e=[];for(let t of this.recorders)t.flush&&e.push(t.flush());await Promise.all(e)}destroy(){for(let e of this.recorders)e.destroy?.()}},je=class{observer;tracked=new Map;captured=[];minDuration;constructor(e={}){this.minDuration=e.minVisibleDuration??1e3,this.observer=new IntersectionObserver(e=>{let t=Date.now();for(let n of e){let e=this.tracked.get(n.target);e&&(n.isIntersecting?e.visibleSince===null&&(e.visibleSince=t):e.visibleSince!==null&&(t-e.visibleSince>=this.minDuration&&this.captured.push({id:e.id,timestamp:e.visibleSince,metadata:e.metadata}),e.visibleSince=null))}},{threshold:e.threshold??.5})}observe(e,t,n){this.tracked.set(e,{id:t,metadata:n,visibleSince:null}),this.observer.observe(e)}unobserve(e){this.observer.unobserve(e),this.tracked.delete(e)}consume(){let e=Date.now();for(let[,t]of this.tracked)t.visibleSince!==null&&e-t.visibleSince>=this.minDuration&&(this.captured.push({id:t.id,timestamp:t.visibleSince,metadata:t.metadata}),t.visibleSince=e);return this.captured.splice(0)}destroy(){this.observer.disconnect(),this.tracked.clear(),this.captured.length=0}},Me=class{record(){}async flush(){}destroy(){}},Ne=class{constructor(e,t){this.inner=e,this.providers=t}record(e,t){let n={};for(let e of this.providers)Object.assign(n,e.getFields());t&&Object.assign(n,t),this.inner.record(e,n)}async flush(){return this.inner.flush?.()}destroy(){this.inner.destroy?.()}};function Pe(t){let{framework:n,log:r}=t;n.onAction(e.EXTERNAL_URL,e=>{r.debug(`ExternalUrlAction → ${e.url}`),window.open(e.url,`_blank`,`noopener,noreferrer`)})}const Z=5e3;let Q=null;function $(){Q?.()}function Fe(e,t,n){$();let r=Math.max(0,n),i=Date.now(),a=!1,o=null,s=null,c=null,l=null;Q=p,f(),document.addEventListener(`load`,u,!0),s=setInterval(u,100),c=setTimeout(u,Z),u();function u(){a||o!==null||(o=requestAnimationFrame(()=>{o=null,d()}))}function d(){if(a)return;let n=Date.now()-i,o=t();if(!o){n>=Z&&(e.warn(`tryScroll: timed out waiting for the scrollable element`,{target:r,elapsedMs:n}),p());return}o.scrollTop=r;let s=o.scrollTop;if(s>=r-2){e.info(`scroll restored`,{target:r,actual:s,elapsedMs:n}),p();return}n>=Z&&(e.warn(`tryScroll: timed out before reaching the target`,{target:r,actual:s,elapsedMs:n,scrollHeight:o.scrollHeight,clientHeight:o.clientHeight}),p())}function f(){if(typeof MutationObserver>`u`)return;let e=document.body??document.documentElement;e&&(l=new MutationObserver(()=>{u()}),l.observe(e,{childList:!0,subtree:!0}))}function p(){a||(a=!0,Q===p&&(Q=null),o!==null&&(cancelAnimationFrame(o),o=null),s!==null&&(clearInterval(s),s=null),c!==null&&(clearTimeout(c),c=null),l?.disconnect(),l=null,document.removeEventListener(`load`,u,!0))}}var Ie=class{entries;log;getScrollablePageElement;currentStateId;constructor(e,t,n=10){this.entries=new ge(n),this.log=e,this.getScrollablePageElement=t.getScrollablePageElement}replaceState(e,t){$();let n=J();window.history.replaceState({id:n},``,t),this.currentStateId=n,this.entries.set(n,{state:e,scrollY:0}),this.scrollTop=0,this.log.info(`replaceState`,e,t,n)}pushState(e,t){$();let n=J();window.history.pushState({id:n},``,t),this.currentStateId=n,this.entries.set(n,{state:e,scrollY:0}),this.scrollTop=0,this.log.info(`pushState`,e,t,n)}beforeTransition(){$();let{state:e}=window.history;if(!e)return;let t=this.entries.get(e.id);if(!t){this.log.info(`current history state evicted from LRU, not saving scroll position`);return}let{scrollTop:n}=this;this.entries.set(e.id,{...t,scrollY:n}),this.log.info(`saving scroll position`,n)}onPopState(e){window.addEventListener(`popstate`,t=>{$(),this.currentStateId=t.state?.id,this.currentStateId||this.log.warn(`encountered a null event.state.id in onPopState event:`,window.location.href),this.log.info(`popstate`,this.entries,this.currentStateId);let n=this.currentStateId?this.entries.get(this.currentStateId):void 0;if(Promise.resolve(e(window.location.href,n?.state)).catch(e=>{this.log.error(`onPopState listener error:`,e)}),!n)return;let{scrollY:r}=n;this.log.info(`restoring scroll to`,r),Fe(this.log,()=>this.getScrollablePageElement(),r)})}pushUrl(e){$();let t=J();window.history.pushState({id:t},``,e),this.currentStateId=t,this.scrollTop=0,this.log.info(`pushUrl (no state)`,e,t)}replaceUrl(e){$();let t=J();window.history.replaceState({id:t},``,e),this.currentStateId=t,this.scrollTop=0,this.log.info(`replaceUrl (no state)`,e,t)}updateState(e){if(!this.currentStateId){this.log.warn(`failed: encountered a null currentStateId inside updateState`);return}let t=this.entries.get(this.currentStateId),n=e(t?.state);this.log.info(`updateState`,n,this.currentStateId),this.entries.set(this.currentStateId,{scrollY:t?.scrollY??0,state:n})}get scrollTop(){return this.getScrollablePageElement()?.scrollTop||0}set scrollTop(e){let t=this.getScrollablePageElement();t&&(t.scrollTop=e)}};function Le(t){let{framework:n,log:r,callbacks:i,updateApp:a}=t,o=!0,s=0,c=new Ie(r,{getScrollablePageElement:t.getScrollablePageElement??(()=>document.getElementById(`scrollable-page-override`)||document.getElementById(`scrollable-page`)||document.documentElement)});async function l(e,t,d){if(t>=5){r.error(`Navigation redirect loop detected (5 redirects), stopping at: ${e}`);return}let f=o||e===window.location.pathname+window.location.search,p=n.routeUrl(e);if(!p){r.warn(`FlowAction: no route for ${e}`);return}let m=X({url:e,intent:p.intent,container:n.container}),h=await n.runBeforeLoad(m,p.beforeGuards);if(h.kind===`redirect`){r.debug(`beforeLoad → redirect to ${h.url}`),await l(h.url,t+1,d);return}if(h.kind===`deny`){r.warn(`beforeLoad → denied (${h.status}): ${h.message}`);return}if(h.kind===`rewrite`){r.debug(`beforeLoad → rewrite to ${h.url}`),await l(h.url,t+1,d);return}let g=n.dispatch(p.intent);if(await Promise.race([g,new Promise(e=>setTimeout(e,500))]).catch(()=>{}),d!==s){r.info(`FlowAction superseded by newer navigation`,e);return}c.beforeTransition(),a({page:g.then(async a=>{if(d!==s)return r.info(`FlowAction commit superseded`,e),a;let o={...m,page:a},h=await n.runAfterLoad(o,p.afterGuards);if(h.kind===`redirect`)return r.debug(`afterLoad → redirect to ${h.url}`),l(h.url,t+1,d),a;let g=e;return h.kind===`rewrite`&&(g=h.url,r.debug(`afterLoad → rewrite URL to ${g}`)),h.kind===`deny`?(r.warn(`afterLoad → denied (${h.status})`),a):(f?c.replaceState({page:a},g):c.pushState({page:a},g),i.onNavigate(new URL(g,window.location.origin).pathname),u(a),a)},t=>{if(d===s){let t=e;f?c.replaceUrl(t):c.pushUrl(t),i.onNavigate(new URL(t,window.location.origin).pathname)}throw t}),isFirstPage:o}),o=!1}n.onAction(e.FLOW,async e=>{let t=e,a=t.url;if(r.debug(`FlowAction → ${a}`),t.presentationContext===`modal`){let e=n.routeUrl(a);if(e){let t=await n.dispatch(e.intent);i.onModal(t)}return}await l(a,0,++s)}),c.onPopState(async(e,t)=>{if(r.debug(`popstate → ${e}, cached=${!!t}`),i.onNavigate(new URL(e).pathname),t){let{page:e}=t;u(e),a({page:e,isFirstPage:o});return}let c=new URL(e),d=n.routeUrl(c.pathname+c.search);if(!d){r.error(`received popstate without data, but URL was unroutable:`,e),u(null),a({page:Promise.reject(Error(`404`)),isFirstPage:o});return}let f=X({url:c.pathname+c.search,intent:d.intent,container:n.container}),p=await n.runBeforeLoad(f,d.beforeGuards);if(p.kind===`redirect`){r.debug(`popstate beforeLoad → redirect to ${p.url}`);let e=++s;await l(p.url,0,e);return}if(p.kind===`deny`||p.kind===`rewrite`){if(p.kind===`deny`)r.warn(`popstate beforeLoad → denied`);else{let e=++s;await l(p.url,0,e)}return}let m=n.dispatch(d.intent);await Promise.race([m,new Promise(e=>setTimeout(e,500))]).catch(()=>{}),a({page:m.then(async e=>{let t={...f,page:e},a=await n.runAfterLoad(t,d.afterGuards);if(a.kind===`redirect`){r.debug(`popstate afterLoad → redirect to ${a.url}`);let t=++s;return l(a.url,0,t),e}if(a.kind===`rewrite`){r.debug(`popstate afterLoad → rewrite URL to ${a.url}`);let t=window.history.state?.id;return window.history.replaceState({id:t},``,a.url),i.onNavigate(new URL(a.url,window.location.origin).pathname),u(e),e}return a.kind===`deny`?(r.warn(`popstate afterLoad → denied (${a.status})`),e):(u(e),e)}),isFirstPage:o})});function u(e){(async()=>{try{e&&n.didEnterPage(e)}catch(e){r.error(`didEnterPage error:`,e)}})()}}function Re(e){let{framework:t,log:n,callbacks:r,updateApp:i}=e;Le({framework:t,log:n,callbacks:r,updateApp:i,getScrollablePageElement:e.getScrollablePageElement}),Pe({framework:t,log:n})}function ze(){let e=document.getElementById(`serialized-server-data`);if(e?.textContent){e.parentNode?.removeChild(e);try{return JSON.parse(e.textContent)}catch{return}}}function Be(){let e=ze();return!e||!Array.isArray(e)?G.empty():G.fromArray(e)}async function Ve(e){let{bootstrap:t,mountId:n=`app`,mount:r,callbacks:i,onBeforeStart:a,onAfterStart:o,frameworkConfig:s={},loadMessages:c}=e,l=Be(),u=window.location.pathname+window.location.search,d=He(s.locale),f=await v({locale:d,loadMessages:c,context:d?{runtime:`browser`,fetch:Ue(s.fetch),url:u}:void 0}),p=K.create({...s,locale:d,_resolvedMessages:f,prefetchedIntents:l});t(p);let h=p.container.resolve(I.LOGGER_FACTORY).loggerFor(`browser`),g=p.getLocale();g&&(m(g),h.debug(`[startBrowserApp] Applied locale attributes:`,g)),await a?.(p);let _=p.routeUrl(u),y=document.getElementById(n);if(!y)throw Error(`[startBrowserApp] Mount target not found: #${n}. Ensure your HTML has <div id="${n}"></div>.`);let b=r(y,{framework:p});Re({framework:p,log:h,callbacks:i,updateApp:b,getScrollablePageElement:e.getScrollablePageElement}),_?await p.perform(_.action):b({page:Promise.reject(Error(`404`)),isFirstPage:!0}),await o?.(p)}function He(e){return e||document.documentElement.lang.trim()||void 0}function Ue(e){return(e??globalThis.fetch?.bind(globalThis))||(()=>{throw Error(`[startBrowserApp] loadMessages requires a fetch implementation.`)})}export{T as $,me as A,B,Se as C,_e as D,ve as E,q as F,P as G,I as H,K as I,A as J,M as K,G as L,pe as M,de as N,ge as O,ue as P,E as Q,U as R,xe as S,ye as T,L as U,z as V,F as W,ne as X,re as Y,k as Z,X as _,r as _t,Le as a,v as at,we as b,a as bt,Pe as c,d as ct,je as d,h as dt,w as et,Ae as f,m as ft,Oe as g,e as gt,De as h,o as ht,Re as i,S as it,fe as j,he as k,Ne as l,u as lt,Ee as m,s as mt,Be as n,x as nt,Ie as o,y as ot,ke as p,c as pt,N as q,ze as r,b as rt,Fe as s,f as st,Ve as t,C as tt,Me as u,p as ut,Te as v,n as vt,be as w,Ce as x,i as xt,J as y,t as yt,V as z};
2
+ //# sourceMappingURL=start-app-BdXBCcor.mjs.map
@@ -0,0 +1,230 @@
1
+ # 1. Getting started
2
+
3
+ A working `@finesoft/front` app in five minutes. By the end you have a Vue/React/Svelte page rendering on the server, hydrating in the browser, and ready to deploy.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js `>= 22.12.0`
8
+ - A package manager: pnpm (recommended), npm, or yarn
9
+ - A view layer (Vue 3, React 19, or Svelte 5) — these docs use Vue, but every example translates one-to-one
10
+
11
+ ## Scaffold a new app
12
+
13
+ ```bash
14
+ npx @finesoft/create-app my-app
15
+ cd my-app
16
+ pnpm install
17
+ pnpm dev
18
+ ```
19
+
20
+ The scaffolder generates a runnable app with routing, SSR, and proxy already wired. Open <http://localhost:5173>.
21
+
22
+ If you want to understand what was generated, the rest of this page builds the same setup from scratch.
23
+
24
+ ## Add to an existing project
25
+
26
+ ```bash
27
+ pnpm add @finesoft/front
28
+ pnpm add -D vite hono
29
+ ```
30
+
31
+ Peer dependencies: `hono >= 4.0.0`. Optional but recommended: `@hono/node-server` for Node deployment, `vite >= 5.0.0` for the dev server.
32
+
33
+ ## Minimal project layout
34
+
35
+ ```
36
+ my-app/
37
+ ├── src/
38
+ │ ├── bootstrap.ts # routes + controllers (shared SSR + CSR)
39
+ │ ├── main.ts # browser entry
40
+ │ ├── ssr.ts # SSR entry
41
+ │ ├── App.vue # root component
42
+ │ └── lib/
43
+ │ └── controllers/
44
+ │ └── home.ts
45
+ ├── index.html
46
+ ├── vite.config.ts
47
+ ├── package.json
48
+ └── tsconfig.json
49
+ ```
50
+
51
+ ## Vite config
52
+
53
+ ```ts
54
+ // vite.config.ts
55
+ import { finesoftFrontViteConfig } from "@finesoft/front";
56
+ import vue from "@vitejs/plugin-vue";
57
+ import { defineConfig } from "vite";
58
+
59
+ export default defineConfig({
60
+ plugins: [
61
+ vue(),
62
+ finesoftFrontViteConfig({
63
+ ssr: { entry: "src/ssr.ts" },
64
+ i18n: { messagesDir: "src/locales" },
65
+ adapter: "auto",
66
+ }),
67
+ ],
68
+ });
69
+ ```
70
+
71
+ What `finesoftFrontViteConfig` adds:
72
+
73
+ - A Hono-based dev server that runs your SSR entry on every request
74
+ - Locale JSON auto-loading from `messagesDir`
75
+ - A build pipeline that emits both the client bundle and a server entry
76
+ - Platform adapter wiring for `adapter: "auto" | "node" | "vercel" | "cloudflare" | "netlify" | "static"`
77
+
78
+ ## Bootstrap (shared by SSR and CSR)
79
+
80
+ ```ts
81
+ // src/bootstrap.ts
82
+ import { type Framework, defineRoutes } from "@finesoft/front";
83
+ import { HomeController } from "./lib/controllers/home";
84
+
85
+ export function bootstrap(framework: Framework): void {
86
+ defineRoutes(framework, [{ path: "/", intentId: "home", controller: new HomeController() }]);
87
+ }
88
+ ```
89
+
90
+ The same `bootstrap()` runs on both sides. This is what guarantees the server and browser resolve URLs identically.
91
+
92
+ ## Controller
93
+
94
+ ```ts
95
+ // src/lib/controllers/home.ts
96
+ import { BaseController, type Container } from "@finesoft/front";
97
+
98
+ interface HomePage {
99
+ kind: "home";
100
+ title: string;
101
+ items: string[];
102
+ }
103
+
104
+ export class HomeController extends BaseController<Record<string, string>, HomePage> {
105
+ readonly intentId = "home";
106
+
107
+ async execute(_params: Record<string, string>, _container: Container): Promise<HomePage> {
108
+ return {
109
+ kind: "home",
110
+ title: "Welcome",
111
+ items: ["one", "two", "three"],
112
+ };
113
+ }
114
+
115
+ fallback(_params: Record<string, string>, error: unknown): HomePage {
116
+ return { kind: "home", title: "Failed", items: [] };
117
+ }
118
+ }
119
+ ```
120
+
121
+ `BaseController` wraps `execute()` in `try/catch` and calls `fallback()` on error. Always provide a `fallback` — see [error handling](./08-observability.md#error-handling-via-fallback) for why.
122
+
123
+ ## SSR entry
124
+
125
+ ```ts
126
+ // src/ssr.ts
127
+ import { createSSRRender, serializeServerData } from "@finesoft/front";
128
+ import { createSSRApp } from "vue";
129
+ import { renderToString } from "vue/server-renderer";
130
+ import App from "./App.vue";
131
+ import { bootstrap } from "./bootstrap";
132
+
133
+ export const render = createSSRRender({
134
+ bootstrap,
135
+ getErrorPage: () => ({ kind: "error", title: "Error" }),
136
+ async renderApp(page) {
137
+ const html = await renderToString(createSSRApp(App, { page }));
138
+ return { html, head: `<title>${(page as { title: string }).title}</title>`, css: "" };
139
+ },
140
+ });
141
+
142
+ export { serializeServerData };
143
+ ```
144
+
145
+ `createSSRRender` returns a function that takes a URL and returns rendered HTML + serialized prefetched intent data. The Vite plugin and adapters call it for you — you do not invoke it directly.
146
+
147
+ ## Browser entry
148
+
149
+ ```ts
150
+ // src/main.ts
151
+ import { startBrowserApp } from "@finesoft/front/browser";
152
+ import { createSSRApp } from "vue";
153
+ import App from "./App.vue";
154
+ import { bootstrap } from "./bootstrap";
155
+
156
+ startBrowserApp({
157
+ bootstrap,
158
+ mount(target, { framework }) {
159
+ const app = createSSRApp(App, { framework });
160
+ app.mount(target);
161
+ },
162
+ });
163
+ ```
164
+
165
+ `startBrowserApp` reads the SSR-injected `PrefetchedIntents` from the DOM, creates the `Framework`, runs the same `bootstrap()` as the server, and triggers the first page. By the time `mount()` runs the framework already has the initial `Page` ready.
166
+
167
+ Import from `@finesoft/front/browser` (not `@finesoft/front`) on the client to avoid pulling server-only modules into your client bundle.
168
+
169
+ ## Root component (Vue example)
170
+
171
+ ```vue
172
+ <!-- src/App.vue -->
173
+ <script setup lang="ts">
174
+ import { computed } from "vue";
175
+ import type { Framework } from "@finesoft/front";
176
+
177
+ const props = defineProps<{ framework?: Framework; page?: { title: string; items: string[] } }>();
178
+
179
+ // SSR receives `page` directly; CSR pulls the current page from the framework.
180
+ const page = computed(() => props.page ?? props.framework?.getCurrentPage());
181
+ </script>
182
+
183
+ <template>
184
+ <main>
185
+ <h1>{{ page?.title }}</h1>
186
+ <ul>
187
+ <li v-for="item in page?.items" :key="item">{{ item }}</li>
188
+ </ul>
189
+ </main>
190
+ </template>
191
+ ```
192
+
193
+ ## index.html
194
+
195
+ ```html
196
+ <!doctype html>
197
+ <html lang="en">
198
+ <head>
199
+ <meta charset="UTF-8" />
200
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
201
+ <!--head-->
202
+ </head>
203
+ <body>
204
+ <div id="app"><!--ssr--></div>
205
+ <script type="module" src="/src/main.ts"></script>
206
+ </body>
207
+ </html>
208
+ ```
209
+
210
+ The `<!--head-->` and `<!--ssr-->` placeholders are where the framework injects the SSR head fragment and rendered HTML. The placeholder names are exported as `SSR_PLACEHOLDERS` if you need them.
211
+
212
+ ## Run it
213
+
214
+ ```bash
215
+ pnpm dev # development with HMR
216
+ pnpm build # production build
217
+ pnpm preview # serve the production build locally
218
+ ```
219
+
220
+ Done. You have an app that:
221
+
222
+ - Renders on the server with prefetched data
223
+ - Hydrates without an extra fetch (SSR data is reused via `PrefetchedIntents`)
224
+ - Routes client-side without page reloads
225
+ - Is ready to deploy to any of the supported adapters
226
+
227
+ ## Next
228
+
229
+ - [Routing & controllers](./02-routing-and-controllers.md) — define more routes and control how they render
230
+ - [Project structure](./engineering/project-structure.md) — recommended layout once the app grows past a handful of pages
@@ -0,0 +1,197 @@
1
+ # 2. Routing & controllers
2
+
3
+ The framework's routing layer maps URLs to **intents**, intents to **controllers**, and controllers produce **pages**. This chapter covers all three.
4
+
5
+ ## The mental model
6
+
7
+ ```
8
+ URL ──Router.resolve()──▶ RouteMatch { intent, renderMode, guards }
9
+
10
+
11
+ IntentDispatcher.dispatch(intent)
12
+
13
+
14
+ Controller.execute() → Page
15
+ ```
16
+
17
+ A route definition combines:
18
+
19
+ - A **path pattern** (`/products/:id`)
20
+ - An **intent id** (logical name for the operation; one intent can have multiple routes)
21
+ - A **controller instance** (where the page data is produced)
22
+ - An optional **render mode** (`ssr` / `csr` / `prerender`)
23
+ - Optional **guards** (`beforeLoad` / `afterLoad`)
24
+
25
+ ## Defining routes
26
+
27
+ ```ts
28
+ // src/bootstrap.ts
29
+ import { type Framework, defineRoutes } from "@finesoft/front";
30
+ import { HomeController } from "./lib/controllers/home";
31
+ import { ProductController } from "./lib/controllers/product";
32
+ import { authGuard } from "./lib/guards/auth";
33
+
34
+ export function bootstrap(framework: Framework): void {
35
+ defineRoutes(framework, [
36
+ // Plain SSR route
37
+ { path: "/", intentId: "home", controller: new HomeController() },
38
+
39
+ // Dynamic segment
40
+ { path: "/products/:id", intentId: "product", controller: new ProductController() },
41
+
42
+ // CSR-only (server returns an empty shell)
43
+ {
44
+ path: "/dashboard",
45
+ intentId: "dashboard",
46
+ controller: new DashboardController(),
47
+ renderMode: "csr",
48
+ },
49
+
50
+ // Statically prerendered at build time
51
+ {
52
+ path: "/about",
53
+ intentId: "about",
54
+ controller: new AboutController(),
55
+ renderMode: "prerender",
56
+ },
57
+
58
+ // Protected route — reuses the home intent but gates with a guard
59
+ {
60
+ path: "/admin",
61
+ intentId: "home",
62
+ controller: new HomeController(),
63
+ beforeLoad: [authGuard],
64
+ },
65
+ ]);
66
+ }
67
+ ```
68
+
69
+ ### Route options
70
+
71
+ | Field | Type | Notes |
72
+ | ------------ | -------------------------------- | -------------------------------------------------------------------- |
73
+ | `path` | `string` | Path pattern with `:param` placeholders. Trailing `/` is normalized. |
74
+ | `intentId` | `string` | Logical operation name. Used to register the controller. |
75
+ | `controller` | `BaseController<TParams, TPage>` | Optional if the intent is already registered. |
76
+ | `renderMode` | `"ssr" \| "csr" \| "prerender"` | Default `"ssr"`. See [chapter 4](./04-rendering-and-hydration.md). |
77
+ | `beforeLoad` | `BeforeLoadGuard[]` | Run before the controller. See [chapter 3](./03-middleware.md). |
78
+ | `afterLoad` | `AfterLoadGuard[]` | Run after the page is produced. |
79
+
80
+ ### Path patterns
81
+
82
+ - Static: `/about`
83
+ - Parameterized: `/products/:id`, `/users/:userId/posts/:postId`
84
+ - Trailing wildcard: `/files/*`
85
+ - Optional segment is **not** supported — write two routes instead.
86
+
87
+ Parameters are passed to `controller.execute(params, container)` as a string-keyed object.
88
+
89
+ ## Writing a controller
90
+
91
+ ```ts
92
+ // src/lib/controllers/product.ts
93
+ import { BaseController, type Container, type HttpClient } from "@finesoft/front";
94
+
95
+ interface ProductPage {
96
+ kind: "product";
97
+ id: string;
98
+ name: string;
99
+ price: number;
100
+ }
101
+
102
+ export class ProductController extends BaseController<{ id: string }, ProductPage> {
103
+ readonly intentId = "product";
104
+
105
+ async execute(params: { id: string }, container: Container): Promise<ProductPage> {
106
+ const http = container.resolve<HttpClient>("http");
107
+ const product = await http.get<{ name: string; price: number }>(
108
+ `/api/products/${params.id}`,
109
+ );
110
+ return {
111
+ kind: "product",
112
+ id: params.id,
113
+ name: product.name,
114
+ price: product.price,
115
+ };
116
+ }
117
+
118
+ fallback(params: { id: string }, _error: unknown): ProductPage {
119
+ return { kind: "product", id: params.id, name: "Not available", price: 0 };
120
+ }
121
+ }
122
+ ```
123
+
124
+ ### Controller contract
125
+
126
+ | Member | Required | Purpose |
127
+ | ---------- | -------- | ---------------------------------------------------------------------------------- |
128
+ | `intentId` | yes | Must match the route's `intentId` (or the `IntentDispatcher.register` call). |
129
+ | `execute` | yes | Produce the page. Receives parsed path params and the request-scoped DI container. |
130
+ | `fallback` | yes | Return a degraded page when `execute()` throws. Must be synchronous and total. |
131
+
132
+ `BaseController` wraps `execute()` in `try/catch` and routes any error through `fallback()`. The framework never throws out of `dispatch()` — your `fallback()` is the last line of defense.
133
+
134
+ ### Why `fallback` is mandatory
135
+
136
+ A thrown error in `execute()` during SSR would otherwise crash the request and either 500 or render a blank document. `fallback()` lets you return a structured "error" `Page` that your view layer renders as a graceful failure (banner, retry button, etc.). See the [error handling section in observability](./08-observability.md#error-handling-via-fallback) for patterns.
137
+
138
+ ## Render modes
139
+
140
+ | Mode | Server returns | When to use |
141
+ | ------------- | -------------------------------------------- | -------------------------------------------------------- |
142
+ | `"ssr"` | Fully rendered HTML + serialized data | Default. Best for SEO and TTFB-sensitive pages. |
143
+ | `"csr"` | Empty shell HTML; controller runs in browser | Authenticated dashboards, heavy-personalization pages. |
144
+ | `"prerender"` | Static HTML built at deploy time | Marketing, docs, blog. Combine with ISR (see chapter 4). |
145
+
146
+ The mode is **per-route**, so you can mix freely. The framework rebuilds prerendered routes at build time; SSR routes execute on every request.
147
+
148
+ ## Registering controllers without routes
149
+
150
+ You can register a controller for an intent without exposing it as a route. This is useful for intents triggered only by `dispatchAction`:
151
+
152
+ ```ts
153
+ framework.intentDispatcher.register("checkout", new CheckoutController());
154
+
155
+ // Elsewhere:
156
+ const page = await framework.intentDispatcher.dispatch({
157
+ intentId: "checkout",
158
+ params: { cartId },
159
+ });
160
+ ```
161
+
162
+ Routes are simply intent dispatches keyed by URL.
163
+
164
+ ## One intent, many routes
165
+
166
+ Same intent can serve different URLs:
167
+
168
+ ```ts
169
+ defineRoutes(framework, [
170
+ { path: "/", intentId: "home", controller: new HomeController() },
171
+ { path: "/welcome", intentId: "home" }, // reuses the registered HomeController
172
+ { path: "/landing/:slug", intentId: "home" }, // same intent, params differ
173
+ ]);
174
+ ```
175
+
176
+ This avoids duplicating controller instances when only the URL surface differs. Authenticated `/admin` reusing the `home` intent in the earlier example is the same pattern.
177
+
178
+ ## Inspecting the resolved match
179
+
180
+ For diagnostics or custom routing, call `Router.resolve()` directly:
181
+
182
+ ```ts
183
+ const match = framework.router.resolve("/products/42");
184
+ // {
185
+ // intent: { intentId: "product", params: { id: "42" } },
186
+ // action: { kind: "flow", url: "/products/42" },
187
+ // renderMode: "ssr",
188
+ // guards: { before: [...], after: [...] },
189
+ // }
190
+ ```
191
+
192
+ `router.resolve()` returns `null` for unmatched URLs — handle this in your server-side 404 logic.
193
+
194
+ ## Next
195
+
196
+ - [Middleware](./03-middleware.md) — gating navigation, redirects, denies
197
+ - [Rendering & hydration](./04-rendering-and-hydration.md) — what happens after the controller produces a page