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 +154 -0
- package/apollo/SsrApolloAdapter.js +184 -0
- package/apollo/index.d.ts +50 -0
- package/bin/vue-ssr-lite.mjs +83 -0
- package/engine/.fuse_hidden0000002d00000001 +166 -0
- package/engine/SsrEngine.js +139 -0
- package/engine/SsrEngineHostPolicy.js +132 -0
- package/engine/SsrEngineHtml.js +166 -0
- package/engine/index.d.ts +86 -0
- package/engine/index.js +26 -0
- package/index.d.ts +104 -0
- package/index.js +40 -0
- package/package.json +91 -0
- package/runtime/SsrRuntimeApp.js +94 -0
- package/runtime/SsrRuntimeClient.js +72 -0
- package/runtime/SsrRuntimeContext.js +44 -0
- package/runtime/SsrRuntimeServer.js +63 -0
- package/runtime/client.d.ts +20 -0
- package/runtime/server.d.ts +26 -0
- package/server/SsrServer.js +600 -0
- package/server/index.d.ts +91 -0
- package/server/index.js +14 -0
- package/vite/SsrVitePlugin.js +218 -0
- package/vite/index.d.ts +37 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SsrEngine — the framework-agnostic SSR request handler.
|
|
2
|
+
//
|
|
3
|
+
// The engine is deliberately independent of the standalone Node server: it
|
|
4
|
+
// accepts a plain request description and returns a plain response
|
|
5
|
+
// description, so it can be mounted into an existing Node server, Express or
|
|
6
|
+
// similar frameworks, tests, serverless functions, or any environment that
|
|
7
|
+
// can supply `loadTemplate` and `loadRenderModule` implementations. The
|
|
8
|
+
// standalone server in ../server is one (default) host for this engine.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
injectSsrDocument,
|
|
12
|
+
renderHeadTags,
|
|
13
|
+
renderStateScript,
|
|
14
|
+
} from './SsrEngineHtml.js'
|
|
15
|
+
import { selectSsrCookieHeader } from './SsrEngineHostPolicy.js'
|
|
16
|
+
|
|
17
|
+
export const SSR_HTML_SECURITY_HEADERS = Object.freeze({
|
|
18
|
+
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
19
|
+
'x-content-type-options': 'nosniff',
|
|
20
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create an SSR request handler.
|
|
25
|
+
*
|
|
26
|
+
* options:
|
|
27
|
+
* - loadTemplate(requestUrl): Promise<string> — marker-carrying HTML template
|
|
28
|
+
* (already transformed for the current mode).
|
|
29
|
+
* - loadRenderModule(): Promise<module> — module exposing the render function.
|
|
30
|
+
* - renderExport: name of the render export (default 'render').
|
|
31
|
+
* - config: optional serializable public config forwarded to the render and
|
|
32
|
+
* serialized for the browser. Omit it entirely when the app needs none.
|
|
33
|
+
* - globals: { configKey, stateKey } — serialized global names.
|
|
34
|
+
* - headAttribute: attribute marking server-rendered head tags.
|
|
35
|
+
* - defaultHead: raw HTML string injected when the render yields no head
|
|
36
|
+
* payload (consumer-owned fallback; empty by default).
|
|
37
|
+
* - cookieAllowlist / cookieDenylist: cookie-name filtering before SSR.
|
|
38
|
+
* - renderTimeoutMs: hard budget for one render (default 10000).
|
|
39
|
+
* - nonce(request): optional per-request CSP nonce provider.
|
|
40
|
+
*/
|
|
41
|
+
export const createSsrRequestHandler = (options) => {
|
|
42
|
+
const {
|
|
43
|
+
loadTemplate,
|
|
44
|
+
loadRenderModule,
|
|
45
|
+
renderExport = 'render',
|
|
46
|
+
config,
|
|
47
|
+
globals = {},
|
|
48
|
+
headAttribute = 'data-ssr-head',
|
|
49
|
+
defaultHead = '',
|
|
50
|
+
cookieAllowlist = [],
|
|
51
|
+
cookieDenylist = [],
|
|
52
|
+
renderTimeoutMs = 10_000,
|
|
53
|
+
nonce,
|
|
54
|
+
} = options
|
|
55
|
+
|
|
56
|
+
if (typeof loadTemplate !== 'function' || typeof loadRenderModule !== 'function') {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'createSsrRequestHandler requires loadTemplate and loadRenderModule functions.',
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Handle one SSR request.
|
|
64
|
+
*
|
|
65
|
+
* request: { url (path + search + hash), host, protocol, cookieHeader }
|
|
66
|
+
* Returns: { statusCode, headers, html } — or throws; the host layer owns
|
|
67
|
+
* error presentation.
|
|
68
|
+
*/
|
|
69
|
+
const handle = async (request) => {
|
|
70
|
+
const { url = '/', host, protocol = 'http', cookieHeader } = request
|
|
71
|
+
const module = await loadRenderModule()
|
|
72
|
+
const render = module[renderExport]
|
|
73
|
+
if (typeof render !== 'function') {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`The SSR module does not export a "${renderExport}" function.`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const cookie = selectSsrCookieHeader(cookieHeader, cookieAllowlist, cookieDenylist)
|
|
80
|
+
const renderRequest = {
|
|
81
|
+
url: `${protocol}://${host}${url}`,
|
|
82
|
+
host,
|
|
83
|
+
cookie,
|
|
84
|
+
config,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let timeoutHandle
|
|
88
|
+
const rendered = await Promise.race([
|
|
89
|
+
render(renderRequest),
|
|
90
|
+
new Promise((_resolve, reject) => {
|
|
91
|
+
timeoutHandle = setTimeout(
|
|
92
|
+
() => reject(new Error(`SSR render exceeded ${renderTimeoutMs}ms.`)),
|
|
93
|
+
renderTimeoutMs,
|
|
94
|
+
)
|
|
95
|
+
if (typeof timeoutHandle === 'object') timeoutHandle.unref?.()
|
|
96
|
+
}),
|
|
97
|
+
]).finally(() => clearTimeout(timeoutHandle))
|
|
98
|
+
|
|
99
|
+
if (rendered.redirect) {
|
|
100
|
+
return {
|
|
101
|
+
statusCode: rendered.statusCode >= 300 && rendered.statusCode < 400
|
|
102
|
+
? rendered.statusCode
|
|
103
|
+
: 302,
|
|
104
|
+
headers: { location: rendered.redirect, 'cache-control': 'no-store' },
|
|
105
|
+
html: '',
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const template = await loadTemplate(url)
|
|
110
|
+
const requestNonce = typeof nonce === 'function' ? nonce(request) : undefined
|
|
111
|
+
const headHtml = renderHeadTags(rendered.head, headAttribute) || defaultHead
|
|
112
|
+
// Server-level config wins; otherwise the definition-resolved config
|
|
113
|
+
// reported by the render is serialized, so simple consumers configure
|
|
114
|
+
// everything in their single application definition.
|
|
115
|
+
const serializedConfig = config ?? rendered.config
|
|
116
|
+
const html = injectSsrDocument(template, {
|
|
117
|
+
head: headHtml,
|
|
118
|
+
html: rendered.html,
|
|
119
|
+
teleports: rendered.teleports ?? '',
|
|
120
|
+
state: renderStateScript(
|
|
121
|
+
{ config: serializedConfig, state: rendered.state },
|
|
122
|
+
{ ...globals, nonce: requestNonce },
|
|
123
|
+
),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
statusCode: rendered.statusCode ?? 200,
|
|
128
|
+
headers: {
|
|
129
|
+
'content-type': 'text/html; charset=utf-8',
|
|
130
|
+
'cache-control': 'private, no-store',
|
|
131
|
+
vary: 'Host, X-Forwarded-Host',
|
|
132
|
+
...SSR_HTML_SECURITY_HEADERS,
|
|
133
|
+
},
|
|
134
|
+
html,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { handle }
|
|
139
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SsrEngineHostPolicy — host normalization and forwarded-header trust policy.
|
|
2
|
+
//
|
|
3
|
+
// Ported from the proven ERP host runtime. Forwarded headers are only ever
|
|
4
|
+
// honored under an explicit trust flag; hosts are validated for length,
|
|
5
|
+
// control characters, credentials, paths, ports, IPv6 brackets, case, and
|
|
6
|
+
// trailing dots. Product classification (which host serves which entry) is a
|
|
7
|
+
// consumer concern layered on top of these primitives.
|
|
8
|
+
|
|
9
|
+
const firstHeaderValue = (value) =>
|
|
10
|
+
String(Array.isArray(value) ? value[0] : value || '')
|
|
11
|
+
.split(',', 1)[0]
|
|
12
|
+
.trim()
|
|
13
|
+
|
|
14
|
+
export const normalizeSsrHost = (host) => {
|
|
15
|
+
const raw = firstHeaderValue(host)
|
|
16
|
+
if (
|
|
17
|
+
!raw ||
|
|
18
|
+
raw.length > 512 ||
|
|
19
|
+
/[\u0000-\u0020\u007f]/.test(raw) ||
|
|
20
|
+
/[\\/@?#]/.test(raw)
|
|
21
|
+
) {
|
|
22
|
+
return ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const authority =
|
|
27
|
+
raw.includes(':') && raw.split(':').length > 2 && !raw.startsWith('[')
|
|
28
|
+
? `[${raw}]`
|
|
29
|
+
: raw
|
|
30
|
+
const parsed = new URL(`http://${authority}`)
|
|
31
|
+
if (
|
|
32
|
+
parsed.username ||
|
|
33
|
+
parsed.password ||
|
|
34
|
+
parsed.pathname !== '/' ||
|
|
35
|
+
parsed.search ||
|
|
36
|
+
parsed.hash
|
|
37
|
+
) {
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, '')
|
|
41
|
+
if (!hostname) return ''
|
|
42
|
+
const bracketedHostname =
|
|
43
|
+
hostname.includes(':') && !hostname.startsWith('[')
|
|
44
|
+
? `[${hostname}]`
|
|
45
|
+
: hostname
|
|
46
|
+
return `${bracketedHostname}${parsed.port ? `:${parsed.port}` : ''}`
|
|
47
|
+
} catch {
|
|
48
|
+
return ''
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const stripSsrHostPort = (host) => {
|
|
53
|
+
const value = normalizeSsrHost(host)
|
|
54
|
+
if (!value) return ''
|
|
55
|
+
if (value.startsWith('[')) {
|
|
56
|
+
const end = value.indexOf(']')
|
|
57
|
+
return end >= 0 ? value.slice(1, end) : ''
|
|
58
|
+
}
|
|
59
|
+
return value.replace(/:\d+$/, '').replace(/\.$/, '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Normalize an exact host or a safe leading-wildcard pattern (`*.domain`). */
|
|
63
|
+
export const normalizeSsrHostPattern = (host) => {
|
|
64
|
+
const value = String(host || '').trim()
|
|
65
|
+
if (!value.startsWith('*.')) return normalizeSsrHost(value)
|
|
66
|
+
const baseDomain = stripSsrHostPort(value.slice(2))
|
|
67
|
+
if (!baseDomain || baseDomain.includes(':')) return ''
|
|
68
|
+
return `*.${baseDomain}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when `hostname` (no port) matches any exact or wildcard pattern. */
|
|
72
|
+
export const matchesSsrHostPatterns = (hostname, patterns) => {
|
|
73
|
+
const host = stripSsrHostPort(hostname)
|
|
74
|
+
if (!host) return false
|
|
75
|
+
for (const candidate of patterns || []) {
|
|
76
|
+
const pattern = normalizeSsrHostPattern(candidate)
|
|
77
|
+
if (!pattern) continue
|
|
78
|
+
if (pattern.startsWith('*.')) {
|
|
79
|
+
if (host.endsWith(`.${pattern.slice(2)}`)) return true
|
|
80
|
+
} else if (stripSsrHostPort(pattern) === host) {
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const resolveSsrForwardedHost = (
|
|
88
|
+
forwardedHost,
|
|
89
|
+
host,
|
|
90
|
+
trustForwardedHeaders = false,
|
|
91
|
+
) =>
|
|
92
|
+
normalizeSsrHost(
|
|
93
|
+
trustForwardedHeaders ? firstHeaderValue(forwardedHost) || host : host,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
export const resolveSsrForwardedProtocol = (
|
|
97
|
+
forwardedProtocol,
|
|
98
|
+
fallbackProtocol = 'http',
|
|
99
|
+
trustForwardedHeaders = false,
|
|
100
|
+
) => {
|
|
101
|
+
const fallback = fallbackProtocol === 'https' ? 'https' : 'http'
|
|
102
|
+
if (!trustForwardedHeaders) return fallback
|
|
103
|
+
const protocol = firstHeaderValue(forwardedProtocol).toLowerCase()
|
|
104
|
+
return protocol === 'https' || protocol === 'http' ? protocol : fallback
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Filter a Cookie header down to an allowlist of cookie names, with an
|
|
109
|
+
* optional denylist that always wins. Returns undefined when nothing remains,
|
|
110
|
+
* so downstream data layers never see unapproved cookies.
|
|
111
|
+
*/
|
|
112
|
+
export const selectSsrCookieHeader = (cookieHeader, allowlist, denylist) => {
|
|
113
|
+
const allowed = new Set(
|
|
114
|
+
(allowlist || [])
|
|
115
|
+
.map((name) => String(name).trim())
|
|
116
|
+
.filter((name) => /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)),
|
|
117
|
+
)
|
|
118
|
+
const denied = new Set((denylist || []).map((name) => String(name).trim()))
|
|
119
|
+
for (const name of denied) allowed.delete(name)
|
|
120
|
+
if (!cookieHeader || allowed.size === 0) return undefined
|
|
121
|
+
const selected = String(cookieHeader)
|
|
122
|
+
.split(';')
|
|
123
|
+
.map((part) => part.trim())
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.filter((part) => {
|
|
126
|
+
const separator = part.indexOf('=')
|
|
127
|
+
if (separator <= 0) return false
|
|
128
|
+
const name = part.slice(0, separator).trim()
|
|
129
|
+
return allowed.has(name) && !denied.has(name)
|
|
130
|
+
})
|
|
131
|
+
return selected.length > 0 ? selected.join('; ') : undefined
|
|
132
|
+
}
|
|
@@ -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('&', '&')
|
|
19
|
+
.replaceAll('<', '<')
|
|
20
|
+
.replaceAll('>', '>')
|
|
21
|
+
.replaceAll('"', '"')
|
|
22
|
+
.replaceAll("'", ''')
|
|
23
|
+
|
|
24
|
+
export const escapeXml = (value) =>
|
|
25
|
+
String(value ?? '')
|
|
26
|
+
.replaceAll('&', '&')
|
|
27
|
+
.replaceAll('<', '<')
|
|
28
|
+
.replaceAll('>', '>')
|
|
29
|
+
.replaceAll('"', '"')
|
|
30
|
+
.replaceAll("'", ''')
|
|
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', '\\u2028')
|
|
37
|
+
.replaceAll('\u2029', '\\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`
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SsrHeadPayload } from '../index'
|
|
2
|
+
|
|
3
|
+
export declare const SSR_MARKERS: Readonly<{
|
|
4
|
+
head: string
|
|
5
|
+
html: string
|
|
6
|
+
state: string
|
|
7
|
+
teleports: string
|
|
8
|
+
}>
|
|
9
|
+
export declare const SSR_HTML_SECURITY_HEADERS: Readonly<Record<string, string>>
|
|
10
|
+
|
|
11
|
+
export declare const escapeHtml: (value: unknown) => string
|
|
12
|
+
export declare const escapeXml: (value: unknown) => string
|
|
13
|
+
export declare const serializeState: (value: unknown) => string
|
|
14
|
+
export declare const renderHeadTags: (
|
|
15
|
+
payload: SsrHeadPayload | null | undefined,
|
|
16
|
+
attribute?: string,
|
|
17
|
+
) => string
|
|
18
|
+
export declare const renderStateScript: (
|
|
19
|
+
parts: { config?: Record<string, unknown> | null; state?: unknown },
|
|
20
|
+
options?: { configKey?: string; stateKey?: string; nonce?: string },
|
|
21
|
+
) => string
|
|
22
|
+
export declare const injectSsrDocument: (
|
|
23
|
+
template: string,
|
|
24
|
+
fragments: { head?: string; html?: string; state?: string; teleports?: string },
|
|
25
|
+
) => string
|
|
26
|
+
export declare const ensureSsrMarkers: (
|
|
27
|
+
template: string,
|
|
28
|
+
options?: { mountSelector?: string },
|
|
29
|
+
) => string
|
|
30
|
+
export declare const renderErrorMarkup: (title: string, message: string) => string
|
|
31
|
+
export declare const renderSitemapUrlSet: (urls: string[]) => string
|
|
32
|
+
export declare const renderSitemapIndex: (urls: string[]) => string
|
|
33
|
+
|
|
34
|
+
export declare const normalizeSsrHost: (host: unknown) => string
|
|
35
|
+
export declare const stripSsrHostPort: (host: unknown) => string
|
|
36
|
+
export declare const normalizeSsrHostPattern: (host: unknown) => string
|
|
37
|
+
export declare const matchesSsrHostPatterns: (
|
|
38
|
+
hostname: unknown,
|
|
39
|
+
patterns: ReadonlyArray<string> | undefined,
|
|
40
|
+
) => boolean
|
|
41
|
+
export declare const resolveSsrForwardedHost: (
|
|
42
|
+
forwardedHost: unknown,
|
|
43
|
+
host: unknown,
|
|
44
|
+
trustForwardedHeaders?: boolean,
|
|
45
|
+
) => string
|
|
46
|
+
export declare const resolveSsrForwardedProtocol: (
|
|
47
|
+
forwardedProtocol: unknown,
|
|
48
|
+
fallbackProtocol?: string,
|
|
49
|
+
trustForwardedHeaders?: boolean,
|
|
50
|
+
) => 'http' | 'https'
|
|
51
|
+
export declare const selectSsrCookieHeader: (
|
|
52
|
+
cookieHeader: unknown,
|
|
53
|
+
allowlist?: ReadonlyArray<string>,
|
|
54
|
+
denylist?: ReadonlyArray<string>,
|
|
55
|
+
) => string | undefined
|
|
56
|
+
|
|
57
|
+
export interface SsrEngineRequest {
|
|
58
|
+
url?: string
|
|
59
|
+
host: string
|
|
60
|
+
protocol?: 'http' | 'https'
|
|
61
|
+
cookieHeader?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SsrEngineResponse {
|
|
65
|
+
statusCode: number
|
|
66
|
+
headers: Record<string, string>
|
|
67
|
+
html: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SsrEngineOptions {
|
|
71
|
+
loadTemplate: (requestUrl?: string) => Promise<string> | string
|
|
72
|
+
loadRenderModule: () => Promise<Record<string, unknown>>
|
|
73
|
+
renderExport?: string
|
|
74
|
+
config?: Record<string, unknown>
|
|
75
|
+
globals?: { configKey?: string; stateKey?: string }
|
|
76
|
+
headAttribute?: string
|
|
77
|
+
defaultHead?: string
|
|
78
|
+
cookieAllowlist?: string[]
|
|
79
|
+
cookieDenylist?: string[]
|
|
80
|
+
renderTimeoutMs?: number
|
|
81
|
+
nonce?: (request: SsrEngineRequest) => string | undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export declare const createSsrRequestHandler: (options: SsrEngineOptions) => {
|
|
85
|
+
handle: (request: SsrEngineRequest) => Promise<SsrEngineResponse>
|
|
86
|
+
}
|
package/engine/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SSR_MARKERS,
|
|
3
|
+
escapeHtml,
|
|
4
|
+
escapeXml,
|
|
5
|
+
serializeState,
|
|
6
|
+
renderHeadTags,
|
|
7
|
+
renderStateScript,
|
|
8
|
+
injectSsrDocument,
|
|
9
|
+
ensureSsrMarkers,
|
|
10
|
+
renderErrorMarkup,
|
|
11
|
+
renderSitemapUrlSet,
|
|
12
|
+
renderSitemapIndex,
|
|
13
|
+
} from './SsrEngineHtml.js'
|
|
14
|
+
export {
|
|
15
|
+
normalizeSsrHost,
|
|
16
|
+
stripSsrHostPort,
|
|
17
|
+
normalizeSsrHostPattern,
|
|
18
|
+
matchesSsrHostPatterns,
|
|
19
|
+
resolveSsrForwardedHost,
|
|
20
|
+
resolveSsrForwardedProtocol,
|
|
21
|
+
selectSsrCookieHeader,
|
|
22
|
+
} from './SsrEngineHostPolicy.js'
|
|
23
|
+
export {
|
|
24
|
+
createSsrRequestHandler,
|
|
25
|
+
SSR_HTML_SECURITY_HEADERS,
|
|
26
|
+
} from './SsrEngine.js'
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { App, Component, InjectionKey, Ref } from 'vue'
|
|
2
|
+
import type { Router, RouteRecordRaw, RouterScrollBehavior } from 'vue-router'
|
|
3
|
+
import type { ApolloClient, ApolloLink, DefaultOptions, InMemoryCacheConfig, NormalizedCacheObject } from '@apollo/client/core'
|
|
4
|
+
|
|
5
|
+
export interface SsrRequestContext<
|
|
6
|
+
TConfig extends Record<string, unknown> = Record<string, unknown>,
|
|
7
|
+
TState extends Record<string, unknown> = Record<string, unknown>,
|
|
8
|
+
> {
|
|
9
|
+
url: URL
|
|
10
|
+
host: string
|
|
11
|
+
/** Allowlist-filtered Cookie header. Server only; undefined in the browser. */
|
|
12
|
+
cookie?: string
|
|
13
|
+
config: TConfig
|
|
14
|
+
state: TState
|
|
15
|
+
head: Ref<SsrHeadPayload | null>
|
|
16
|
+
response: { statusCode: number; redirect: string | null }
|
|
17
|
+
server: boolean
|
|
18
|
+
/** Present when the definition declares an Apollo integration. */
|
|
19
|
+
apollo?: ApolloClient<NormalizedCacheObject>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SsrHeadPayload {
|
|
23
|
+
title?: string
|
|
24
|
+
description?: string
|
|
25
|
+
keywords?: string
|
|
26
|
+
robots?: string
|
|
27
|
+
ogTitle?: string
|
|
28
|
+
ogDescription?: string
|
|
29
|
+
ogImage?: string
|
|
30
|
+
ogImageAlt?: string
|
|
31
|
+
ogUrl?: string
|
|
32
|
+
ogType?: string
|
|
33
|
+
ogSiteName?: string
|
|
34
|
+
ogLocale?: string
|
|
35
|
+
twitterCard?: string
|
|
36
|
+
twitterTitle?: string
|
|
37
|
+
twitterDescription?: string
|
|
38
|
+
twitterImage?: string
|
|
39
|
+
twitterImageAlt?: string
|
|
40
|
+
twitterSite?: string
|
|
41
|
+
twitterCreator?: string
|
|
42
|
+
canonicalUrl?: string
|
|
43
|
+
favicon?: string
|
|
44
|
+
jsonLd?: Array<Record<string, unknown>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SsrApolloDefinition {
|
|
48
|
+
endpoint?: string | ((config: Record<string, unknown>) => string)
|
|
49
|
+
endpoints?: Record<string, string | ((config: Record<string, unknown>) => string)>
|
|
50
|
+
timeoutMs?: number | ((config: Record<string, unknown>) => number)
|
|
51
|
+
headers?:
|
|
52
|
+
| Record<string, string>
|
|
53
|
+
| ((environment: { server: boolean }) => Record<string, string>)
|
|
54
|
+
links?:
|
|
55
|
+
| ApolloLink[]
|
|
56
|
+
| ((environment: {
|
|
57
|
+
server: boolean
|
|
58
|
+
context: SsrRequestContext
|
|
59
|
+
}) => ApolloLink[])
|
|
60
|
+
cacheConfig?: InMemoryCacheConfig
|
|
61
|
+
ssrForceFetchDelay?: number
|
|
62
|
+
defaultOptions?: DefaultOptions
|
|
63
|
+
install?: (
|
|
64
|
+
app: App,
|
|
65
|
+
clients: Record<string, ApolloClient<NormalizedCacheObject>>,
|
|
66
|
+
context: SsrRequestContext,
|
|
67
|
+
) => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SsrSetupEnvironment {
|
|
71
|
+
app: App
|
|
72
|
+
router: Router | null
|
|
73
|
+
context: SsrRequestContext
|
|
74
|
+
apollo: ApolloClient<NormalizedCacheObject> | null
|
|
75
|
+
apolloClients: Record<string, ApolloClient<NormalizedCacheObject>> | null
|
|
76
|
+
server: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SsrAppDefinition {
|
|
80
|
+
root: Component
|
|
81
|
+
routes?: RouteRecordRaw[]
|
|
82
|
+
router?: (environment: {
|
|
83
|
+
server: boolean
|
|
84
|
+
context: SsrRequestContext
|
|
85
|
+
}) => Router | Promise<Router>
|
|
86
|
+
scrollBehavior?: RouterScrollBehavior
|
|
87
|
+
apollo?: SsrApolloDefinition
|
|
88
|
+
config?: Record<string, unknown> | (() => Record<string, unknown>)
|
|
89
|
+
head?: SsrHeadPayload
|
|
90
|
+
setup?: (environment: SsrSetupEnvironment) => void | Promise<void>
|
|
91
|
+
hydration?: {
|
|
92
|
+
configKey?: string
|
|
93
|
+
stateKey?: string
|
|
94
|
+
headAttribute?: string
|
|
95
|
+
mountSelector?: string
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export declare const SSR_CONTEXT_KEY: InjectionKey<SsrRequestContext>
|
|
100
|
+
export declare const useSsrContext: <
|
|
101
|
+
TConfig extends Record<string, unknown> = Record<string, unknown>,
|
|
102
|
+
TState extends Record<string, unknown> = Record<string, unknown>,
|
|
103
|
+
>() => SsrRequestContext<TConfig, TState>
|
|
104
|
+
export declare const defineSsrApp: (definition: SsrAppDefinition) => SsrAppDefinition
|
package/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// vue-ssr-lite — public application-definition entry (isomorphic, tiny).
|
|
2
|
+
//
|
|
3
|
+
// A consumer's entire SSR integration is one application definition created
|
|
4
|
+
// with defineSsrApp. The same definition drives the generated server entry
|
|
5
|
+
// (render) and the generated client entry (hydration), so server and browser
|
|
6
|
+
// can never drift.
|
|
7
|
+
|
|
8
|
+
export { SSR_CONTEXT_KEY, useSsrContext } from './runtime/SsrRuntimeContext.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Declare an SSR application.
|
|
12
|
+
*
|
|
13
|
+
* definition:
|
|
14
|
+
* - root (required): the root Vue component of the public application.
|
|
15
|
+
* - routes: optional route records; when present the package creates a
|
|
16
|
+
* router (memory history on the server, web history in the browser).
|
|
17
|
+
* - router: optional async factory ({ server, context }) => Router for apps
|
|
18
|
+
* that build their router dynamically (advanced alternative to routes).
|
|
19
|
+
* - scrollBehavior: optional router scroll behavior (used with routes).
|
|
20
|
+
* - apollo: optional Apollo integration — endpoint or endpoints (values may
|
|
21
|
+
* be functions of the public config), timeoutMs, headers, links,
|
|
22
|
+
* cacheConfig, ssrForceFetchDelay, defaultOptions, and an optional
|
|
23
|
+
* install(app, clients, context) hook for library-specific provides.
|
|
24
|
+
* - config: optional serializable public configuration (object or factory).
|
|
25
|
+
* Serialized for the browser only when non-empty.
|
|
26
|
+
* - head: optional default head payload used when no page sets one.
|
|
27
|
+
* - setup: optional ({ app, router, context, apollo, apolloClients, server })
|
|
28
|
+
* hook for plugin installation and application provides.
|
|
29
|
+
* - hydration: optional overrides for global names, head attribute, and
|
|
30
|
+
* mount selector (compatibility tier; normal consumers never set these).
|
|
31
|
+
*/
|
|
32
|
+
export const defineSsrApp = (definition) => {
|
|
33
|
+
if (!definition || typeof definition !== 'object') {
|
|
34
|
+
throw new Error('defineSsrApp requires a definition object.')
|
|
35
|
+
}
|
|
36
|
+
if (!definition.root) {
|
|
37
|
+
throw new Error('defineSsrApp requires a root component.')
|
|
38
|
+
}
|
|
39
|
+
return definition
|
|
40
|
+
}
|