@plitzi/sdk-server 0.30.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +870 -0
  3. package/dist/.gitkeep +0 -0
  4. package/dist/adapters/jsonAdapters.js +46 -0
  5. package/dist/core/createServer.js +47 -0
  6. package/dist/core/mimeTypes.js +41 -0
  7. package/dist/core/requestHandler.js +108 -0
  8. package/dist/core/requestParser.js +34 -0
  9. package/dist/core/staticFiles.js +42 -0
  10. package/dist/core/transports.js +51 -0
  11. package/dist/helpers/buildResponseHelpers.js +43 -0
  12. package/dist/helpers/buildServerInfo.js +55 -0
  13. package/dist/helpers/cache/TtlCache.js +55 -0
  14. package/dist/helpers/cache/cacheManager.js +25 -0
  15. package/dist/helpers/cache/defaults.js +9 -0
  16. package/dist/helpers/cache/keys.js +6 -0
  17. package/dist/helpers/cache/serverCaches.js +15 -0
  18. package/dist/helpers/compress.js +17 -0
  19. package/dist/helpers/escapeJson.js +4 -0
  20. package/dist/helpers/metrics.js +36 -0
  21. package/dist/helpers/normalizePlugins.js +16 -0
  22. package/dist/helpers/runMiddlewares.js +21 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +3 -0
  25. package/dist/middlewares/auth.js +10 -0
  26. package/dist/middlewares/basicAuth.js +67 -0
  27. package/dist/middlewares/spaceDeployment.js +16 -0
  28. package/dist/modules/mcp/handler.js +34 -0
  29. package/dist/modules/mcp/server.js +120 -0
  30. package/dist/modules/rsc/handler.js +69 -0
  31. package/dist/modules/ssr/Component.js +20 -0
  32. package/dist/modules/ssr/buildBody.js +20 -0
  33. package/dist/modules/ssr/handler.js +36 -0
  34. package/dist/modules/ssr/loadPluginComponents.js +45 -0
  35. package/dist/modules/ssr/prepareRender.js +64 -0
  36. package/dist/modules/ssr/registerExternalPlugins.js +54 -0
  37. package/dist/modules/ssr/streamBody.js +54 -0
  38. package/dist/modules/ssr/template.js +10 -0
  39. package/dist/modules/ssr/views/template.ejs +71 -0
  40. package/dist/plugins/compile.js +29 -0
  41. package/dist/plugins/copy.js +13 -0
  42. package/dist/plugins/detect.js +9 -0
  43. package/dist/plugins/manager.js +251 -0
  44. package/dist/public/.gitkeep +0 -0
  45. package/dist/server.d.ts +1 -0
  46. package/dist/server.js +82 -0
  47. package/dist/src/adapters/jsonAdapters.d.ts +7 -0
  48. package/dist/src/core/createServer.d.ts +2 -0
  49. package/dist/src/core/mimeTypes.d.ts +4 -0
  50. package/dist/src/core/requestHandler.d.ts +5 -0
  51. package/dist/src/core/requestParser.d.ts +3 -0
  52. package/dist/src/core/staticFiles.d.ts +2 -0
  53. package/dist/src/core/transports.d.ts +18 -0
  54. package/dist/src/helpers/buildResponseHelpers.d.ts +10 -0
  55. package/dist/src/helpers/buildServerInfo.d.ts +2 -0
  56. package/dist/src/helpers/cache/TtlCache.d.ts +15 -0
  57. package/dist/src/helpers/cache/cacheManager.d.ts +3 -0
  58. package/dist/src/helpers/cache/defaults.d.ts +1 -0
  59. package/dist/src/helpers/cache/index.d.ts +6 -0
  60. package/dist/src/helpers/cache/keys.d.ts +7 -0
  61. package/dist/src/helpers/cache/serverCaches.d.ts +9 -0
  62. package/dist/src/helpers/compress.d.ts +3 -0
  63. package/dist/src/helpers/escapeJson.d.ts +1 -0
  64. package/dist/src/helpers/metrics.d.ts +11 -0
  65. package/dist/src/helpers/normalizePlugins.d.ts +4 -0
  66. package/dist/src/helpers/runMiddlewares.d.ts +2 -0
  67. package/dist/src/index.d.ts +4 -0
  68. package/dist/src/middlewares/auth.d.ts +2 -0
  69. package/dist/src/middlewares/basicAuth.d.ts +6 -0
  70. package/dist/src/middlewares/spaceDeployment.d.ts +2 -0
  71. package/dist/src/modules/mcp/handler.d.ts +3 -0
  72. package/dist/src/modules/mcp/server.d.ts +3 -0
  73. package/dist/src/modules/rsc/handler.d.ts +17 -0
  74. package/dist/src/modules/ssr/Component.d.ts +11 -0
  75. package/dist/src/modules/ssr/buildBody.d.ts +5 -0
  76. package/dist/src/modules/ssr/handler.d.ts +4 -0
  77. package/dist/src/modules/ssr/loadPluginComponents.d.ts +13 -0
  78. package/dist/src/modules/ssr/prepareRender.d.ts +13 -0
  79. package/dist/src/modules/ssr/registerExternalPlugins.d.ts +7 -0
  80. package/dist/src/modules/ssr/streamBody.d.ts +5 -0
  81. package/dist/src/modules/ssr/template.d.ts +6 -0
  82. package/dist/src/plugins/compile.d.ts +3 -0
  83. package/dist/src/plugins/copy.d.ts +1 -0
  84. package/dist/src/plugins/detect.d.ts +3 -0
  85. package/dist/src/plugins/manager.d.ts +40 -0
  86. package/dist/src/standalone/alias-loader.d.mts +1 -0
  87. package/dist/src/standalone/plugins/ClientInfo.d.ts +2 -0
  88. package/dist/src/standalone/plugins/ServerInfo.d.ts +2 -0
  89. package/dist/src/standalone/plugins/SharedInfo.d.ts +2 -0
  90. package/dist/src/standalone/plugins/styles.d.ts +5 -0
  91. package/dist/src/standalone/register-alias.d.mts +1 -0
  92. package/dist/src/standalone/server.d.ts +1 -0
  93. package/package.json +60 -0
