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,600 @@
|
|
|
1
|
+
// SsrServer — the optional standalone Node server (default host of the engine).
|
|
2
|
+
//
|
|
3
|
+
// Ready-to-use production and development server: static assets, health and
|
|
4
|
+
// readiness endpoints, host classification across SSR and SPA entries,
|
|
5
|
+
// startup preflight, error shells, and graceful shutdown. The SSR rendering
|
|
6
|
+
// itself goes through the separable engine (../engine), so deployments with
|
|
7
|
+
// their own servers can bypass this file entirely.
|
|
8
|
+
//
|
|
9
|
+
// Development and production run one request pipeline; only module loading
|
|
10
|
+
// and HTML transformation differ (Vite middleware vs built assets).
|
|
11
|
+
|
|
12
|
+
import { createServer } from 'node:http'
|
|
13
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
14
|
+
import { extname, resolve } from 'node:path'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import {
|
|
17
|
+
createSsrRequestHandler,
|
|
18
|
+
SSR_HTML_SECURITY_HEADERS,
|
|
19
|
+
} from '../engine/SsrEngine.js'
|
|
20
|
+
import {
|
|
21
|
+
SSR_MARKERS,
|
|
22
|
+
renderErrorMarkup,
|
|
23
|
+
escapeHtml,
|
|
24
|
+
} from '../engine/SsrEngineHtml.js'
|
|
25
|
+
import {
|
|
26
|
+
matchesSsrHostPatterns,
|
|
27
|
+
resolveSsrForwardedHost,
|
|
28
|
+
resolveSsrForwardedProtocol,
|
|
29
|
+
stripSsrHostPort,
|
|
30
|
+
} from '../engine/SsrEngineHostPolicy.js'
|
|
31
|
+
|
|
32
|
+
const LOCAL_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', '::1']
|
|
33
|
+
|
|
34
|
+
const MIME_TYPES = {
|
|
35
|
+
'.css': 'text/css; charset=utf-8',
|
|
36
|
+
'.gif': 'image/gif',
|
|
37
|
+
'.html': 'text/html; charset=utf-8',
|
|
38
|
+
'.ico': 'image/x-icon',
|
|
39
|
+
'.jpeg': 'image/jpeg',
|
|
40
|
+
'.jpg': 'image/jpeg',
|
|
41
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
42
|
+
'.json': 'application/json; charset=utf-8',
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.svg': 'image/svg+xml',
|
|
45
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
46
|
+
'.webp': 'image/webp',
|
|
47
|
+
'.woff': 'font/woff',
|
|
48
|
+
'.woff2': 'font/woff2',
|
|
49
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parseBoolean = (value) =>
|
|
53
|
+
['1', 'true', 'yes', 'on'].includes(
|
|
54
|
+
String(value || '')
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase(),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const parsePort = (value, fallback) => {
|
|
60
|
+
const parsed = Number(value)
|
|
61
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return fallback
|
|
62
|
+
return parsed
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resolveRootCause = (error) => {
|
|
66
|
+
let cause = error
|
|
67
|
+
while (cause instanceof Error && cause.cause) cause = cause.cause
|
|
68
|
+
return cause
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const SSR_RUNTIME_MANIFEST = '.vue-ssr-lite.json'
|
|
72
|
+
|
|
73
|
+
/** Load serialized server options emitted by the Vite integration at build time. */
|
|
74
|
+
export const loadSsrRuntimeManifest = async (clientDir) => {
|
|
75
|
+
try {
|
|
76
|
+
const raw = await readFile(resolve(clientDir, SSR_RUNTIME_MANIFEST), 'utf8')
|
|
77
|
+
return JSON.parse(raw)
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const createSsrServer = async (userOptions = {}) => {
|
|
84
|
+
const root = resolve(userOptions.root || process.cwd())
|
|
85
|
+
const production =
|
|
86
|
+
userOptions.mode != null
|
|
87
|
+
? userOptions.mode === 'production'
|
|
88
|
+
: process.env.NODE_ENV === 'production'
|
|
89
|
+
const clientDir = resolve(root, userOptions.clientDir || 'dist/client')
|
|
90
|
+
const logger = userOptions.logger || console
|
|
91
|
+
|
|
92
|
+
// ---- Development: Vite in middleware mode -----------------------------
|
|
93
|
+
let vite = null
|
|
94
|
+
if (!production) {
|
|
95
|
+
const { createServer: createViteServer } = await import('vite')
|
|
96
|
+
vite = await createViteServer({
|
|
97
|
+
root,
|
|
98
|
+
server: { middlewareMode: true },
|
|
99
|
+
appType: 'custom',
|
|
100
|
+
...(userOptions.vite || {}),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Options: programmatic > plugin/manifest > defaults ----------------
|
|
105
|
+
let integrationOptions = null
|
|
106
|
+
if (!production && vite) {
|
|
107
|
+
const plugin = vite.config.plugins.find((p) => p.name === 'vue-ssr-lite')
|
|
108
|
+
integrationOptions = plugin?.api?.getServerOptions?.() ?? null
|
|
109
|
+
} else if (production) {
|
|
110
|
+
integrationOptions = await loadSsrRuntimeManifest(clientDir)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const options = { ...(integrationOptions || {}), ...userOptions }
|
|
114
|
+
|
|
115
|
+
const port = parsePort(process.env.PORT ?? options.port, 4173)
|
|
116
|
+
const host = process.env.HOST || options.host || '0.0.0.0'
|
|
117
|
+
const trustProxy =
|
|
118
|
+
options.trustProxy ?? parseBoolean(process.env.SSR_TRUST_PROXY)
|
|
119
|
+
const globals = options.globals || {}
|
|
120
|
+
const headAttribute = options.headAttribute || 'data-ssr-head'
|
|
121
|
+
const cookieAllowlist = options.cookieAllowlist || []
|
|
122
|
+
const cookieDenylist = options.cookieDenylist || []
|
|
123
|
+
const serveRole = options.serveRole || 'unified'
|
|
124
|
+
const consumerRoutes = options.routes || []
|
|
125
|
+
const readinessProbes = options.readiness || []
|
|
126
|
+
const serviceName = options.serviceName || 'vue-ssr-lite'
|
|
127
|
+
|
|
128
|
+
const apps = (options.apps || []).map((app) => ({
|
|
129
|
+
type: 'ssr',
|
|
130
|
+
hosts: [],
|
|
131
|
+
...app,
|
|
132
|
+
}))
|
|
133
|
+
if (apps.length === 0) {
|
|
134
|
+
apps.push({ name: 'ssr', type: 'ssr', template: 'index.html', hosts: [] })
|
|
135
|
+
}
|
|
136
|
+
const defaultApp =
|
|
137
|
+
apps.find((app) => app.default) || apps.find((app) => app.type === 'ssr') || apps[0]
|
|
138
|
+
const ssrApp = apps.find((app) => app.type === 'ssr')
|
|
139
|
+
|
|
140
|
+
const serverEntry = options.serverEntry || 'virtual:vue-ssr-lite/server'
|
|
141
|
+
const bundlePath = resolve(root, options.bundlePath || 'dist/server/entry.mjs')
|
|
142
|
+
const renderExport = options.renderExport || 'render'
|
|
143
|
+
const readyExport = options.readyExport || 'assertReady'
|
|
144
|
+
|
|
145
|
+
let productionModule = null
|
|
146
|
+
const loadRenderModule = async () => {
|
|
147
|
+
if (production) {
|
|
148
|
+
productionModule ??= await import(pathToFileURL(bundlePath).href)
|
|
149
|
+
return productionModule
|
|
150
|
+
}
|
|
151
|
+
return vite.ssrLoadModule(serverEntry)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const loadHtmlTemplate = async (templateName, requestUrl) => {
|
|
155
|
+
let template = await readFile(
|
|
156
|
+
resolve(production ? clientDir : root, templateName),
|
|
157
|
+
'utf8',
|
|
158
|
+
)
|
|
159
|
+
if (!production) {
|
|
160
|
+
template = await vite.transformIndexHtml(requestUrl || '/', template)
|
|
161
|
+
}
|
|
162
|
+
return template
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- The separable engine ---------------------------------------------
|
|
166
|
+
const engine = ssrApp
|
|
167
|
+
? createSsrRequestHandler({
|
|
168
|
+
loadTemplate: (requestUrl) => loadHtmlTemplate(ssrApp.template, requestUrl),
|
|
169
|
+
loadRenderModule,
|
|
170
|
+
renderExport,
|
|
171
|
+
config: options.config,
|
|
172
|
+
globals,
|
|
173
|
+
headAttribute,
|
|
174
|
+
defaultHead: options.defaultHead || '',
|
|
175
|
+
cookieAllowlist,
|
|
176
|
+
cookieDenylist,
|
|
177
|
+
renderTimeoutMs: options.renderTimeoutMs,
|
|
178
|
+
nonce: options.security?.nonce,
|
|
179
|
+
})
|
|
180
|
+
: null
|
|
181
|
+
|
|
182
|
+
// ---- Classification ----------------------------------------------------
|
|
183
|
+
const classify = (hostname, pathname, headers) => {
|
|
184
|
+
if (typeof options.classifyRequest === 'function') {
|
|
185
|
+
const result = options.classifyRequest({ hostname, pathname, headers })
|
|
186
|
+
if (result) return result
|
|
187
|
+
}
|
|
188
|
+
const bare = stripSsrHostPort(hostname)
|
|
189
|
+
if (!bare) return 'invalid'
|
|
190
|
+
for (const app of apps) {
|
|
191
|
+
if (app.hosts?.length && matchesSsrHostPatterns(bare, app.hosts)) {
|
|
192
|
+
return app.name
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (LOCAL_HOSTS.includes(bare)) {
|
|
196
|
+
const localApp = apps.find((app) => app.localhost) || defaultApp
|
|
197
|
+
return localApp.name
|
|
198
|
+
}
|
|
199
|
+
return defaultApp.name
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const isAppAllowed = (appName) => serveRole === 'unified' || serveRole === appName
|
|
203
|
+
|
|
204
|
+
// ---- Helpers -----------------------------------------------------------
|
|
205
|
+
const endResponse = (request, response, body = '') => {
|
|
206
|
+
response.end(request.method === 'HEAD' ? '' : body)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sendJson = (request, response, statusCode, payload) => {
|
|
210
|
+
response.writeHead(statusCode, {
|
|
211
|
+
'content-type': 'application/json; charset=utf-8',
|
|
212
|
+
'cache-control': 'no-store',
|
|
213
|
+
})
|
|
214
|
+
endResponse(request, response, JSON.stringify(payload))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sendText = (
|
|
218
|
+
request,
|
|
219
|
+
response,
|
|
220
|
+
statusCode,
|
|
221
|
+
body,
|
|
222
|
+
contentType = 'text/plain; charset=utf-8',
|
|
223
|
+
headers = {},
|
|
224
|
+
) => {
|
|
225
|
+
response.writeHead(statusCode, {
|
|
226
|
+
'content-type': contentType,
|
|
227
|
+
'cache-control': 'no-store',
|
|
228
|
+
...headers,
|
|
229
|
+
})
|
|
230
|
+
endResponse(request, response, body)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const isHtmlNavigation = (request, pathname) => {
|
|
234
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return false
|
|
235
|
+
if (request.headers['sec-fetch-mode'] === 'navigate') return true
|
|
236
|
+
const accept = String(request.headers.accept || '')
|
|
237
|
+
if (accept.includes('text/html')) return true
|
|
238
|
+
return (!accept || accept === '*/*') && !extname(pathname)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const serveProductionAsset = async (pathname, request, response) => {
|
|
242
|
+
try {
|
|
243
|
+
const relativePath = decodeURIComponent(pathname).replace(/^\/+/, '')
|
|
244
|
+
if (
|
|
245
|
+
relativePath === SSR_RUNTIME_MANIFEST ||
|
|
246
|
+
apps.some((app) => relativePath === app.template)
|
|
247
|
+
) {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
const filePath = resolve(clientDir, relativePath)
|
|
251
|
+
if (!filePath.startsWith(`${clientDir}/`)) return false
|
|
252
|
+
const info = await stat(filePath)
|
|
253
|
+
if (!info.isFile()) return false
|
|
254
|
+
const body = await readFile(filePath)
|
|
255
|
+
response.writeHead(200, {
|
|
256
|
+
'content-type': MIME_TYPES[extname(filePath)] || 'application/octet-stream',
|
|
257
|
+
'cache-control': pathname.startsWith('/assets/')
|
|
258
|
+
? 'public, max-age=31536000, immutable'
|
|
259
|
+
: 'public, max-age=3600',
|
|
260
|
+
})
|
|
261
|
+
endResponse(request, response, body)
|
|
262
|
+
return true
|
|
263
|
+
} catch {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const runViteMiddleware = (request, response) =>
|
|
269
|
+
new Promise((resolveMiddleware, reject) => {
|
|
270
|
+
vite.middlewares(request, response, (error) => {
|
|
271
|
+
if (error) reject(error)
|
|
272
|
+
else resolveMiddleware()
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const sendNavigationError = async (request, response, statusCode, appName, error) => {
|
|
277
|
+
const title = 'Application unavailable'
|
|
278
|
+
const message =
|
|
279
|
+
!production && error instanceof Error
|
|
280
|
+
? error.message
|
|
281
|
+
: 'The application could not render this page. Please try again.'
|
|
282
|
+
const custom = options.errorPage?.({
|
|
283
|
+
statusCode,
|
|
284
|
+
error,
|
|
285
|
+
appName,
|
|
286
|
+
mode: production ? 'production' : 'development',
|
|
287
|
+
})
|
|
288
|
+
if (custom) {
|
|
289
|
+
return sendText(
|
|
290
|
+
request,
|
|
291
|
+
response,
|
|
292
|
+
statusCode,
|
|
293
|
+
custom,
|
|
294
|
+
'text/html; charset=utf-8',
|
|
295
|
+
SSR_HTML_SECURITY_HEADERS,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
const markup = renderErrorMarkup(title, message)
|
|
299
|
+
try {
|
|
300
|
+
const app = apps.find((candidate) => candidate.name === appName) || defaultApp
|
|
301
|
+
let template = await loadHtmlTemplate(app.template, request.url)
|
|
302
|
+
template = template
|
|
303
|
+
.replace(
|
|
304
|
+
SSR_MARKERS.head,
|
|
305
|
+
`<title ${headAttribute}>${escapeHtml(title)}</title><meta ${headAttribute} name="robots" content="noindex, nofollow">`,
|
|
306
|
+
)
|
|
307
|
+
.replace(SSR_MARKERS.state, '')
|
|
308
|
+
.replace(SSR_MARKERS.teleports, '')
|
|
309
|
+
// A broken bundle must not half-hydrate an error page.
|
|
310
|
+
.replace(/<script\b[^>]*type=["']module["'][^>]*>[\s\S]*?<\/script>/gi, '')
|
|
311
|
+
.replace(SSR_MARKERS.html, markup)
|
|
312
|
+
sendText(
|
|
313
|
+
request,
|
|
314
|
+
response,
|
|
315
|
+
statusCode,
|
|
316
|
+
template,
|
|
317
|
+
'text/html; charset=utf-8',
|
|
318
|
+
SSR_HTML_SECURITY_HEADERS,
|
|
319
|
+
)
|
|
320
|
+
} catch {
|
|
321
|
+
sendText(
|
|
322
|
+
request,
|
|
323
|
+
response,
|
|
324
|
+
statusCode,
|
|
325
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex, nofollow"><title>${escapeHtml(title)}</title></head><body>${markup}</body></html>`,
|
|
326
|
+
'text/html; charset=utf-8',
|
|
327
|
+
SSR_HTML_SECURITY_HEADERS,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- Preflight ---------------------------------------------------------
|
|
333
|
+
const assertTemplatesReady = async () => {
|
|
334
|
+
await Promise.all(
|
|
335
|
+
apps
|
|
336
|
+
.filter((app) => isAppAllowed(app.name))
|
|
337
|
+
.map(async (app) => {
|
|
338
|
+
const templatePath = resolve(production ? clientDir : root, app.template)
|
|
339
|
+
const info = await stat(templatePath)
|
|
340
|
+
if (!info.isFile()) {
|
|
341
|
+
throw new Error(`Required client template is missing: ${app.template}`)
|
|
342
|
+
}
|
|
343
|
+
}),
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const assertRuntimeReady = async () => {
|
|
348
|
+
await assertTemplatesReady()
|
|
349
|
+
if (ssrApp && isAppAllowed(ssrApp.name)) {
|
|
350
|
+
const module = await loadRenderModule()
|
|
351
|
+
if (typeof module[renderExport] !== 'function') {
|
|
352
|
+
throw new Error('The SSR render module could not be loaded.')
|
|
353
|
+
}
|
|
354
|
+
await module[readyExport]?.()
|
|
355
|
+
}
|
|
356
|
+
for (const probe of readinessProbes) await probe()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
await assertTemplatesReady()
|
|
361
|
+
if (ssrApp && isAppAllowed(ssrApp.name)) {
|
|
362
|
+
const module = await loadRenderModule()
|
|
363
|
+
if (typeof module[renderExport] !== 'function') {
|
|
364
|
+
throw new Error('The SSR render module could not be loaded.')
|
|
365
|
+
}
|
|
366
|
+
await module[readyExport]?.()
|
|
367
|
+
}
|
|
368
|
+
} catch (error) {
|
|
369
|
+
try {
|
|
370
|
+
await vite?.close()
|
|
371
|
+
} catch {
|
|
372
|
+
// Closing a half-started Vite server must not mask the preflight error.
|
|
373
|
+
}
|
|
374
|
+
logger.error(`\n[${serviceName}] runtime preflight failed; not starting.`)
|
|
375
|
+
logger.error(resolveRootCause(error))
|
|
376
|
+
process.exit(1)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- Request pipeline --------------------------------------------------
|
|
380
|
+
let shuttingDown = false
|
|
381
|
+
|
|
382
|
+
const server = createServer(async (request, response) => {
|
|
383
|
+
const startedAt = new Date().toISOString()
|
|
384
|
+
let pathname = '/'
|
|
385
|
+
let appName = defaultApp.name
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
pathname = new URL(request.url || '/', 'http://internal').pathname
|
|
389
|
+
|
|
390
|
+
if (pathname === '/healthz') {
|
|
391
|
+
return sendJson(request, response, 200, {
|
|
392
|
+
status: 'ok',
|
|
393
|
+
service: serviceName,
|
|
394
|
+
runtime: serveRole,
|
|
395
|
+
timestamp: startedAt,
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
if (pathname === '/readyz') {
|
|
399
|
+
if (shuttingDown) {
|
|
400
|
+
return sendJson(request, response, 503, {
|
|
401
|
+
status: 'error',
|
|
402
|
+
service: serviceName,
|
|
403
|
+
runtime: serveRole,
|
|
404
|
+
message: 'Server is shutting down.',
|
|
405
|
+
timestamp: startedAt,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
await assertRuntimeReady()
|
|
410
|
+
return sendJson(request, response, 200, {
|
|
411
|
+
status: 'ok',
|
|
412
|
+
service: serviceName,
|
|
413
|
+
runtime: serveRole,
|
|
414
|
+
timestamp: startedAt,
|
|
415
|
+
})
|
|
416
|
+
} catch {
|
|
417
|
+
return sendJson(request, response, 503, {
|
|
418
|
+
status: 'error',
|
|
419
|
+
service: serviceName,
|
|
420
|
+
runtime: serveRole,
|
|
421
|
+
timestamp: startedAt,
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const incomingHost = resolveSsrForwardedHost(
|
|
427
|
+
request.headers['x-forwarded-host'],
|
|
428
|
+
request.headers.host,
|
|
429
|
+
trustProxy,
|
|
430
|
+
)
|
|
431
|
+
appName = classify(incomingHost, pathname, request.headers)
|
|
432
|
+
if (appName === 'invalid' || !incomingHost) {
|
|
433
|
+
if (isHtmlNavigation(request, pathname)) {
|
|
434
|
+
return sendNavigationError(
|
|
435
|
+
request,
|
|
436
|
+
response,
|
|
437
|
+
400,
|
|
438
|
+
defaultApp.name,
|
|
439
|
+
new Error('The request Host header is invalid.'),
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
return sendJson(request, response, 400, {
|
|
443
|
+
status: 'error',
|
|
444
|
+
service: serviceName,
|
|
445
|
+
message: 'Invalid Host header.',
|
|
446
|
+
timestamp: startedAt,
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
if (!isAppAllowed(appName)) {
|
|
450
|
+
const message = `This ${serveRole} runtime does not serve ${appName} hosts.`
|
|
451
|
+
if (isHtmlNavigation(request, pathname)) {
|
|
452
|
+
return sendText(
|
|
453
|
+
request,
|
|
454
|
+
response,
|
|
455
|
+
421,
|
|
456
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex, nofollow"><title>Misdirected request</title></head><body>${renderErrorMarkup('Misdirected request', message)}</body></html>`,
|
|
457
|
+
'text/html; charset=utf-8',
|
|
458
|
+
SSR_HTML_SECURITY_HEADERS,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
return sendJson(request, response, 421, {
|
|
462
|
+
status: 'error',
|
|
463
|
+
service: serviceName,
|
|
464
|
+
runtime: serveRole,
|
|
465
|
+
message,
|
|
466
|
+
timestamp: startedAt,
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const protocol = resolveSsrForwardedProtocol(
|
|
471
|
+
request.headers['x-forwarded-proto'],
|
|
472
|
+
request.socket.encrypted ? 'https' : 'http',
|
|
473
|
+
trustProxy,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
// Consumer endpoints (SEO resources, proxies, …) run after host
|
|
477
|
+
// classification and before asset handling, mirroring the audited
|
|
478
|
+
// ERP pipeline.
|
|
479
|
+
for (const route of consumerRoutes) {
|
|
480
|
+
if (!route.match(pathname, { appName, host: incomingHost, protocol })) {
|
|
481
|
+
continue
|
|
482
|
+
}
|
|
483
|
+
await route.handle(request, response, {
|
|
484
|
+
appName,
|
|
485
|
+
host: incomingHost,
|
|
486
|
+
protocol,
|
|
487
|
+
pathname,
|
|
488
|
+
config: options.config,
|
|
489
|
+
loadRenderModule,
|
|
490
|
+
sendJson: (statusCode, payload) =>
|
|
491
|
+
sendJson(request, response, statusCode, payload),
|
|
492
|
+
sendText: (statusCode, body, contentType, headers) =>
|
|
493
|
+
sendText(request, response, statusCode, body, contentType, headers),
|
|
494
|
+
})
|
|
495
|
+
if (response.writableEnded) return
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (production && (await serveProductionAsset(pathname, request, response))) {
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
!production &&
|
|
503
|
+
(pathname.startsWith('/src/') ||
|
|
504
|
+
pathname.startsWith('/@') ||
|
|
505
|
+
pathname.includes('.') ||
|
|
506
|
+
pathname === '/__vite_ping')
|
|
507
|
+
) {
|
|
508
|
+
await runViteMiddleware(request, response)
|
|
509
|
+
if (response.writableEnded) return
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!isHtmlNavigation(request, pathname)) {
|
|
513
|
+
return sendJson(request, response, 404, {
|
|
514
|
+
status: 'error',
|
|
515
|
+
service: serviceName,
|
|
516
|
+
message: 'Resource not found.',
|
|
517
|
+
timestamp: startedAt,
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const app = apps.find((candidate) => candidate.name === appName) || defaultApp
|
|
522
|
+
|
|
523
|
+
if (app.type === 'spa') {
|
|
524
|
+
const template = await loadHtmlTemplate(app.template, request.url)
|
|
525
|
+
response.writeHead(200, {
|
|
526
|
+
'content-type': 'text/html; charset=utf-8',
|
|
527
|
+
'cache-control': 'private, no-store',
|
|
528
|
+
vary: 'Host, X-Forwarded-Host',
|
|
529
|
+
...SSR_HTML_SECURITY_HEADERS,
|
|
530
|
+
})
|
|
531
|
+
return endResponse(
|
|
532
|
+
request,
|
|
533
|
+
response,
|
|
534
|
+
template.replace(SSR_MARKERS.html, ''),
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const result = await engine.handle({
|
|
539
|
+
url: request.url || '/',
|
|
540
|
+
host: incomingHost,
|
|
541
|
+
protocol,
|
|
542
|
+
cookieHeader: request.headers.cookie,
|
|
543
|
+
})
|
|
544
|
+
response.writeHead(result.statusCode, result.headers)
|
|
545
|
+
endResponse(request, response, result.html)
|
|
546
|
+
} catch (error) {
|
|
547
|
+
vite?.ssrFixStacktrace(error)
|
|
548
|
+
logger.error(error)
|
|
549
|
+
if (response.writableEnded) return
|
|
550
|
+
if (response.headersSent) {
|
|
551
|
+
response.destroy()
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
if (isHtmlNavigation(request, pathname)) {
|
|
555
|
+
return sendNavigationError(request, response, 500, appName, error)
|
|
556
|
+
}
|
|
557
|
+
sendJson(request, response, 500, {
|
|
558
|
+
status: 'error',
|
|
559
|
+
service: serviceName,
|
|
560
|
+
timestamp: startedAt,
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// ---- Lifecycle ---------------------------------------------------------
|
|
566
|
+
const shutdown = async () => {
|
|
567
|
+
if (shuttingDown) return
|
|
568
|
+
shuttingDown = true
|
|
569
|
+
const forcedExit = setTimeout(() => process.exit(1), 10_000)
|
|
570
|
+
forcedExit.unref()
|
|
571
|
+
try {
|
|
572
|
+
await Promise.all([
|
|
573
|
+
new Promise((resolveClose, rejectClose) => {
|
|
574
|
+
server.close((error) => {
|
|
575
|
+
if (error) rejectClose(error)
|
|
576
|
+
else resolveClose()
|
|
577
|
+
})
|
|
578
|
+
}),
|
|
579
|
+
vite?.close(),
|
|
580
|
+
])
|
|
581
|
+
clearTimeout(forcedExit)
|
|
582
|
+
process.exit(0)
|
|
583
|
+
} catch (error) {
|
|
584
|
+
logger.error(`[${serviceName}] Graceful shutdown failed.`, error)
|
|
585
|
+
process.exit(1)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
process.once('SIGINT', () => void shutdown())
|
|
589
|
+
process.once('SIGTERM', () => void shutdown())
|
|
590
|
+
|
|
591
|
+
const listen = () =>
|
|
592
|
+
new Promise((resolveListen) => {
|
|
593
|
+
server.listen(port, host, () => {
|
|
594
|
+
logger.log(`${serviceName} (${serveRole}) listening on ${host}:${port}`)
|
|
595
|
+
resolveListen({ server, port, host })
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
return { server, vite, listen, shutdown, options }
|
|
600
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Server } from 'node:http'
|
|
2
|
+
import type { SsrEngineRequest } from '../engine/index'
|
|
3
|
+
|
|
4
|
+
export * from '../engine/index'
|
|
5
|
+
|
|
6
|
+
export interface SsrServerApp {
|
|
7
|
+
name: string
|
|
8
|
+
type?: 'ssr' | 'spa'
|
|
9
|
+
template: string
|
|
10
|
+
hosts?: string[]
|
|
11
|
+
default?: boolean
|
|
12
|
+
localhost?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SsrServerRoute {
|
|
16
|
+
match: (
|
|
17
|
+
pathname: string,
|
|
18
|
+
info: { appName: string; host: string; protocol: string },
|
|
19
|
+
) => boolean
|
|
20
|
+
handle: (
|
|
21
|
+
request: import('node:http').IncomingMessage,
|
|
22
|
+
response: import('node:http').ServerResponse,
|
|
23
|
+
tools: {
|
|
24
|
+
appName: string
|
|
25
|
+
host: string
|
|
26
|
+
protocol: string
|
|
27
|
+
pathname: string
|
|
28
|
+
config?: Record<string, unknown>
|
|
29
|
+
loadRenderModule: () => Promise<Record<string, unknown>>
|
|
30
|
+
sendJson: (statusCode: number, payload: unknown) => void
|
|
31
|
+
sendText: (
|
|
32
|
+
statusCode: number,
|
|
33
|
+
body: string,
|
|
34
|
+
contentType?: string,
|
|
35
|
+
headers?: Record<string, string>,
|
|
36
|
+
) => void
|
|
37
|
+
},
|
|
38
|
+
) => Promise<void> | void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SsrServerOptions {
|
|
42
|
+
root?: string
|
|
43
|
+
mode?: 'development' | 'production'
|
|
44
|
+
port?: number
|
|
45
|
+
host?: string
|
|
46
|
+
trustProxy?: boolean
|
|
47
|
+
apps?: SsrServerApp[]
|
|
48
|
+
serverEntry?: string
|
|
49
|
+
bundlePath?: string
|
|
50
|
+
clientDir?: string
|
|
51
|
+
renderExport?: string
|
|
52
|
+
readyExport?: string
|
|
53
|
+
config?: Record<string, unknown>
|
|
54
|
+
globals?: { configKey?: string; stateKey?: string }
|
|
55
|
+
headAttribute?: string
|
|
56
|
+
defaultHead?: string
|
|
57
|
+
cookieAllowlist?: string[]
|
|
58
|
+
cookieDenylist?: string[]
|
|
59
|
+
classifyRequest?: (info: {
|
|
60
|
+
hostname: string
|
|
61
|
+
pathname: string
|
|
62
|
+
headers: Record<string, string | string[] | undefined>
|
|
63
|
+
}) => string | undefined
|
|
64
|
+
serveRole?: string
|
|
65
|
+
routes?: SsrServerRoute[]
|
|
66
|
+
readiness?: Array<() => Promise<void> | void>
|
|
67
|
+
errorPage?: (info: {
|
|
68
|
+
statusCode: number
|
|
69
|
+
error: unknown
|
|
70
|
+
appName: string
|
|
71
|
+
mode: 'development' | 'production'
|
|
72
|
+
}) => string | null | undefined
|
|
73
|
+
renderTimeoutMs?: number
|
|
74
|
+
security?: { nonce?: (request: SsrEngineRequest) => string | undefined }
|
|
75
|
+
serviceName?: string
|
|
76
|
+
logger?: Pick<Console, 'log' | 'warn' | 'error'>
|
|
77
|
+
vite?: Record<string, unknown>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export declare const SSR_RUNTIME_MANIFEST: string
|
|
81
|
+
export declare const loadSsrRuntimeManifest: (
|
|
82
|
+
clientDir: string,
|
|
83
|
+
) => Promise<Record<string, unknown> | null>
|
|
84
|
+
|
|
85
|
+
export declare const createSsrServer: (options?: SsrServerOptions) => Promise<{
|
|
86
|
+
server: Server
|
|
87
|
+
vite: unknown
|
|
88
|
+
listen: () => Promise<{ server: Server; port: number; host: string }>
|
|
89
|
+
shutdown: () => Promise<void>
|
|
90
|
+
options: SsrServerOptions
|
|
91
|
+
}>
|
package/server/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createSsrServer,
|
|
3
|
+
loadSsrRuntimeManifest,
|
|
4
|
+
SSR_RUNTIME_MANIFEST,
|
|
5
|
+
} from './SsrServer.js'
|
|
6
|
+
// The engine is separable from the standalone server: custom Node servers,
|
|
7
|
+
// Express-style frameworks, tests, and serverless deployments consume it
|
|
8
|
+
// directly (also available from 'vue-ssr-lite/engine').
|
|
9
|
+
export {
|
|
10
|
+
createSsrRequestHandler,
|
|
11
|
+
SSR_HTML_SECURITY_HEADERS,
|
|
12
|
+
} from '../engine/SsrEngine.js'
|
|
13
|
+
export * from '../engine/SsrEngineHtml.js'
|
|
14
|
+
export * from '../engine/SsrEngineHostPolicy.js'
|