vue-ssr-lite 0.0.3

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.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # vue-ssr-lite
2
+
3
+ Low-configuration server-side rendering for Vue 3 + Vite applications: a
4
+ separable SSR engine, an optional standalone Node server, a shared
5
+ server/browser runtime, a first-class Apollo SSR adapter, and a Vite
6
+ integration with generated (virtual) entries.
7
+
8
+ Substantially lighter than Nuxt, focused on one job: give a normal Vue + Vite
9
+ app production-grade SSR — hydration, routing integration, Apollo state
10
+ transfer, SEO head rendering, correct HTTP statuses, secure serialization,
11
+ static assets, health endpoints, and production serving — without hand-writing
12
+ a Node server, SSR entries, hydration bootstraps, or build orchestration.
13
+
14
+ ## Default adoption (the whole thing)
15
+
16
+ 1. Install:
17
+
18
+ ```
19
+ yarn add vue-ssr-lite
20
+ ```
21
+
22
+ 2. Add the Vite integration to `vite.config`:
23
+
24
+ ```
25
+ import { vueSsrLite } from 'vue-ssr-lite/vite'
26
+
27
+ plugins: [vue(), vueSsrLite({ app: './src/SsrApp.ts' })]
28
+ ```
29
+
30
+ 3. Provide the one application definition (`src/SsrApp.ts`), or scaffold it
31
+ with `vue-ssr-lite init`:
32
+
33
+ ```
34
+ import { defineSsrApp } from 'vue-ssr-lite'
35
+ import App from './App.vue'
36
+
37
+ export default defineSsrApp({ root: App })
38
+ ```
39
+
40
+ 4. Use the package commands:
41
+
42
+ ```
43
+ vue-ssr-lite dev # development server (Vite middleware mode, HMR)
44
+ vue-ssr-lite build # client build + SSR build
45
+ vue-ssr-lite start # production server (no Vite required in the image)
46
+ ```
47
+
48
+ That's it. There is no server file, no client entry, no server entry, no HTML
49
+ marker knowledge, no state-global naming, no Apollo extraction wiring. The
50
+ integration injects markers and the generated client entry into your existing
51
+ HTML template, builds a virtual server entry, and emits a runtime manifest so
52
+ `start` needs no build tooling.
53
+
54
+ ## The application definition
55
+
56
+ Everything the runtime needs lives in one `defineSsrApp` call:
57
+
58
+ - `root` — the root component (required; the only required field).
59
+ - `routes` / `scrollBehavior` — optional; the package creates the router with
60
+ memory history on the server and web history in the browser. Router-less
61
+ apps are fully supported. `router` accepts an async factory for dynamic
62
+ router construction.
63
+ - `apollo` — optional: `endpoint` (or `endpoints`; values may be functions of
64
+ the public config), `timeoutMs`, `headers`, `links` (your auth/refresh
65
+ links; may be a function of `{ server }`), `cacheConfig` (type policies),
66
+ `ssrForceFetchDelay` (hydration deduplication), and `install(app, clients,
67
+ context)` for library-specific provides. The adapter owns per-request
68
+ clients and caches, server extraction, browser restoration, timeout and
69
+ cancellation. Requires `@apollo/client` to be installed.
70
+ - `config` — optional serializable public configuration (object or factory).
71
+ Only serialized when non-empty. Never put secrets here.
72
+ - `head` — optional default head payload (title, description, Open Graph,
73
+ Twitter, canonical, favicon, JSON-LD). Pages set `useSsrContext().head`.
74
+ - `setup({ app, router, context, apollo, server })` — install plugins,
75
+ provide services.
76
+
77
+ Inside components/composables, `useSsrContext()` exposes the request URL,
78
+ host, public config, hydration state, the head ref, and mutable
79
+ `response.statusCode` / `response.redirect` — statuses are honest: a 404 page
80
+ returns 404.
81
+
82
+ ## Multi-app hosting (builder/admin SPA + public SSR)
83
+
84
+ ```
85
+ vueSsrLite({
86
+ app: './src/site/SiteSsrApp.ts',
87
+ template: 'site.html',
88
+ spa: [{ name: 'builder', template: 'index.html', hosts: ['app.example.com'], localhost: true }],
89
+ })
90
+ ```
91
+
92
+ Host classification is declarative (exact hosts and `*.wildcards`); anything
93
+ unmatched is served by SSR — the custom-domain model. The dev server honors
94
+ the same classification, so subdomain testing works with `*.localhost`.
95
+
96
+ ## Advanced tier (ERP-class applications)
97
+
98
+ `createSsrServer` from `vue-ssr-lite/server` accepts programmatic options for
99
+ request classification hooks, custom server endpoints (e.g. robots/sitemaps),
100
+ cookie allow/deny lists, readiness probes, role-based serving for split
101
+ deployments, error-page overrides, serialized-global and head-attribute
102
+ overrides (migration compatibility), and a per-request CSP `nonce` provider.
103
+ `ssrApolloNoExternal` from `vue-ssr-lite/vite` is the SSR dependency-inlining
104
+ preset for the Apollo cluster.
105
+
106
+ ## Embedding the engine (no standalone server)
107
+
108
+ `createSsrRequestHandler` from `vue-ssr-lite/engine` is the framework-agnostic
109
+ core: give it `loadTemplate` and `loadRenderModule`, hand it a request
110
+ description, and it returns `{ statusCode, headers, html }`. Mount it in an
111
+ existing Node/Express server, tests, or serverless functions. The standalone
112
+ server is one host of this engine, not the engine itself.
113
+
114
+ ## Security invariants (package-owned, not configurable)
115
+
116
+ XSS-safe state serialization; escaped head rendering including JSON-LD;
117
+ cookie allowlisting (nothing is forwarded by default); forwarded headers
118
+ trusted only with `SSR_TRUST_PROXY`/`trustProxy`; host validation; static
119
+ traversal guard; security headers on HTML; production error pages without
120
+ stack traces; module scripts stripped from error shells.
121
+
122
+ ## SSR data rules
123
+
124
+ Never share Apollo clients, caches, routers, request contexts, or refresh
125
+ promises across requests. The runtime and adapter enforce per-request
126
+ creation; if you build custom data layers, follow the same rule. Global
127
+ client libraries (e.g. vue-apollo-client's `createApollo`) are for SPA
128
+ entries only — never call them in code that runs during SSR.
129
+
130
+ ## Environment
131
+
132
+ `NODE_ENV`, `PORT`, `HOST`, `SSR_TRUST_PROXY`. Product-specific variables
133
+ belong to your app: map them into `config`/server options in your definition
134
+ or server file.
135
+
136
+ ## Tests
137
+
138
+ `yarn test` runs the package contract tests (Node's built-in test runner; no
139
+ build step — the package ships plain ESM with hand-maintained type
140
+ declarations).
141
+
142
+ ## Publishing
143
+
144
+ Choose the release type and run the corresponding command:
145
+
146
+ ```
147
+ npm run release:patch
148
+ npm run release:minor
149
+ npm run release:major
150
+ ```
151
+
152
+ Each command first runs the contract tests, then updates the package version,
153
+ creates the npm version commit and tag, and publishes the package. The tests
154
+ also run automatically before publishing directly.
@@ -0,0 +1,184 @@
1
+ // SsrApolloAdapter — the single generic Apollo SSR integration.
2
+ //
3
+ // Non-negotiable contract (audited from the proven ERP runtime): one Apollo
4
+ // client and one cache per request; ssrMode on the server; credentials
5
+ // omitted with only the allowlist-filtered cookie header forwarded; a
6
+ // timeout/abort fetch; cache extraction into hydration state on the server
7
+ // and cache restoration before mount in the browser; no module-scope clients,
8
+ // caches, or refresh coordination anywhere. Consumers contribute only
9
+ // product-specific links (auth, refresh), cache policies, endpoints, and
10
+ // operations.
11
+
12
+ import {
13
+ ApolloClient,
14
+ ApolloLink,
15
+ HttpLink,
16
+ InMemoryCache,
17
+ } from '@apollo/client/core'
18
+
19
+ const DEFAULT_TIMEOUT_MS = 8000
20
+ const DEFAULT_SSR_FORCE_FETCH_DELAY_MS = 150
21
+
22
+ /** Fetch wrapper enforcing a per-request timeout and upstream abort chaining. */
23
+ export const createSsrTimedFetch = (timeoutMs = DEFAULT_TIMEOUT_MS) => {
24
+ return async (input, init = {}) => {
25
+ const controller = new AbortController()
26
+ const upstreamSignal = init.signal
27
+ const abortFromUpstream = () => controller.abort()
28
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
29
+
30
+ if (upstreamSignal?.aborted) controller.abort()
31
+ else
32
+ upstreamSignal?.addEventListener('abort', abortFromUpstream, {
33
+ once: true,
34
+ })
35
+
36
+ try {
37
+ return await fetch(input, {
38
+ ...init,
39
+ credentials: 'omit',
40
+ signal: controller.signal,
41
+ })
42
+ } finally {
43
+ clearTimeout(timeout)
44
+ upstreamSignal?.removeEventListener('abort', abortFromUpstream)
45
+ }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Create one request-scoped Apollo client.
51
+ *
52
+ * options:
53
+ * - endpoint (required): GraphQL endpoint URL.
54
+ * - server: true during SSR.
55
+ * - cookie: allowlist-filtered cookie header (server only).
56
+ * - timeoutMs: per-operation budget (default 8000).
57
+ * - headers: static or ({ server }) => object extra HTTP headers.
58
+ * - links: array or ({ server, context }) => array of consumer links placed
59
+ * before the terminating HTTP link (auth, refresh, error mapping).
60
+ * - cacheConfig: InMemoryCache configuration (type policies etc.).
61
+ * - restoreState: extracted cache to restore (browser hydration).
62
+ * - ssrForceFetchDelay: hydration deduplication window (browser; default 150).
63
+ * - defaultOptions: Apollo default options override.
64
+ */
65
+ export const createSsrApolloClient = (options) => {
66
+ const {
67
+ endpoint,
68
+ server = false,
69
+ cookie,
70
+ timeoutMs = DEFAULT_TIMEOUT_MS,
71
+ headers,
72
+ links,
73
+ cacheConfig,
74
+ restoreState,
75
+ ssrForceFetchDelay = DEFAULT_SSR_FORCE_FETCH_DELAY_MS,
76
+ defaultOptions,
77
+ context,
78
+ } = options
79
+ if (!endpoint) {
80
+ throw new Error('vue-ssr-lite/apollo: an endpoint is required.')
81
+ }
82
+
83
+ const resolvedHeaders = {
84
+ ...(typeof headers === 'function' ? headers({ server }) : headers),
85
+ ...(server && cookie ? { cookie } : {}),
86
+ }
87
+
88
+ const httpLink = new HttpLink({
89
+ uri: endpoint,
90
+ fetch: createSsrTimedFetch(timeoutMs),
91
+ headers: Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined,
92
+ })
93
+
94
+ const consumerLinks =
95
+ typeof links === 'function' ? links({ server, context }) ?? [] : links ?? []
96
+
97
+ const cache = new InMemoryCache(cacheConfig)
98
+ if (!server && restoreState) cache.restore(restoreState)
99
+
100
+ return new ApolloClient({
101
+ ssrMode: server,
102
+ ...(server ? {} : { ssrForceFetchDelay }),
103
+ link:
104
+ consumerLinks.length > 0
105
+ ? ApolloLink.from([...consumerLinks, httpLink])
106
+ : httpLink,
107
+ cache,
108
+ defaultOptions: defaultOptions ?? {
109
+ query: { errorPolicy: 'all' },
110
+ watchQuery: { errorPolicy: 'all', fetchPolicy: 'cache-first' },
111
+ mutate: { errorPolicy: 'none' },
112
+ },
113
+ })
114
+ }
115
+
116
+ const resolveEndpoints = (apolloDefinition, config) => {
117
+ if (apolloDefinition.endpoints) {
118
+ const resolved = {}
119
+ for (const [name, value] of Object.entries(apolloDefinition.endpoints)) {
120
+ resolved[name] = typeof value === 'function' ? value(config) : value
121
+ }
122
+ return resolved
123
+ }
124
+ const endpoint =
125
+ typeof apolloDefinition.endpoint === 'function'
126
+ ? apolloDefinition.endpoint(config)
127
+ : apolloDefinition.endpoint
128
+ return { default: endpoint }
129
+ }
130
+
131
+ /**
132
+ * Create the request-scoped client set declared by an application definition,
133
+ * with managed hydration-state extraction and restoration.
134
+ *
135
+ * State layout: a single "default" client extracts to state.apollo directly
136
+ * (matching the audited ERP layout); multiple clients extract to a name-keyed
137
+ * map under state.apollo.
138
+ */
139
+ export const createSsrApolloClients = (apolloDefinition, environment) => {
140
+ const { server, cookie, config, state, context } = environment
141
+ const endpoints = resolveEndpoints(apolloDefinition, config)
142
+ const names = Object.keys(endpoints)
143
+ const single = names.length === 1 && names[0] === 'default'
144
+
145
+ const timeoutMs =
146
+ typeof apolloDefinition.timeoutMs === 'function'
147
+ ? apolloDefinition.timeoutMs(config)
148
+ : apolloDefinition.timeoutMs
149
+
150
+ const clients = {}
151
+ for (const name of names) {
152
+ clients[name] = createSsrApolloClient({
153
+ endpoint: endpoints[name],
154
+ server,
155
+ cookie,
156
+ context,
157
+ timeoutMs,
158
+ headers: apolloDefinition.headers,
159
+ links: apolloDefinition.links,
160
+ cacheConfig: apolloDefinition.cacheConfig,
161
+ ssrForceFetchDelay: apolloDefinition.ssrForceFetchDelay,
162
+ defaultOptions: apolloDefinition.defaultOptions,
163
+ restoreState: server
164
+ ? undefined
165
+ : single
166
+ ? state?.apollo
167
+ : state?.apollo?.[name],
168
+ })
169
+ }
170
+
171
+ return {
172
+ clients,
173
+ defaultClient: clients.default ?? clients[names[0]],
174
+ extractInto: (targetState) => {
175
+ if (!server) return targetState
176
+ targetState.apollo = single
177
+ ? clients.default.extract()
178
+ : Object.fromEntries(
179
+ names.map((name) => [name, clients[name].extract()]),
180
+ )
181
+ return targetState
182
+ },
183
+ }
184
+ }
@@ -0,0 +1,50 @@
1
+ import type {
2
+ ApolloClient,
3
+ ApolloLink,
4
+ DefaultOptions,
5
+ InMemoryCacheConfig,
6
+ NormalizedCacheObject,
7
+ } from '@apollo/client/core'
8
+ import type { SsrApolloDefinition, SsrRequestContext } from '../index'
9
+
10
+ export interface SsrApolloClientOptions {
11
+ endpoint: string
12
+ server?: boolean
13
+ cookie?: string
14
+ timeoutMs?: number
15
+ headers?:
16
+ | Record<string, string>
17
+ | ((environment: { server: boolean }) => Record<string, string>)
18
+ links?:
19
+ | ApolloLink[]
20
+ | ((environment: {
21
+ server: boolean
22
+ context?: SsrRequestContext
23
+ }) => ApolloLink[])
24
+ cacheConfig?: InMemoryCacheConfig
25
+ restoreState?: NormalizedCacheObject
26
+ ssrForceFetchDelay?: number
27
+ defaultOptions?: DefaultOptions
28
+ context?: SsrRequestContext
29
+ }
30
+
31
+ export declare const createSsrTimedFetch: (timeoutMs?: number) => typeof fetch
32
+
33
+ export declare const createSsrApolloClient: (
34
+ options: SsrApolloClientOptions,
35
+ ) => ApolloClient<NormalizedCacheObject>
36
+
37
+ export declare const createSsrApolloClients: (
38
+ apolloDefinition: SsrApolloDefinition,
39
+ environment: {
40
+ server: boolean
41
+ cookie?: string
42
+ config?: Record<string, unknown>
43
+ state?: Record<string, unknown>
44
+ context?: SsrRequestContext
45
+ },
46
+ ) => {
47
+ clients: Record<string, ApolloClient<NormalizedCacheObject>>
48
+ defaultClient: ApolloClient<NormalizedCacheObject>
49
+ extractInto: (state: Record<string, unknown>) => Record<string, unknown>
50
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // vue-ssr-lite CLI — package-managed development, build, and production
3
+ // commands so consumers never write server or build orchestration files.
4
+ //
5
+ // vue-ssr-lite dev start the development server (Vite middleware mode)
6
+ // vue-ssr-lite build build the client bundle, then the SSR bundle
7
+ // vue-ssr-lite start run the production server from built output
8
+ // vue-ssr-lite init scaffold a minimal application definition (optional)
9
+
10
+ import { writeFile, mkdir, access } from 'node:fs/promises'
11
+ import { resolve, dirname } from 'node:path'
12
+
13
+ const [, , command = 'dev', ...rest] = process.argv
14
+ const root = process.cwd()
15
+
16
+ const readFlag = (name) => {
17
+ const index = rest.indexOf(`--${name}`)
18
+ return index >= 0 ? rest[index + 1] : undefined
19
+ }
20
+
21
+ const dev = async () => {
22
+ process.env.NODE_ENV ||= 'development'
23
+ const { createSsrServer } = await import('../server/index.js')
24
+ const runtime = await createSsrServer({ root, mode: 'development' })
25
+ await runtime.listen()
26
+ }
27
+
28
+ const start = async () => {
29
+ process.env.NODE_ENV ||= 'production'
30
+ const { createSsrServer } = await import('../server/index.js')
31
+ const runtime = await createSsrServer({ root, mode: 'production' })
32
+ await runtime.listen()
33
+ }
34
+
35
+ const build = async () => {
36
+ const { build: viteBuild } = await import('vite')
37
+ // Client build (the plugin supplies entries, markers, and the manifest).
38
+ await viteBuild({ root })
39
+ // Server build (the plugin recognizes isSsrBuild and swaps the target).
40
+ await viteBuild({ root, build: { ssr: true } })
41
+ console.log('[vue-ssr-lite] client and server builds complete.')
42
+ }
43
+
44
+ const init = async () => {
45
+ const appPath = readFlag('app') || 'src/SsrApp.ts'
46
+ const absolute = resolve(root, appPath)
47
+ try {
48
+ await access(absolute)
49
+ console.log(`[vue-ssr-lite] ${appPath} already exists; nothing to do.`)
50
+ return
51
+ } catch {
52
+ // continue
53
+ }
54
+ await mkdir(dirname(absolute), { recursive: true })
55
+ await writeFile(
56
+ absolute,
57
+ [
58
+ "import { defineSsrApp } from 'vue-ssr-lite'",
59
+ "import App from './App.vue'",
60
+ '',
61
+ 'export default defineSsrApp({',
62
+ '\troot: App,',
63
+ '})',
64
+ '',
65
+ ].join('\n'),
66
+ )
67
+ console.log(
68
+ `[vue-ssr-lite] created ${appPath}. Add vueSsrLite({ app: './${appPath}' }) to vite.config and use the dev/build/start commands.`,
69
+ )
70
+ }
71
+
72
+ const commands = { dev, build, start, init }
73
+ const run = commands[command]
74
+ if (!run) {
75
+ console.error(
76
+ `[vue-ssr-lite] unknown command "${command}". Available: dev, build, start, init.`,
77
+ )
78
+ process.exit(1)
79
+ }
80
+ run().catch((error) => {
81
+ console.error(error)
82
+ process.exit(1)
83
+ })
@@ -0,0 +1,166 @@
1
+ // SsrEngineHtml — package-owned HTML document primitives.
2
+ //
3
+ // These are invariants, not configuration: escaping rules, XSS-safe state
4
+ // serialization, marker names, and head-tag rendering order are identical in
5
+ // development and production and identical across consumers. The head payload
6
+ // contract and tag order are byte-compatible with the audited ERP renderer so
7
+ // the ERP migration cannot change its SEO output.
8
+
9
+ export const SSR_MARKERS = Object.freeze({
10
+ head: '<!--app-head-->',
11
+ html: '<!--app-html-->',
12
+ state: '<!--app-state-->',
13
+ teleports: '<!--app-teleports-->',
14
+ })
15
+
16
+ export const escapeHtml = (value) =>
17
+ String(value ?? '')
18
+ .replaceAll('&', '&amp;')
19
+ .replaceAll('<', '&lt;')
20
+ .replaceAll('>', '&gt;')
21
+ .replaceAll('"', '&quot;')
22
+ .replaceAll("'", '&#39;')
23
+
24
+ export const escapeXml = (value) =>
25
+ String(value ?? '')
26
+ .replaceAll('&', '&amp;')
27
+ .replaceAll('<', '&lt;')
28
+ .replaceAll('>', '&gt;')
29
+ .replaceAll('"', '&quot;')
30
+ .replaceAll("'", '&apos;')
31
+
32
+ /** XSS-safe JSON serialization for inline <script> payloads. */
33
+ export const serializeState = (value) =>
34
+ JSON.stringify(value)
35
+ .replaceAll('<', '\\u003c')
36
+ .replaceAll('
', '\\u2028')
37
+ .replaceAll('
', '\\u2029')
38
+
39
+ /**
40
+ * Render a head payload into escaped tags.
41
+ *
42
+ * Payload contract (all fields optional): title, description, keywords,
43
+ * robots, ogTitle, ogDescription, ogImage, ogImageAlt, ogUrl, ogType,
44
+ * ogSiteName, ogLocale, twitterCard, twitterTitle, twitterDescription,
45
+ * twitterImage, twitterImageAlt, twitterSite, twitterCreator, canonicalUrl,
46
+ * favicon, jsonLd (array of objects).
47
+ *
48
+ * Every rendered tag carries `attribute` so the browser runtime can remove
49
+ * server-rendered tags before the client SEO layer takes over.
50
+ */
51
+ export const renderHeadTags = (payload, attribute = 'data-ssr-head') => {
52
+ if (!payload) return ''
53
+ const tags = []
54
+ if (payload.title) {
55
+ tags.push(`<title ${attribute}>${escapeHtml(payload.title)}</title>`)
56
+ }
57
+ const meta = [
58
+ ['name', 'description', payload.description],
59
+ ['name', 'keywords', payload.keywords],
60
+ ['name', 'robots', payload.robots],
61
+ ['property', 'og:title', payload.ogTitle],
62
+ ['property', 'og:description', payload.ogDescription],
63
+ ['property', 'og:image', payload.ogImage],
64
+ ['property', 'og:image:alt', payload.ogImageAlt],
65
+ ['property', 'og:url', payload.ogUrl],
66
+ ['property', 'og:type', payload.ogType],
67
+ ['property', 'og:site_name', payload.ogSiteName],
68
+ ['property', 'og:locale', payload.ogLocale],
69
+ ['name', 'twitter:card', payload.twitterCard],
70
+ ['name', 'twitter:title', payload.twitterTitle],
71
+ ['name', 'twitter:description', payload.twitterDescription],
72
+ ['name', 'twitter:image', payload.twitterImage],
73
+ ['name', 'twitter:image:alt', payload.twitterImageAlt],
74
+ ['name', 'twitter:site', payload.twitterSite],
75
+ ['name', 'twitter:creator', payload.twitterCreator],
76
+ ]
77
+ for (const [attributeName, name, content] of meta) {
78
+ if (content != null && content !== '') {
79
+ tags.push(
80
+ `<meta ${attribute} ${attributeName}="${escapeHtml(name)}" content="${escapeHtml(content)}">`,
81
+ )
82
+ }
83
+ }
84
+ if (payload.canonicalUrl) {
85
+ tags.push(
86
+ `<link ${attribute} rel="canonical" href="${escapeHtml(payload.canonicalUrl)}">`,
87
+ )
88
+ }
89
+ if (payload.favicon) {
90
+ tags.push(`<link ${attribute} rel="icon" href="${escapeHtml(payload.favicon)}">`)
91
+ }
92
+ for (const block of payload.jsonLd || []) {
93
+ const json = JSON.stringify(block).replaceAll('<', '\\u003c')
94
+ tags.push(`<script ${attribute} type="application/ld+json">${json}</script>`)
95
+ }
96
+ return tags.join('')
97
+ }
98
+
99
+ /**
100
+ * Render the state script exposing serialized public config and hydration
101
+ * state on package-owned (or consumer-overridden) globals. Emits nothing when
102
+ * both parts are empty. `nonce` supports strict Content Security Policies.
103
+ */
104
+ export const renderStateScript = (
105
+ { config, state },
106
+ { configKey = '__SSR_CONFIG__', stateKey = '__SSR_STATE__', nonce } = {},
107
+ ) => {
108
+ const parts = []
109
+ if (config != null && Object.keys(config).length > 0) {
110
+ parts.push(`window.${configKey}=${serializeState(config)}`)
111
+ }
112
+ if (state != null) {
113
+ parts.push(`window.${stateKey}=${serializeState(state)}`)
114
+ }
115
+ if (parts.length === 0) return ''
116
+ const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
117
+ return `<script${nonceAttribute}>${parts.join(';')}</script>`
118
+ }
119
+
120
+ /** Inject rendered fragments into a marker-carrying HTML template. */
121
+ export const injectSsrDocument = (template, { head = '', html = '', state = '', teleports = '' }) =>
122
+ template
123
+ .replace(SSR_MARKERS.head, head)
124
+ .replace(SSR_MARKERS.teleports, teleports)
125
+ .replace(SSR_MARKERS.html, html)
126
+ .replace(SSR_MARKERS.state, state)
127
+
128
+ /**
129
+ * Guarantee the four SSR markers exist in a template so consumers never learn
130
+ * marker names. Existing markers are left untouched; missing ones are added:
131
+ * head before </head>, teleports after <body>, html inside the mount element,
132
+ * state before </body>.
133
+ */
134
+ export const ensureSsrMarkers = (template, { mountSelector = '#app' } = {}) => {
135
+ let html = String(template)
136
+ if (!html.includes(SSR_MARKERS.head)) {
137
+ html = html.replace(/<\/head>/i, `${SSR_MARKERS.head}\n</head>`)
138
+ }
139
+ if (!html.includes(SSR_MARKERS.teleports)) {
140
+ html = html.replace(/<body([^>]*)>/i, `<body$1>${SSR_MARKERS.teleports}`)
141
+ }
142
+ if (!html.includes(SSR_MARKERS.html)) {
143
+ const id = mountSelector.startsWith('#') ? mountSelector.slice(1) : mountSelector
144
+ const mountPattern = new RegExp(`(<div[^>]*id=["']${id}["'][^>]*>)(</div>)`, 'i')
145
+ html = html.replace(mountPattern, `$1${SSR_MARKERS.html}$2`)
146
+ }
147
+ if (!html.includes(SSR_MARKERS.state)) {
148
+ html = html.replace(/<\/body>/i, `${SSR_MARKERS.state}\n</body>`)
149
+ }
150
+ return html
151
+ }
152
+
153
+ /** Generic accessible error markup used by fallback shells (overridable upstream). */
154
+ export const renderErrorMarkup = (title, message) => `
155
+ <main id="main-content" style="min-height:70vh;display:grid;place-items:center;padding:2rem;text-align:center;font-family:system-ui,sans-serif" tabindex="-1">
156
+ <div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p><p><a href="/">Return home</a></p></div>
157
+ </main>`
158
+
159
+ /** Sitemap primitives shared by consumers that serve SEO XML from the server. */
160
+ export const renderSitemapUrlSet = (urls) => {
161
+ const uniqueUrls = [...new Set(urls)]
162
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${uniqueUrls.map((url) => `\n <url><loc>${escapeXml(url)}</loc></url>`).join('')}\n</urlset>\n`
163
+ }
164
+
165
+ export const renderSitemapIndex = (urls) =>
166
+ `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.map((url) => `\n <sitemap><loc>${escapeXml(url)}</loc></sitemap>`).join('')}\n</sitemapindex>\n`