package/README.md ADDED
@@ -0,0 +1,870 @@
1
+ # @plitzi/sdk-server
2
+
3
+ Server-side rendering (SSR) server for Plitzi spaces. Ships as an HTTP/2 server by default with support for HTTP/1.1 and HTTP/3.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ yarn add @plitzi/sdk-server
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { readFileSync } from 'node:fs';
15
+ import { createSSRServer } from '@plitzi/sdk-server';
16
+
17
+ const server = createSSRServer({
18
+ httpVersion: 2,
19
+ tls: {
20
+ key: readFileSync('./certs/server-key.pem'),
21
+ cert: readFileSync('./certs/server.pem')
22
+ },
23
+ adapters: {
24
+ getOfflineData,
25
+ getSpaceDeployment
26
+ }
27
+ });
28
+
29
+ server.listen(3001);
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ | Option | Type | Default | Description |
35
+ |---|---|---|---|
36
+ | `httpVersion` | `1 \| 2 \| 3` | `2` | HTTP protocol version. Falls back to the nearest available lower version. |
37
+ | `tls` | `{ key, cert, minVersion? }` | — | TLS key and certificate. Required for versions 2 and 3; optional for version 1. |
38
+ | `devMode` | `boolean` | `NODE_ENV !== 'production'` | Enables development mode: appends `?dev` to esm.sh CDN URLs for React, and activates per-request timing metrics (see [Dev metrics](#dev-metrics)). |
39
+ | `assetVersion` | `string` | — | Cache-buster appended as `?v=<assetVersion>` to all default SDK asset URLs. Compute from file mtime or package version at startup. |
40
+ | `cacheTtlMs` | `number` | `300000` | TTL in milliseconds for the SSR render cache. Set to `0` to disable. |
41
+ | `loginPath` | `string \| false` | `'/auth/login'` | Path for the built-in login endpoint. Set to `false` to disable it entirely. |
42
+ | `logoutPath` | `string \| false` | `'/auth/logout'` | Path for the built-in logout endpoint. Set to `false` to disable it entirely. |
43
+ | `templateFn` | `SSRTemplateFn` | built-in EJS template | Custom render function. Receives all template params and returns an HTML string. |
44
+ | `plugins` | `Record<string, PluginSource>` | — | Named plugin definitions. Compiled or copied on first use and cached for `pluginsTtlMs`. |
45
+ | `pluginsCacheDir` | `string` | `.sdk-plugins` | Directory where compiled plugin files are stored. |
46
+ | `pluginsTtlMs` | `number` | `604800000` | TTL in milliseconds for compiled plugins (default: 1 week). |
47
+ | `autoLoadSchemaPlugins` | `boolean` | `true` | Auto-download and cache plugins declared in the schema's `offlineData.plugins` list. Set to `false` to manage plugin loading manually. |
48
+ | `publicDir` | `string` | — | Absolute path to a directory served at the root URL level (e.g. `robots.txt`, `favicon.png`). Files are checked after the built-in public directory and before `static` prefix routes. |
49
+ | `static` | `Record<string, string>` | — | URL prefix → filesystem path mappings for static file serving. |
50
+ | `ssrOnly` | `boolean` | `false` | Omit client-side JS from the rendered page. Useful for verifying SSR HTML without hydration. |
51
+ | `streaming` | `boolean` | `false` | Stream HTML to the browser incrementally to reduce TTFB. See [Streaming](#streaming). |
52
+ | `middlewares` | `SSRMiddleware[]` | — | Array of custom middleware functions executed before the SSR renderer on every request (see [Custom middlewares](#custom-middlewares)). |
53
+ | `rsc` | `SSRRscConfig` | — | React Server Components endpoint configuration (see [RSC](#react-server-components-rsc)). |
54
+ | `adapters` | `SSRAdapters` | — | Required. Adapter callbacks for data fetching. |
55
+
56
+ ### HTTP version behaviour
57
+
58
+ | `httpVersion` | TLS required | Transport |
59
+ |---|---|---|
60
+ | `1` | No | Plain HTTP, or HTTPS when `tls` is set |
61
+ | `2` | Yes | HTTP/2 with HTTP/1.1 fallback via ALPN |
62
+ | `3` | Yes | HTTP/2 primary + HTTP/3 QUIC when available |
63
+
64
+ HTTP/3 requires Node.js ≥ 23 started with `--experimental-quic`. When unavailable the server falls back to HTTP/2 and logs a warning. An `Alt-Svc` response header is added automatically so browsers can upgrade.
65
+
66
+ ## Adapters
67
+
68
+ The `adapters` option is the integration point between the SSR server and your data layer.
69
+
70
+ ```ts
71
+ type SSRAdapters = {
72
+ getOfflineData: (spaceId: number, environment: string, revision?: number) => Promise<OfflineDataRaw | undefined>;
73
+ getSpaceDeployment: (req: SSRRequest) => Promise<SSRSpaceDeployment>;
74
+ getUser?: (req: SSRRequest) => Promise<SSRUser | undefined>;
75
+ onLogin?: (req: SSRRequest) => Promise<boolean>;
76
+ onLogout?: (req: SSRRequest) => Promise<void>;
77
+ getRscData?: (
78
+ req: SSRRequest,
79
+ spaceId: number,
80
+ environment: Environment,
81
+ revision: number,
82
+ user: SSRUser | undefined,
83
+ ids?: string[] // present on partial refresh; absent for full fetch
84
+ ) => Promise<SSRRscData>;
85
+ };
86
+ ```
87
+
88
+ - **`getOfflineData`** — returns the space snapshot (schema, plugins, styles, segments, collections) for SSR.
89
+ - **`getSpaceDeployment`** — resolves which space and environment to render for a given inbound request. Return `{ error: { code, message } }` to abort with an HTTP error. Optionally include `templateProps` to override template variables, or `pluginNames` to activate plugins for the space (see [Plugins](#plugins) and [Template props](#template-props)).
90
+ - **`getUser`** *(optional)* — resolves the authenticated user from the inbound request (e.g. via a session cookie or `Authorization` header). Called in parallel with `getOfflineData` on every cache miss. The returned user is forwarded to the SDK as `authenticated: true` and `user.details`, which controls page-level access for guest vs. registered users. Return `undefined` for unauthenticated requests.
91
+ - **`onLogin`** *(optional)* — called when `POST {loginPath}` is received. Responsible for establishing a session or issuing tokens. Return `true` if login succeeded; return `false` to respond with `401 Unauthorized`.
92
+ - **`onLogout`** *(optional)* — called when `POST {logoutPath}` is received. Responsible for invalidating any server-side user session or cache entry. The server responds with `204 No Content` after the adapter resolves.
93
+ - **`getRscData`** *(optional)* — called by the RSC endpoint (`/_rsc`) to fetch server-side data for schema elements with `runtime: 'server'`. Receives the full request, space context, and the resolved user so that authenticated operations can be performed. When `ids` is provided the adapter should return data only for those element IDs (partial refresh); omitting `ids` means a full fetch for all elements. Return `{}` when there is no server data for the current request (see [RSC](#react-server-components-rsc)).
94
+
95
+ ## JSON adapters (offline mode)
96
+
97
+ `createJsonAdapters` provides a ready-made adapter set that reads data from local JSON files, useful for offline mode, integration tests, and static deployments.
98
+
99
+ ```ts
100
+ import { createSSRServer, createJsonAdapters } from '@plitzi/sdk-server';
101
+
102
+ const server = createSSRServer({
103
+ adapters: createJsonAdapters({
104
+ offlineData: '/exports/offline.json',
105
+ deployment: { spaceId: 1, environment: 'main', revision: 0 },
106
+ user: { id: 1, username: 'admin', email: 'admin@example.com', verified: true, permissions: [], roles: [] }
107
+ })
108
+ });
109
+
110
+ server.listen(3001);
111
+ ```
112
+
113
+ ### `JsonAdaptersConfig`
114
+
115
+ | Option | Type | Description |
116
+ |---|---|---|
117
+ | `offlineData` | `string` | Path to a single JSON file used for every request. |
118
+ | `offlineData` | `(spaceId, environment, revision?) => string` | Function returning the path for the requested space. |
119
+ | `deployment` | `string` | Path to a JSON file containing an `SSRSpaceDeployment` object. |
120
+ | `deployment` | `SSRSpaceDeployment` | Inline deployment object used for every request. |
121
+ | `deployment` | `Record<hostname, SSRSpaceDeployment>` | Per-hostname map. Use `'*'` as a catch-all. |
122
+ | `user` | `SSRUser` | Fixed user returned for every request. Useful for testing authenticated flows. |
123
+ | `user` | `(req) => SSRUser \| undefined \| Promise<SSRUser \| undefined>` | Function for dynamic user resolution per request. |
124
+
125
+ ## Static files
126
+
127
+ Map URL prefixes to local directories:
128
+
129
+ ```ts
130
+ createSSRServer({
131
+ static: {
132
+ '/sdk-assets': './node_modules/@plitzi/plitzi-sdk/dist',
133
+ '/builder-assets': './node_modules/@plitzi/plitzi-builder/dist'
134
+ },
135
+ adapters: { ... }
136
+ });
137
+ ```
138
+
139
+ Static responses include `ETag`, `Last-Modified`, and `Cache-Control` headers. Subsequent requests with `If-None-Match` receive `304 Not Modified` when the file has not changed. JS, CSS, and font files are served with `Cache-Control: immutable`; all other assets use a 1-hour max-age.
140
+
141
+ ### Public directory
142
+
143
+ Any file placed in the package's `public/` directory is served automatically at its root path (e.g. `public/favicon.png` → `/favicon.png`).
144
+
145
+ Use `publicDir` to serve your own root-level files (e.g. `robots.txt`, `sitemap.xml`) without prefixes:
146
+
147
+ ```ts
148
+ createSSRServer({
149
+ publicDir: path.resolve(process.cwd(), 'src/services/ssr/public'),
150
+ adapters: { ... }
151
+ });
152
+ ```
153
+
154
+ The lookup order for a request is: built-in `public/` → `publicDir` → `static` prefix routes → SSR renderer.
155
+
156
+ `/.well-known/` paths follow the same lookup order: served from `publicDir` if a matching file exists, otherwise `404 Not Found`. They are never handled by the SSR renderer.
157
+
158
+ ## Compression
159
+
160
+ Responses are compressed automatically based on the `Accept-Encoding` request header. The server prefers Brotli (`br`) over gzip, and skips compression for payloads under 1 KB.
161
+
162
+ | Encoding | Algorithm | Settings |
163
+ |---|---|---|
164
+ | `br` | Brotli | Quality 4 |
165
+ | `gzip` | Gzip | Level 6 |
166
+
167
+ `Content-Encoding` and `Vary: Accept-Encoding` are set on all compressed responses.
168
+
169
+ ## Render cache
170
+
171
+ SSR output is cached in-memory per `(spaceId, environment, revision, hostname, path, search)`. The cache uses a 5-minute TTL by default. The `main` environment is always excluded from caching — it is the development environment and its schema changes frequently.
172
+
173
+ ```ts
174
+ createSSRServer({
175
+ cacheTtlMs: 60_000, // 1 minute
176
+ adapters: { ... }
177
+ });
178
+
179
+ // Disable caching entirely
180
+ createSSRServer({
181
+ cacheTtlMs: 0,
182
+ adapters: { ... }
183
+ });
184
+ ```
185
+
186
+ Schema data (`getOfflineData`) is also cached under the same TTL, keyed by `(spaceId, environment, revision)`. This avoids repeated adapter calls on consecutive HTML cache misses for the same space version.
187
+
188
+ Responses include an `X-Cache: HIT` or `X-Cache: MISS` header for observability. The cache is cleared and its sweep timer is stopped when `server.close()` is called.
189
+
190
+ ### Cache manager
191
+
192
+ `server.cache` exposes programmatic cache control, useful when content changes and you need to invalidate entries without restarting the server.
193
+
194
+ ```ts
195
+ const server = createSSRServer({ cacheTtlMs: 300_000, adapters });
196
+
197
+ // Invalidate all entries for a specific space
198
+ server.cache?.invalidate({ spaceId: 42 });
199
+
200
+ // Invalidate a specific hostname
201
+ server.cache?.invalidate({ hostname: 'app.example.com' });
202
+
203
+ // Invalidate by space + environment
204
+ server.cache?.invalidate({ spaceId: 42, environment: 'staging' });
205
+
206
+ // Clear everything
207
+ server.cache?.clear();
208
+
209
+ // Inspect size
210
+ console.log(server.cache?.size);
211
+ ```
212
+
213
+ `server.cache` is `null` when caching is disabled (`cacheTtlMs: 0`).
214
+
215
+ #### `CacheFilter`
216
+
217
+ | Field | Type | Description |
218
+ |---|---|---|
219
+ | `spaceId` | `number` | Match entries for this space. |
220
+ | `environment` | `string` | Match entries for this environment. |
221
+ | `hostname` | `string` | Match entries for this hostname. |
222
+
223
+ All fields are optional and combined with AND logic. Calling `invalidate()` with no filter (or an empty object) clears the entire cache.
224
+
225
+ ## Plugins
226
+
227
+ Plugins are React component bundles that extend the Plitzi schema renderer. They are defined globally at server config level, compiled or copied on first use, and cached on disk for one week. The `getSpaceDeployment` adapter controls which plugins each space gets access to via `pluginNames`.
228
+
229
+ ```ts
230
+ import type { SSRSpaceDeployment } from '@plitzi/sdk-server';
231
+
232
+ const server = createSSRServer({
233
+ plugins: {
234
+ // From a source file — compiled to ESM with esbuild
235
+ 'my-chart': {
236
+ js: '/abs/path/to/MyChart.tsx',
237
+ css: '/abs/path/to/MyChart.css', // filesystem path — copied to .sdk-plugins
238
+ version: '1.2.0'
239
+ },
240
+ // Pre-compiled local file — copied as-is (version defaults to '1.0.0')
241
+ 'data-table': {
242
+ js: '/abs/path/to/table.js',
243
+ action: 'copy'
244
+ },
245
+ // Pre-compiled from a CDN — fetched and cached
246
+ 'video-player': {
247
+ js: 'https://cdn.example.com/player.js',
248
+ css: 'https://cdn.example.com/player.css',
249
+ version: '3.0.1'
250
+ },
251
+ // CSS already served via `static` — referenced directly, not copied
252
+ 'plitziBuilder': {
253
+ js: '/abs/path/to/builder/index.ts',
254
+ css: '/builder-assets/plitzi-builder.css', // web URL — injected as-is
255
+ action: 'compile',
256
+ version: '2.1.0'
257
+ }
258
+ },
259
+ adapters: { ... }
260
+ });
261
+
262
+ // Invalidate one specific version
263
+ await server.plugins.invalidate('my-chart', '1.2.0');
264
+
265
+ // Invalidate all versions of a plugin (my-chart, my-chart@1.2.0, …)
266
+ await server.plugins.invalidate('my-chart');
267
+
268
+ // Invalidate everything
269
+ await server.plugins.invalidate();
270
+ ```
271
+
272
+ The adapter controls which plugins each space gets via `pluginNames` (for pre-registered plugins) and `pluginSources` (for plugins defined inline — downloaded and cached automatically):
273
+
274
+ ```ts
275
+ const getSpaceDeployment = async (req): Promise<SSRSpaceDeployment> => {
276
+ const space = await fetchSpace(req.hostname);
277
+
278
+ return {
279
+ spaceId: space.id,
280
+ environment: space.environment,
281
+ revision: space.revision,
282
+ // Activate pre-registered plugins by name
283
+ pluginNames: space.hasPremiumPlugins ? ['my-chart', 'data-table'] : [],
284
+ // Inline plugin definitions — auto-downloaded and compiled on first use
285
+ pluginSources: space.customPlugins
286
+ ? {
287
+ 'custom-widget': {
288
+ js: `https://cdn.example.com/widgets/${space.id}/index.js`,
289
+ css: `https://cdn.example.com/widgets/${space.id}/index.css`,
290
+ version: space.customPluginVersion
291
+ }
292
+ }
293
+ : undefined
294
+ };
295
+ };
296
+ ```
297
+
298
+ Plugins listed in `pluginSources` are registered into the plugin manager on-the-fly using `ensure()`, which only triggers a rebuild if the plugin is new or its `version` has changed. Both `pluginNames` and `pluginSources` entries are resolved in parallel before the HTML is rendered.
299
+
300
+ ### Plugin sources
301
+
302
+ | Shape | Action | When to use |
303
+ |---|---|---|
304
+ | `{ js: 'file.tsx' }` | Auto-detected → compile | TypeScript/JSX source files |
305
+ | `{ js: 'file.js' }` | Auto-detected → copy | Pre-compiled local JS |
306
+ | `{ js: 'https://...' }` | Auto-detected → copy (fetch) | CDN or external URLs |
307
+ | `{ js: '...', action: 'compile' \| 'copy' }` | Explicit | Override auto-detection |
308
+
309
+ > **Client vs. SSR-only plugins**: A plugin registered as `{ component: MyFC }` (component reference) is rendered only on the server — no JS bundle is emitted for the browser. Elements using this plugin will disappear after client-side hydration. To keep the component alive in the browser, use a source file with `action: 'compile'` so esbuild produces a browser-loadable ESM bundle, and list the plugin in `pluginNames` so it is injected into the page template.
310
+
311
+ ### Plugin versioning
312
+
313
+ Every plugin registered through `createSSRServer` (or via `server.plugins.register`) is versioned. If you omit `version`, it defaults to `'1.0.0'`:
314
+
315
+ ```ts
316
+ plugins: {
317
+ // explicit version
318
+ 'my-chart': {
319
+ js: 'https://cdn.example.com/chart@1.2.0/index.js',
320
+ css: 'https://cdn.example.com/chart@1.2.0/index.css',
321
+ version: '1.2.0'
322
+ },
323
+ // version defaults to '1.0.0'
324
+ 'data-table': {
325
+ js: '/abs/path/to/table.js'
326
+ }
327
+ }
328
+ ```
329
+
330
+ Versioned plugins:
331
+ - **Never expire by TTL** — considered immutable; disk cache is kept indefinitely.
332
+ - **Version change triggers rebuild** — if the on-disk `meta.json` has a different version, the old cache is discarded and the plugin is recompiled/re-fetched automatically on the next request.
333
+ - **Bump `version`** whenever you deploy a new build to guarantee all nodes pick up the update.
334
+
335
+ Plugins coming from `pluginSources` in the deployment follow the same rules — version is required there to avoid stale caches across deployments.
336
+
337
+ ### Action auto-detection
338
+
339
+ When `action` is not set, the server infers it from the `js` value:
340
+
341
+ - HTTP/HTTPS URL → `copy` (fetched over the network)
342
+ - `.tsx`, `.ts`, `.jsx` extension → `compile` (esbuild, ESM output, React/SDK externalized)
343
+ - `.js` extension → `copy`
344
+
345
+ ### Plugin serving
346
+
347
+ Compiled and copied plugin files are served under `/sdk-plugins/{name}/`:
348
+
349
+ ```
350
+ /sdk-plugins/my-chart/index.js
351
+ /sdk-plugins/my-chart/index.css (if CSS was generated or provided)
352
+ ```
353
+
354
+ Plugin responses are compressed with Brotli or gzip like all other responses.
355
+
356
+ ### TTL and invalidation
357
+
358
+ Plugins are compiled once and cached for `pluginsTtlMs` (default: 1 week). The TTL is tracked via a `meta.json` file written alongside each plugin's compiled output. On the next request after expiry the plugin is automatically recompiled.
359
+
360
+ To force recompilation without waiting for TTL expiry, call `server.plugins.invalidate(name?)`.
361
+
362
+ ### Dynamic plugin registration
363
+
364
+ Plugins can be registered after the server has started without restarting it. This is useful when plugins are loaded from a database or activated at runtime:
365
+
366
+ ```ts
367
+ const server = createSSRServer({ adapters });
368
+ server.listen(3001);
369
+
370
+ // Later — register a new plugin dynamically
371
+ server.plugins.register('my-chart', {
372
+ js: '/abs/path/to/MyChart.tsx'
373
+ });
374
+
375
+ // Or re-register an existing plugin to update its source
376
+ server.plugins.register('my-chart', {
377
+ js: 'https://cdn.example.com/chart-v2.js',
378
+ action: 'copy'
379
+ });
380
+ ```
381
+
382
+ `register` clears any in-memory cache for that plugin name, so the next request triggers a fresh compile/copy. Previously compiled disk files are reused if they are within their TTL; call `server.plugins.invalidate(name)` beforehand to force a full rebuild.
383
+
384
+ ## React Server Components (RSC)
385
+
386
+ The SSR server includes a lightweight RSC endpoint that delivers server-side data to schema elements with `runtime: 'server'`. This is not the React RSC wire protocol — it uses a simple JSON transport that any element can consume via `useRscData()`.
387
+
388
+ ### Schema setup
389
+
390
+ Enable RSC at the top level of your schema:
391
+
392
+ ```json
393
+ {
394
+ "rsc": { "enabled": true },
395
+ "items": [
396
+ {
397
+ "id": "my-element",
398
+ "type": "myPlugin",
399
+ "runtime": "server",
400
+ "loadStrategy": "eager"
401
+ }
402
+ ]
403
+ }
404
+ ```
405
+
406
+ **`runtime`** — controls where an element renders:
407
+
408
+ | Value | Behaviour |
409
+ |---|---|
410
+ | `'server'` | Rendered during SSR; filtered out on the client until RSC data arrives. |
411
+ | `'client'` | Skipped during SSR; rendered only in the browser after hydration. |
412
+ | `'shared'` | Rendered on both server and client (default behaviour). |
413
+
414
+ **`loadStrategy`** — controls when the browser requests the element's data (schema field; browser runtime behaviour is handled by the SDK):
415
+
416
+ | Value | Behaviour |
417
+ |---|---|
418
+ | `'eager'` | Data fetched immediately on mount. |
419
+ | `'lazy'` | Data fetched after the initial render completes. |
420
+ | `'visible'` | Data fetched when the element enters the viewport. |
421
+
422
+ ### `getRscData` adapter
423
+
424
+ Implement `getRscData` in your adapters to serve data from the `/_rsc` endpoint. `serverData` is a map keyed by schema element ID — each element reads only its own slice, so multiple `runtime:'server'` elements in the same schema can have independent data.
425
+
426
+ When `ids` is provided the client is performing a **partial refresh** — only return data for those element IDs. When `ids` is absent, return data for all elements (full fetch):
427
+
428
+ ```ts
429
+ import type { SSRAdapters, SSRRscData, SSRUser } from '@plitzi/sdk-server';
430
+
431
+ const getRscData = async (
432
+ req: SSRRequest,
433
+ spaceId: number,
434
+ environment: string,
435
+ revision: number,
436
+ user: SSRUser | undefined,
437
+ ids?: string[]
438
+ ): Promise<SSRRscData> => {
439
+ // Only serve data when the schema has RSC enabled
440
+ const offlineData = await getOfflineData(spaceId, environment, revision);
441
+ if (!offlineData?.schema.rsc?.enabled) {
442
+ return {};
443
+ }
444
+
445
+ // Authenticated operations are safe here — user is already resolved
446
+ const profile = user ? await db.profiles.find(user.id) : null;
447
+
448
+ const all: Record<string, unknown> = {
449
+ // keys are the schema element IDs that have runtime:'server'
450
+ 'my-profile-card': {
451
+ authenticated: !!user,
452
+ userId: user?.id ?? null,
453
+ profile
454
+ },
455
+ 'my-stats-widget': {
456
+ totalOrders: await db.orders.countByUser(user?.id)
457
+ }
458
+ };
459
+
460
+ // Filter to requested IDs on partial refresh
461
+ const serverData = ids?.length
462
+ ? Object.fromEntries(ids.filter(id => id in all).map(id => [id, all[id]]))
463
+ : all;
464
+
465
+ return { serverData };
466
+ };
467
+
468
+ const adapters: SSRAdapters = { getOfflineData, getSpaceDeployment, getUser, getRscData };
469
+ ```
470
+
471
+ ### Partial refresh
472
+
473
+ The `/_rsc` endpoint accepts an optional `?ids=elem1,elem2` query string. When present, the server calls `getRscData` with only those IDs and returns a partial payload. On the client the partial data is **merged** into the existing `serverData` state rather than replacing it, so unrelated elements are unaffected:
474
+
475
+ ```ts
476
+ // From a plugin — refresh only this element's data
477
+ const [{ refresh }] = useRscData();
478
+ await refresh(['my-stats-widget']);
479
+
480
+ // Refresh multiple elements at once
481
+ await refresh(['my-profile-card', 'my-stats-widget']);
482
+
483
+ // Full refresh (replaces all serverData)
484
+ await refresh();
485
+ ```
486
+
487
+ ### `SSRRscData`
488
+
489
+ ```ts
490
+ type SSRRscData = {
491
+ /** Per-element server data keyed by schema element ID. */
492
+ serverData?: Record<string, unknown>;
493
+ };
494
+ ```
495
+
496
+ Return `{}` (empty object) when there is no server data for the current request. Each key must match the `id` of a schema element with `runtime: 'server'`.
497
+
498
+ ### `/_rsc` endpoint
499
+
500
+ The server automatically registers `GET /_rsc` when `adapters.getRscData` is provided. The endpoint:
501
+
502
+ 1. Reads `spaceId`, `environment`, and `revision` from the resolved `spaceDeployment` context.
503
+ 2. Reads the authenticated user from `ctx.user`.
504
+ 3. Reads optional `?ids=elem1,elem2` for partial refresh.
505
+ 4. Calls `adapters.getRscData(req, spaceId, environment, revision, user, ids?)`.
506
+ 5. Returns a JSON payload:
507
+
508
+ ```json
509
+ {
510
+ "version": 1,
511
+ "transport": "json",
512
+ "spaceId": 42,
513
+ "environment": "main",
514
+ "revision": 7,
515
+ "serverData": { ... }
516
+ }
517
+ ```
518
+
519
+ **Cache-Control**: `no-store` for the `main` environment; `private, max-age=30` for other environments when `rsc.cacheTtlMs > 0`. Responses also include `X-Cache: HIT` or `X-Cache: MISS` for observability.
520
+
521
+ The endpoint returns `400` if `spaceId` is missing or invalid, `500` if `getRscData` throws, and `501` if the adapter is not configured.
522
+
523
+ ### RSC configuration
524
+
525
+ ```ts
526
+ createSSRServer({
527
+ rsc: {
528
+ enabled: true, // default: true when getRscData is provided
529
+ path: '/_rsc' // default: '/_rsc'
530
+ },
531
+ adapters: { getRscData, ... }
532
+ });
533
+ ```
534
+
535
+ | Field | Type | Default | Description |
536
+ |---|---|---|---|
537
+ | `enabled` | `boolean` | `true` (when adapter provided) | Activate or deactivate the RSC endpoint. |
538
+ | `path` | `string` | `'/_rsc'` | URL path for the RSC endpoint. |
539
+ | `cacheTtlMs` | `number` | `30000` | TTL in milliseconds for the RSC response cache. Set to `0` to disable RSC caching. Ignored for the `main` environment. |
540
+
541
+ ### Consuming RSC data in plugins
542
+
543
+ Schema elements with `runtime: 'server'` can read the server payload via the SDK's `useRscData` hook:
544
+
545
+ ```tsx
546
+ import { useRscData } from '@plitzi/sdk-elements';
547
+
548
+ const MyPlugin = () => {
549
+ const [{ loaded, serverData, refresh }] = useRscData();
550
+
551
+ if (!loaded) return <p>Loading...</p>;
552
+ if (!serverData) return null;
553
+
554
+ return (
555
+ <>
556
+ <pre>{JSON.stringify(serverData, null, 2)}</pre>
557
+ <button onClick={() => refresh()}>Refresh all</button>
558
+ <button onClick={() => refresh(['my-stats-widget'])}>Refresh this</button>
559
+ </>
560
+ );
561
+ };
562
+ ```
563
+
564
+ The hook is backed by `RscProvider`, which fetches `/_rsc` once on mount and updates on navigation. The `loaded` boolean distinguishes "still fetching" from "fetched but returned no data". `refresh(ids?)` re-fetches server data: when `ids` is provided only those elements are refreshed and their results are merged; omitting `ids` performs a full replace.
565
+
566
+ ## User authentication
567
+
568
+ The server supports per-request user resolution to enable page-level access control within a space. Schemas can have pages restricted to registered users; the SDK uses `authenticated` and `user.details` to decide which pages to render.
569
+
570
+ ### `getUser` adapter
571
+
572
+ Implement `getUser` to resolve the current visitor from the request. The server calls it on every render (cache misses only) in parallel with `getOfflineData`, so there is no sequential overhead.
573
+
574
+ ```ts
575
+ import type { SSRUser } from '@plitzi/sdk-server';
576
+
577
+ const getUser = async (req: SSRRequest): Promise<SSRUser | undefined> => {
578
+ const token = req.headers['authorization']?.replace('Bearer ', '')
579
+ ?? parseCookies(req.headers['cookie'] ?? '')['my_session'];
580
+
581
+ if (!token) {
582
+ return undefined;
583
+ }
584
+
585
+ const user = await db.users.findByToken(token);
586
+ if (!user || user.tokenExpiredAt < Date.now() / 1000) {
587
+ return undefined;
588
+ }
589
+
590
+ return {
591
+ id: user.id,
592
+ username: user.username,
593
+ email: user.email,
594
+ verified: user.isActive,
595
+ permissions: user.permissions,
596
+ roles: user.roles
597
+ };
598
+ };
599
+
600
+ createSSRServer({ adapters: { getOfflineData, getSpaceDeployment, getUser } });
601
+ ```
602
+
603
+ ### `SSRUser`
604
+
605
+ | Field | Type | Description |
606
+ |---|---|---|
607
+ | `token` | `string` | Opaque token or JWT from the auth provider. |
608
+ | `id` | `number` | Unique user identifier. |
609
+ | `username` | `string` | Display name. |
610
+ | `email` | `string` | Email address. |
611
+ | `verified` | `boolean` | Whether the account is active/verified. |
612
+ | `permissions` | `string[]` | Permission keys for fine-grained access control. |
613
+ | `roles` | `string[]` | Role names. |
614
+
615
+ ### Login endpoint
616
+
617
+ The server exposes a built-in `POST /auth/login` endpoint. When hit, it calls `adapters.onLogin(req)` and responds with `200 OK` on success or `401 Unauthorized` when the adapter returns `false`:
618
+
619
+ ```ts
620
+ const onLogin = async (req: SSRRequest): Promise<boolean> => {
621
+ const { username, password } = JSON.parse(req.body ?? '{}');
622
+ const user = await db.users.authenticate(username, password);
623
+ if (!user) return false;
624
+
625
+ // Set session cookie or issue token here
626
+ return true;
627
+ };
628
+
629
+ createSSRServer({ adapters: { getOfflineData, getSpaceDeployment, onLogin } });
630
+ ```
631
+
632
+ The path is configurable via `loginPath`. Set it to `false` to disable the endpoint entirely:
633
+
634
+ ```ts
635
+ createSSRServer({
636
+ loginPath: '/api/login', // custom path
637
+ // loginPath: false, // disable
638
+ adapters: { ... }
639
+ });
640
+ ```
641
+
642
+ ### Logout endpoint
643
+
644
+ The server exposes a built-in `POST /auth/logout` endpoint. When hit, it calls `adapters.onLogout(req)` and responds with `204 No Content`. Implement `onLogout` to invalidate the session or cached user entry:
645
+
646
+ ```ts
647
+ const onLogout = async (req: SSRRequest): Promise<void> => {
648
+ const token = parseCookies(req.headers['cookie'] ?? '')['my_session'];
649
+ if (token) {
650
+ await cache.delete(`user-${token}`);
651
+ }
652
+ };
653
+
654
+ createSSRServer({ adapters: { getOfflineData, getSpaceDeployment, getUser, onLogout } });
655
+ ```
656
+
657
+ The path is configurable via `logoutPath`. Set it to `false` to disable the endpoint entirely:
658
+
659
+ ```ts
660
+ createSSRServer({
661
+ logoutPath: '/api/logout', // custom path
662
+ // logoutPath: false, // disable
663
+ adapters: { ... }
664
+ });
665
+ ```
666
+
667
+ ## Basic auth
668
+
669
+ Per-space HTTP Basic authentication is handled automatically via `ctx.spaceDeployment.credential`. Set `credential.provider = 'ssr'` and `credential.data = { type: 'basic', user, pass }` in your `getSpaceDeployment` adapter. Successful authentications are cached in-memory for 5 minutes.
670
+
671
+ Credential comparison uses `crypto.timingSafeEqual` to prevent timing attacks. Invalid credentials always receive a `WWW-Authenticate` challenge and `401 Unauthorized`.
672
+
673
+ ## Custom middlewares
674
+
675
+ Register request-scoped middleware to run before the SSR renderer. Middlewares execute in the order they are declared and can short-circuit by not calling `next()`.
676
+
677
+ ```ts
678
+ import type { SSRMiddleware } from '@plitzi/sdk-server';
679
+
680
+ const corsMiddleware: SSRMiddleware = (req, res, next) => {
681
+ res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
682
+ return next();
683
+ };
684
+
685
+ const rateLimitMiddleware: SSRMiddleware = async (req, res, next) => {
686
+ const allowed = await rateLimiter.check(req.hostname);
687
+ if (!allowed) {
688
+ res.setStatus(429);
689
+ res.send(JSON.stringify({ error: 'Too Many Requests' }));
690
+ return;
691
+ }
692
+ return next();
693
+ };
694
+
695
+ createSSRServer({
696
+ middlewares: [corsMiddleware, rateLimitMiddleware],
697
+ adapters: { ... }
698
+ });
699
+ ```
700
+
701
+ Middlewares run after the built-in auth checks (Basic auth, `spaceDeployment` resolution, `getUser`) and before RSC and the SSR renderer.
702
+
703
+ ## SSR-only mode
704
+
705
+ Set `ssrOnly: true` to serve raw server-rendered HTML without any client-side scripts. Useful for inspecting SSR output or building purely static pages:
706
+
707
+ ```ts
708
+ createSSRServer({
709
+ ssrOnly: true,
710
+ adapters: { ... }
711
+ });
712
+ ```
713
+
714
+ The `<script>` block that bootstraps the SDK client is omitted from the response. Images, styles, and static assets are still served normally.
715
+
716
+ ## Asset versioning
717
+
718
+ Append a cache-buster to all default SDK asset URLs (JS, CSS, React CDN imports) to force browsers to re-fetch after a deployment:
719
+
720
+ ```ts
721
+ import { statSync } from 'node:fs';
722
+
723
+ const assetVersion = String(statSync('./node_modules/@plitzi/plitzi-sdk/dist/plitzi-sdk.js').mtimeMs | 0);
724
+
725
+ createSSRServer({
726
+ assetVersion,
727
+ adapters: { ... }
728
+ });
729
+ ```
730
+
731
+ URLs become `/sdk-assets/plitzi-sdk.js?v=<assetVersion>`. This is separate from plugin versioning — it only affects the built-in SDK asset paths, not plugin URLs.
732
+
733
+ ## Custom template
734
+
735
+ By default the server uses its built-in EJS template. You can replace it with any function that receives the template params and returns an HTML string:
736
+
737
+ ```ts
738
+ import type { SSRTemplateFn } from '@plitzi/sdk-server';
739
+
740
+ const templateFn: SSRTemplateFn = ({ html, offlineData, jsPath, cssPath, plugins, react, reactDom, reactDomClient, reactJsx }) => `
741
+ <!doctype html>
742
+ <html lang="en">
743
+ <head>
744
+ <script type="importmap">
745
+ { "imports": { "react": "${react}", "react-dom": "${reactDom}", "@plitzi/plitzi-sdk": "${jsPath}" } }
746
+ </script>
747
+ <link href="${cssPath}" rel="stylesheet" />
748
+ ${(plugins ?? []).filter(p => p.css).map(p => `<link href="${p.css}" rel="stylesheet" />`).join('\n')}
749
+ </head>
750
+ <body>
751
+ <div id="plitzi">${html}</div>
752
+ </body>
753
+ </html>
754
+ `;
755
+
756
+ createSSRServer({ templateFn, adapters: { ... } });
757
+ ```
758
+
759
+ The function is called once per render (cache misses only). The built-in `template.ejs` is used as fallback when `templateFn` is not set.
760
+
761
+ **Streaming compatibility**: when `streaming: true` the server calls `templateFn` with a sentinel placeholder (`<!--SSR_CONTENT-->`) in place of the React HTML, splits the output at that marker, and streams head and tail separately. Existing templates that interpolate `html` as-is are compatible without any changes.
762
+
763
+ ## Template props
764
+
765
+ Override or extend template variables per space by returning `templateProps` from `getSpaceDeployment`. Values are merged over the server defaults, with `html` and `offlineData` always computed by the server.
766
+
767
+ ```ts
768
+ return {
769
+ spaceId: space.id,
770
+ templateProps: {
771
+ title: space.name,
772
+ builderJsPath: '/builder-assets/plitzi-builder.js',
773
+ builderCssPath: '/builder-assets/plitzi-builder.css'
774
+ }
775
+ };
776
+ ```
777
+
778
+ ### `SSRTemplateProps`
779
+
780
+ | Property | Type | Description |
781
+ |---|---|---|
782
+ | `title` | `string` | Page `<title>`. Defaults to `'Plitzi App'`. |
783
+ | `jsPath` | `string` | URL for the SDK JS module. Defaults to `/sdk-assets/plitzi-sdk.js`. |
784
+ | `cssPath` | `string` | URL for the SDK stylesheet. Defaults to `/sdk-assets/plitzi-sdk.css`. |
785
+ | `builderJsPath` | `string` | URL for the builder JS module. Omitted by default. |
786
+ | `builderCssPath` | `string` | URL for the builder stylesheet. Omitted by default. |
787
+ | `plugins` | `PluginEntry[]` | Plugin entries to inject. Normally set automatically via `pluginNames`. |
788
+ | `react` | `string` | React ESM URL. Defaults to `esm.sh/react@19`. |
789
+ | `reactDom` | `string` | ReactDOM ESM URL. |
790
+ | `reactDomClient` | `string` | ReactDOM client ESM URL. |
791
+ | `reactJsx` | `string` | React JSX runtime ESM URL. |
792
+ | `ssrOnly` | `boolean` | When `true`, the client-side `<script>` block is omitted. |
793
+
794
+ ## Streaming
795
+
796
+ Enable streaming to reduce TTFB by sending the `<head>` section to the browser before React finishes rendering:
797
+
798
+ ```ts
799
+ createSSRServer({
800
+ streaming: true,
801
+ adapters: { ... }
802
+ });
803
+ ```
804
+
805
+ How it works:
806
+
807
+ 1. All async data (`getOfflineData`, `getRscData`, plugins) is fetched and prepared in parallel as usual.
808
+ 2. The page template is called with a sentinel placeholder in place of React HTML.
809
+ 3. The `<head>` section — including `<script>` and `<link>` tags — is flushed immediately. The browser starts loading JS and CSS while React is still rendering.
810
+ 4. React renders via `renderToPipeableStream` and streams its HTML chunks as they are produced.
811
+ 5. The closing tags are flushed when React finishes.
812
+
813
+ **Cache hits** are unaffected — cached HTML is sent as a single compressed response as usual, since the full string is already available.
814
+
815
+ **Compression**: streaming responses use chunked transfer encoding and skip Brotli/gzip compression. A `Content-Length` header cannot be set before the body is complete, so compression is intentionally bypassed for streaming responses.
816
+
817
+ ## Dev metrics
818
+
819
+ When `devMode: true`, per-phase timing is instrumented on every render and reported in two ways:
820
+
821
+ - A `Server-Timing` header is set on the response, visible in the browser's DevTools under **Network → Timing**.
822
+ - A one-line summary is logged to stdout:
823
+
824
+ ```
825
+ [SSR] GET / — schema=1ms rsc=0ms extPlugins=0ms plugins=0ms template=2ms react=16ms | total=19ms
826
+ ```
827
+
828
+ | Phase | Description |
829
+ |---|---|
830
+ | `schema` | `getOfflineData` adapter call (or cache hit — skipped from log). |
831
+ | `rsc` | `getRscData` + `getUser` resolution via `buildServerInfo`. |
832
+ | `extPlugins` | Auto-loading plugins declared in the schema's `offlineData.plugins` list. |
833
+ | `plugins` | Dynamic import and component loading for all active plugins. |
834
+ | `template` | `templateFn` call — HTML string assembly. |
835
+ | `react` | `renderToString` duration (buffered mode) or time until `onShellReady` (streaming). |
836
+ | `total` | Wall-clock time from request entry to response headers flushed. |
837
+
838
+ In production (`devMode: false`) timing instrumentation is skipped entirely — no `Server-Timing` header, no console output.
839
+
840
+ ## Exported types
841
+
842
+ ```ts
843
+ import type {
844
+ SSRAdapters,
845
+ SSRServerConfig,
846
+ SSRRequest,
847
+ SSRResponseHelpers,
848
+ SSRMiddleware,
849
+ SSRMiddlewareNext,
850
+ SSRContext,
851
+ SSRSpaceDeployment,
852
+ SSRTemplateProps,
853
+ SSRTemplateFn,
854
+ SSRCredential,
855
+ SSRUser,
856
+ SSRHeaders,
857
+ SSRRscData,
858
+ SSRRscConfig,
859
+ SSRServer,
860
+ PluginSource,
861
+ PluginSourceFile,
862
+ PluginSourceComponent,
863
+ PluginAction,
864
+ PluginEntry,
865
+ PluginRegistry,
866
+ CacheFilter,
867
+ CacheManager,
868
+ JsonAdaptersConfig
869
+ } from '@plitzi/sdk-server';
870
+ ```