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
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('&', '&')
|
|
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')
|
|
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`
|