@vertz/ui-server 0.2.15 → 0.2.16

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.
@@ -1,3 +1,30 @@
1
+ import { AccessSet } from "@vertz/ui/auth";
2
+ interface SessionData {
3
+ user: {
4
+ id: string;
5
+ email: string;
6
+ role: string;
7
+ [key: string]: unknown;
8
+ };
9
+ /** Unix timestamp in milliseconds (JWT exp * 1000). */
10
+ expiresAt: number;
11
+ }
12
+ /** Resolved session data for SSR injection. */
13
+ interface SSRSessionInfo {
14
+ session: SessionData;
15
+ /**
16
+ * Access set from JWT acl claim.
17
+ * - Present (object): inline access set (no overflow)
18
+ * - null: access control is configured but the set overflowed the JWT
19
+ * - undefined: access control is not configured
20
+ */
21
+ accessSet?: AccessSet | null;
22
+ }
23
+ /**
24
+ * Callback that extracts session data from a request.
25
+ * Returns null when no valid session exists (expired, missing, or invalid cookie).
26
+ */
27
+ type SessionResolver = (request: Request) => Promise<SSRSessionInfo | null>;
1
28
  interface BunDevServerOptions {
2
29
  /** SSR entry module (e.g., './src/app.tsx') */
3
30
  entry: string;
@@ -31,6 +58,12 @@ interface BunDevServerOptions {
31
58
  editor?: string;
32
59
  /** Extra HTML tags to inject into the <head> (e.g., font preloads, meta tags). */
33
60
  headTags?: string;
61
+ /**
62
+ * Resolves session data from request cookies for SSR injection.
63
+ * When provided, SSR HTML includes `window.__VERTZ_SESSION__` and
64
+ * optionally `window.__VERTZ_ACCESS_SET__` for instant auth hydration.
65
+ */
66
+ sessionResolver?: SessionResolver;
34
67
  }
35
68
  interface ErrorDetail {
36
69
  message: string;
@@ -96,11 +129,13 @@ interface SSRPageHtmlOptions {
96
129
  scriptTag: string;
97
130
  editor?: string;
98
131
  headTags?: string;
132
+ /** Pre-built session + access set script tags for SSR injection. */
133
+ sessionScript?: string;
99
134
  }
100
135
  /**
101
136
  * Generate a full SSR HTML page with the given content, CSS, SSR data, and script tag.
102
137
  */
103
- declare function generateSSRPageHtml({ title, css, bodyHtml, ssrData, scriptTag, editor, headTags }: SSRPageHtmlOptions): string;
138
+ declare function generateSSRPageHtml({ title, css, bodyHtml, ssrData, scriptTag, editor, headTags, sessionScript }: SSRPageHtmlOptions): string;
104
139
  interface FetchInterceptorOptions {
105
140
  apiHandler: (req: Request) => Promise<Response>;
106
141
  origin: string;
@@ -1,4 +1,8 @@
1
1
  // @bun
2
+ import {
3
+ imageContentType,
4
+ isValidImageName
5
+ } from "./shared/chunk-gggnhyqj.js";
2
6
  import {
3
7
  __require
4
8
  } from "./shared/chunk-eb80r8e8.js";
@@ -40,6 +44,53 @@ function createDebugLogger(logDir) {
40
44
  };
41
45
  }
42
46
 
47
+ // src/dev-image-proxy.ts
48
+ function jsonError(error, status) {
49
+ return new Response(JSON.stringify({ error, status }), {
50
+ status,
51
+ headers: { "Content-Type": "application/json" }
52
+ });
53
+ }
54
+ var FETCH_TIMEOUT_MS = 1e4;
55
+ async function handleDevImageProxy(request) {
56
+ const url = new URL(request.url);
57
+ const sourceUrl = url.searchParams.get("url");
58
+ if (!sourceUrl) {
59
+ return jsonError('Missing required "url" parameter', 400);
60
+ }
61
+ let parsed;
62
+ try {
63
+ parsed = new URL(sourceUrl);
64
+ } catch {
65
+ return jsonError('Invalid "url" parameter', 400);
66
+ }
67
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
68
+ return jsonError("Only HTTP(S) URLs are allowed", 400);
69
+ }
70
+ let response;
71
+ try {
72
+ response = await fetch(sourceUrl, {
73
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
74
+ });
75
+ } catch (error) {
76
+ if (error instanceof DOMException && error.name === "TimeoutError") {
77
+ return jsonError("Image fetch timed out", 504);
78
+ }
79
+ return jsonError("Failed to fetch image", 502);
80
+ }
81
+ if (!response.ok) {
82
+ return jsonError(`Upstream returned status ${response.status}`, 502);
83
+ }
84
+ const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
85
+ return new Response(response.body, {
86
+ status: 200,
87
+ headers: {
88
+ "Content-Type": contentType,
89
+ "Cache-Control": "no-cache"
90
+ }
91
+ });
92
+ }
93
+
43
94
  // src/diagnostics-collector.ts
44
95
  class DiagnosticsCollector {
45
96
  startTime = Date.now();
@@ -59,6 +110,10 @@ class DiagnosticsCollector {
59
110
  manifestFileCount = 0;
60
111
  manifestDurationMs = 0;
61
112
  manifestWarnings = [];
113
+ manifestHmrUpdateCount = 0;
114
+ manifestLastHmrUpdate = null;
115
+ manifestLastHmrFile = null;
116
+ manifestLastHmrChanged = null;
62
117
  errorCurrent = null;
63
118
  errorLastCategory = null;
64
119
  errorLastMessage = null;
@@ -67,6 +122,10 @@ class DiagnosticsCollector {
67
122
  watcherLastChangeTime = null;
68
123
  static MAX_RUNTIME_ERRORS = 10;
69
124
  runtimeErrorsBuffer = [];
125
+ fieldSelectionManifestFileCount = 0;
126
+ fieldSelectionEntries = new Map;
127
+ static MAX_FIELD_MISSES = 50;
128
+ fieldMissesBuffer = [];
70
129
  recordPluginConfig(filter, hmr, fastRefresh) {
71
130
  this.pluginFilter = filter;
72
131
  this.pluginHmr = hmr;
@@ -94,6 +153,12 @@ class DiagnosticsCollector {
94
153
  this.manifestDurationMs = durationMs;
95
154
  this.manifestWarnings = warnings;
96
155
  }
156
+ recordManifestUpdate(file, changed, _durationMs) {
157
+ this.manifestHmrUpdateCount++;
158
+ this.manifestLastHmrUpdate = new Date().toISOString();
159
+ this.manifestLastHmrFile = file;
160
+ this.manifestLastHmrChanged = changed;
161
+ }
97
162
  recordHMRAssets(bundledScriptUrl, bootstrapDiscovered) {
98
163
  this.hmrBundledScriptUrl = bundledScriptUrl;
99
164
  this.hmrBootstrapDiscovered = bootstrapDiscovered;
@@ -126,6 +191,24 @@ class DiagnosticsCollector {
126
191
  clearRuntimeErrors() {
127
192
  this.runtimeErrorsBuffer = [];
128
193
  }
194
+ recordFieldSelectionManifest(fileCount) {
195
+ this.fieldSelectionManifestFileCount = fileCount;
196
+ }
197
+ recordFieldSelection(file, entry) {
198
+ this.fieldSelectionEntries.set(file, entry);
199
+ }
200
+ recordFieldMiss(type, id, field, querySource) {
201
+ this.fieldMissesBuffer.push({
202
+ type,
203
+ id,
204
+ field,
205
+ querySource,
206
+ timestamp: new Date().toISOString()
207
+ });
208
+ if (this.fieldMissesBuffer.length > DiagnosticsCollector.MAX_FIELD_MISSES) {
209
+ this.fieldMissesBuffer = this.fieldMissesBuffer.slice(this.fieldMissesBuffer.length - DiagnosticsCollector.MAX_FIELD_MISSES);
210
+ }
211
+ }
129
212
  getSnapshot() {
130
213
  return {
131
214
  status: "ok",
@@ -152,7 +235,11 @@ class DiagnosticsCollector {
152
235
  manifest: {
153
236
  fileCount: this.manifestFileCount,
154
237
  durationMs: this.manifestDurationMs,
155
- warnings: [...this.manifestWarnings]
238
+ warnings: [...this.manifestWarnings],
239
+ hmrUpdateCount: this.manifestHmrUpdateCount,
240
+ lastHmrUpdate: this.manifestLastHmrUpdate,
241
+ lastHmrFile: this.manifestLastHmrFile,
242
+ lastHmrChanged: this.manifestLastHmrChanged
156
243
  },
157
244
  errors: {
158
245
  current: this.errorCurrent,
@@ -166,7 +253,12 @@ class DiagnosticsCollector {
166
253
  lastChangedFile: this.watcherLastChangedFile,
167
254
  lastChangeTime: this.watcherLastChangeTime
168
255
  },
169
- runtimeErrors: [...this.runtimeErrorsBuffer]
256
+ runtimeErrors: [...this.runtimeErrorsBuffer],
257
+ fieldSelection: {
258
+ manifestFileCount: this.fieldSelectionManifestFileCount,
259
+ entries: Object.fromEntries(this.fieldSelectionEntries),
260
+ misses: [...this.fieldMissesBuffer]
261
+ }
170
262
  };
171
263
  }
172
264
  }
@@ -193,6 +285,109 @@ function runWithScopedFetch(interceptor, fn) {
193
285
  return fetchScope.run(interceptor, fn);
194
286
  }
195
287
 
288
+ // src/font-metrics.ts
289
+ import { readFile } from "fs/promises";
290
+ import { join as join2 } from "path";
291
+ import { fromBuffer } from "@capsizecss/unpack";
292
+ var SYSTEM_FONT_METRICS = {
293
+ Arial: {
294
+ ascent: 1854,
295
+ descent: -434,
296
+ lineGap: 67,
297
+ unitsPerEm: 2048,
298
+ xWidthAvg: 904
299
+ },
300
+ "Times New Roman": {
301
+ ascent: 1825,
302
+ descent: -443,
303
+ lineGap: 87,
304
+ unitsPerEm: 2048,
305
+ xWidthAvg: 819
306
+ },
307
+ "Courier New": {
308
+ ascent: 1705,
309
+ descent: -615,
310
+ lineGap: 0,
311
+ unitsPerEm: 2048,
312
+ xWidthAvg: 1229
313
+ }
314
+ };
315
+ function detectFallbackFont(fallback) {
316
+ for (const f of fallback) {
317
+ const lower = f.toLowerCase();
318
+ if (lower === "sans-serif" || lower === "system-ui")
319
+ return "Arial";
320
+ if (lower === "serif")
321
+ return "Times New Roman";
322
+ if (lower === "monospace")
323
+ return "Courier New";
324
+ }
325
+ return "Arial";
326
+ }
327
+ function formatPercent(value) {
328
+ return `${(value * 100).toFixed(2)}%`;
329
+ }
330
+ function computeFallbackMetrics(fontMetrics, fallbackFont) {
331
+ const systemMetrics = SYSTEM_FONT_METRICS[fallbackFont];
332
+ const fontNormalizedWidth = fontMetrics.xWidthAvg / fontMetrics.unitsPerEm;
333
+ const systemNormalizedWidth = systemMetrics.xWidthAvg / systemMetrics.unitsPerEm;
334
+ const sizeAdjust = fontNormalizedWidth / systemNormalizedWidth;
335
+ const ascentOverride = fontMetrics.ascent / (fontMetrics.unitsPerEm * sizeAdjust);
336
+ const descentOverride = Math.abs(fontMetrics.descent) / (fontMetrics.unitsPerEm * sizeAdjust);
337
+ const lineGapOverride = fontMetrics.lineGap / (fontMetrics.unitsPerEm * sizeAdjust);
338
+ return {
339
+ ascentOverride: formatPercent(ascentOverride),
340
+ descentOverride: formatPercent(descentOverride),
341
+ lineGapOverride: formatPercent(lineGapOverride),
342
+ sizeAdjust: formatPercent(sizeAdjust),
343
+ fallbackFont
344
+ };
345
+ }
346
+ function getPrimarySrcPath(descriptor) {
347
+ const { src } = descriptor;
348
+ if (!src)
349
+ return null;
350
+ if (typeof src === "string")
351
+ return src;
352
+ const first = src[0];
353
+ if (first)
354
+ return first.path;
355
+ return null;
356
+ }
357
+ function resolveFilePath(urlPath, rootDir) {
358
+ const cleaned = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
359
+ return join2(rootDir, cleaned);
360
+ }
361
+ async function extractFontMetrics(fonts, rootDir) {
362
+ const result = {};
363
+ for (const [key, descriptor] of Object.entries(fonts)) {
364
+ const adjustFontFallback = descriptor.adjustFontFallback ?? true;
365
+ if (adjustFontFallback === false)
366
+ continue;
367
+ const srcPath = getPrimarySrcPath(descriptor);
368
+ if (!srcPath)
369
+ continue;
370
+ if (!srcPath.toLowerCase().endsWith(".woff2"))
371
+ continue;
372
+ try {
373
+ const filePath = resolveFilePath(srcPath, rootDir);
374
+ const buffer = await readFile(filePath);
375
+ const metrics = await fromBuffer(buffer);
376
+ const fallbackFont = typeof adjustFontFallback === "string" ? adjustFontFallback : detectFallbackFont(descriptor.fallback);
377
+ result[key] = computeFallbackMetrics({
378
+ ascent: metrics.ascent,
379
+ descent: metrics.descent,
380
+ lineGap: metrics.lineGap,
381
+ unitsPerEm: metrics.unitsPerEm,
382
+ xWidthAvg: metrics.xWidthAvg
383
+ }, fallbackFont);
384
+ } catch (error) {
385
+ console.warn(`[vertz] Failed to extract font metrics for "${key}" from "${srcPath}":`, error instanceof Error ? error.message : error);
386
+ }
387
+ }
388
+ return result;
389
+ }
390
+
196
391
  // src/source-map-resolver.ts
197
392
  import { readFileSync } from "fs";
198
393
  import { resolve as resolvePath } from "path";
@@ -338,6 +533,17 @@ function createSourceMapResolver(projectRoot) {
338
533
  };
339
534
  }
340
535
 
536
+ // src/ssr-access-set.ts
537
+ function createAccessSetScript(accessSet, nonce) {
538
+ const json = JSON.stringify(accessSet);
539
+ const escaped = json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
540
+ const nonceAttr = nonce ? ` nonce="${escapeAttr(nonce)}"` : "";
541
+ return `<script${nonceAttr}>window.__VERTZ_ACCESS_SET__=${escaped}</script>`;
542
+ }
543
+ function escapeAttr(s) {
544
+ return s.replace(/[&"'<>]/g, (c) => `&#${c.charCodeAt(0)};`);
545
+ }
546
+
341
547
  // src/ssr-render.ts
342
548
  import { compileTheme } from "@vertz/ui";
343
549
  import { EntityStore, MemoryCache, QueryEnvelopeStore } from "@vertz/ui/internals";
@@ -745,14 +951,14 @@ function installDomShim() {
745
951
  querySelector: () => null,
746
952
  querySelectorAll: () => [],
747
953
  getElementById: () => null,
954
+ addEventListener: () => {},
955
+ removeEventListener: () => {},
748
956
  cookie: ""
749
957
  };
750
958
  globalThis.document = fakeDocument;
751
959
  if (typeof window === "undefined") {
752
960
  globalThis.window = {
753
961
  location: { pathname: ssrStorage.getStore()?.url || "/", search: "", hash: "" },
754
- addEventListener: () => {},
755
- removeEventListener: () => {},
756
962
  history: {
757
963
  pushState: () => {},
758
964
  replaceState: () => {}
@@ -865,14 +1071,14 @@ var RAW_TEXT_ELEMENTS = new Set(["script", "style"]);
865
1071
  function escapeHtml(text) {
866
1072
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
867
1073
  }
868
- function escapeAttr(value) {
1074
+ function escapeAttr2(value) {
869
1075
  const str = typeof value === "string" ? value : String(value);
870
1076
  return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
871
1077
  }
872
1078
  function serializeAttrs(attrs) {
873
1079
  const parts = [];
874
1080
  for (const [key, value] of Object.entries(attrs)) {
875
- parts.push(` ${key}="${escapeAttr(value)}"`);
1081
+ parts.push(` ${key}="${escapeAttr2(value)}"`);
876
1082
  }
877
1083
  return parts.join("");
878
1084
  }
@@ -937,7 +1143,7 @@ async function streamToString(stream) {
937
1143
  function createTemplateChunk(slotId, resolvedHtml, nonce) {
938
1144
  const tmplId = `v-tmpl-${slotId}`;
939
1145
  const slotRef = `v-slot-${slotId}`;
940
- const nonceAttr = nonce != null ? ` nonce="${escapeAttr(nonce)}"` : "";
1146
+ const nonceAttr = nonce != null ? ` nonce="${escapeAttr2(nonce)}"` : "";
941
1147
  return `<template id="${tmplId}">${resolvedHtml}</template>` + `<script${nonceAttr}>` + `(function(){` + `var s=document.getElementById("${slotRef}");` + `var t=document.getElementById("${tmplId}");` + `if(s&&t){s.replaceWith(t.content.cloneNode(true));t.remove()}` + `})()` + "</script>";
942
1148
  }
943
1149
 
@@ -964,7 +1170,7 @@ function renderToStream(tree, options) {
964
1170
  }
965
1171
  const { tag, attrs, children } = node;
966
1172
  const isRawText = RAW_TEXT_ELEMENTS.has(tag);
967
- const attrStr = Object.entries(attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
1173
+ const attrStr = Object.entries(attrs).map(([k, v]) => ` ${k}="${escapeAttr2(v)}"`).join("");
968
1174
  if (VOID_ELEMENTS.has(tag)) {
969
1175
  return `<${tag}${attrStr}>`;
970
1176
  }
@@ -1020,7 +1226,7 @@ function createRequestContext(url) {
1020
1226
  contextScope: null,
1021
1227
  entityStore: new EntityStore,
1022
1228
  envelopeStore: new QueryEnvelopeStore,
1023
- queryCache: new MemoryCache,
1229
+ queryCache: new MemoryCache({ maxSize: Infinity }),
1024
1230
  inflight: new Map,
1025
1231
  queries: [],
1026
1232
  errors: []
@@ -1041,9 +1247,6 @@ function resolveAppFactory(module) {
1041
1247
  return createApp;
1042
1248
  }
1043
1249
  function collectCSS(themeCss, module) {
1044
- const themeTag = themeCss ? `<style data-vertz-css>${themeCss}</style>` : "";
1045
- const globalTags = module.styles ? module.styles.map((s) => `<style data-vertz-css>${s}</style>`).join(`
1046
- `) : "";
1047
1250
  const alreadyIncluded = new Set;
1048
1251
  if (themeCss)
1049
1252
  alreadyIncluded.add(themeCss);
@@ -1052,9 +1255,12 @@ function collectCSS(themeCss, module) {
1052
1255
  alreadyIncluded.add(s);
1053
1256
  }
1054
1257
  const componentCss = module.getInjectedCSS ? module.getInjectedCSS().filter((s) => !alreadyIncluded.has(s)) : [];
1055
- const componentStyles = componentCss.map((s) => `<style data-vertz-css>${s}</style>`).join(`
1056
- `);
1057
- return [themeTag, globalTags, componentStyles].filter(Boolean).join(`
1258
+ const themeTag = themeCss ? `<style data-vertz-css>${themeCss}</style>` : "";
1259
+ const globalTag = module.styles && module.styles.length > 0 ? `<style data-vertz-css>${module.styles.join(`
1260
+ `)}</style>` : "";
1261
+ const componentTag = componentCss.length > 0 ? `<style data-vertz-css>${componentCss.join(`
1262
+ `)}</style>` : "";
1263
+ return [themeTag, globalTag, componentTag].filter(Boolean).join(`
1058
1264
  `);
1059
1265
  }
1060
1266
  async function ssrRenderToString(module, url, options) {
@@ -1067,14 +1273,40 @@ async function ssrRenderToString(module, url, options) {
1067
1273
  setGlobalSSRTimeout(ssrTimeout);
1068
1274
  const createApp = resolveAppFactory(module);
1069
1275
  let themeCss = "";
1276
+ let themePreloadTags = "";
1070
1277
  if (module.theme) {
1071
1278
  try {
1072
- themeCss = compileTheme(module.theme).css;
1279
+ const compiled = compileTheme(module.theme, {
1280
+ fallbackMetrics: options?.fallbackMetrics
1281
+ });
1282
+ themeCss = compiled.css;
1283
+ themePreloadTags = compiled.preloadTags;
1073
1284
  } catch (e) {
1074
1285
  console.error("[vertz] Failed to compile theme export. Ensure your theme is created with defineTheme().", e);
1075
1286
  }
1076
1287
  }
1077
1288
  createApp();
1289
+ const store = ssrStorage.getStore();
1290
+ if (store) {
1291
+ if (store.pendingRouteComponents?.size) {
1292
+ const entries = Array.from(store.pendingRouteComponents.entries());
1293
+ const results = await Promise.allSettled(entries.map(([route, promise]) => Promise.race([
1294
+ promise.then((mod) => ({ route, factory: mod.default })),
1295
+ new Promise((_, reject) => setTimeout(() => reject(new Error("lazy route timeout")), ssrTimeout))
1296
+ ])));
1297
+ store.resolvedComponents = new Map;
1298
+ for (const result of results) {
1299
+ if (result.status === "fulfilled") {
1300
+ const { route, factory } = result.value;
1301
+ store.resolvedComponents.set(route, factory);
1302
+ }
1303
+ }
1304
+ store.pendingRouteComponents = undefined;
1305
+ }
1306
+ if (!store.resolvedComponents) {
1307
+ store.resolvedComponents = new Map;
1308
+ }
1309
+ }
1078
1310
  const queries = getSSRQueries();
1079
1311
  const resolvedQueries = [];
1080
1312
  if (queries.length > 0) {
@@ -1086,7 +1318,6 @@ async function ssrRenderToString(module, url, options) {
1086
1318
  }),
1087
1319
  new Promise((r) => setTimeout(r, timeout || ssrTimeout)).then(() => "timeout")
1088
1320
  ])));
1089
- const store = ssrStorage.getStore();
1090
1321
  if (store)
1091
1322
  store.queries = [];
1092
1323
  }
@@ -1099,7 +1330,13 @@ async function ssrRenderToString(module, url, options) {
1099
1330
  key,
1100
1331
  data: JSON.parse(JSON.stringify(data))
1101
1332
  })) : [];
1102
- return { html, css, ssrData };
1333
+ return {
1334
+ html,
1335
+ css,
1336
+ ssrData,
1337
+ headTags: themePreloadTags,
1338
+ discoveredRoutes: ctx.discoveredRoutes
1339
+ };
1103
1340
  } finally {
1104
1341
  clearGlobalSSRTimeout();
1105
1342
  }
@@ -1203,6 +1440,17 @@ data: ${safeSerialize(entry)}
1203
1440
  });
1204
1441
  }
1205
1442
 
1443
+ // src/ssr-session.ts
1444
+ function createSessionScript(session, nonce) {
1445
+ const json = JSON.stringify(session);
1446
+ const escaped = json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1447
+ const nonceAttr = nonce ? ` nonce="${escapeAttr3(nonce)}"` : "";
1448
+ return `<script${nonceAttr}>window.__VERTZ_SESSION__=${escaped}</script>`;
1449
+ }
1450
+ function escapeAttr3(s) {
1451
+ return s.replace(/[&"'<>]/g, (c) => `&#${c.charCodeAt(0)};`);
1452
+ }
1453
+
1206
1454
  // src/bun-dev-server.ts
1207
1455
  var MAX_TERMINAL_STACK_FRAMES = 5;
1208
1456
  function formatTerminalRuntimeError(errors, parsedStack) {
@@ -1431,7 +1679,8 @@ function generateSSRPageHtml({
1431
1679
  ssrData,
1432
1680
  scriptTag,
1433
1681
  editor = "vscode",
1434
- headTags = ""
1682
+ headTags = "",
1683
+ sessionScript = ""
1435
1684
  }) {
1436
1685
  const ssrDataScript = ssrData.length > 0 ? `<script>window.__VERTZ_SSR_DATA__=${safeSerialize(ssrData)};</script>` : "";
1437
1686
  return `<!doctype html>
@@ -1447,6 +1696,7 @@ function generateSSRPageHtml({
1447
1696
  </head>
1448
1697
  <body>
1449
1698
  <div id="app">${bodyHtml}</div>
1699
+ ${sessionScript}
1450
1700
  ${ssrDataScript}
1451
1701
  ${scriptTag}
1452
1702
  </body>
@@ -1529,7 +1779,8 @@ function createBunDevServer(options) {
1529
1779
  projectRoot = process.cwd(),
1530
1780
  logRequests = true,
1531
1781
  editor: editorOption,
1532
- headTags = ""
1782
+ headTags = "",
1783
+ sessionResolver
1533
1784
  } = options;
1534
1785
  const editor = detectEditor(editorOption);
1535
1786
  if (apiHandler) {
@@ -1783,7 +2034,7 @@ function createBunDevServer(options) {
1783
2034
  }));
1784
2035
  }
1785
2036
  });
1786
- const { plugin: serverPlugin } = createVertzBunPlugin({
2037
+ const { plugin: serverPlugin, updateManifest: updateServerManifest } = createVertzBunPlugin({
1787
2038
  hmr: false,
1788
2039
  fastRefresh: false,
1789
2040
  logger,
@@ -1800,6 +2051,14 @@ function createBunDevServer(options) {
1800
2051
  console.error("[Server] Failed to load SSR module:", e);
1801
2052
  process.exit(1);
1802
2053
  }
2054
+ let fontFallbackMetrics;
2055
+ if (ssrMod.theme?.fonts) {
2056
+ try {
2057
+ fontFallbackMetrics = await extractFontMetrics(ssrMod.theme.fonts, projectRoot);
2058
+ } catch (e) {
2059
+ console.warn("[Server] Failed to extract font metrics:", e);
2060
+ }
2061
+ }
1803
2062
  mkdirSync(devDir, { recursive: true });
1804
2063
  const frInitPath = resolve(devDir, "fast-refresh-init.ts");
1805
2064
  writeFileSync2(frInitPath, `import '@vertz/ui-server/fast-refresh-runtime';
@@ -1884,6 +2143,31 @@ if (import.meta.hot) import.meta.hot.accept();
1884
2143
  if (pathname === "/__vertz_diagnostics") {
1885
2144
  return Response.json(diagnostics.getSnapshot());
1886
2145
  }
2146
+ if (pathname === "/_vertz/image") {
2147
+ return handleDevImageProxy(request);
2148
+ }
2149
+ if (pathname.startsWith("/__vertz_img/")) {
2150
+ const imgName = pathname.slice("/__vertz_img/".length);
2151
+ if (!isValidImageName(imgName)) {
2152
+ return new Response("Forbidden", { status: 403 });
2153
+ }
2154
+ const imagesDir = resolve(projectRoot, ".vertz", "images");
2155
+ const imgPath = resolve(imagesDir, imgName);
2156
+ if (!imgPath.startsWith(imagesDir)) {
2157
+ return new Response("Forbidden", { status: 403 });
2158
+ }
2159
+ const file = Bun.file(imgPath);
2160
+ if (await file.exists()) {
2161
+ const ext = imgName.split(".").pop();
2162
+ return new Response(file, {
2163
+ headers: {
2164
+ "Content-Type": imageContentType(ext),
2165
+ "Cache-Control": "public, max-age=31536000, immutable"
2166
+ }
2167
+ });
2168
+ }
2169
+ return new Response("Not Found", { status: 404 });
2170
+ }
1887
2171
  if (openapi && request.method === "GET" && pathname === "/api/openapi.json") {
1888
2172
  return serveOpenAPISpec();
1889
2173
  }
@@ -1944,16 +2228,38 @@ data: {}
1944
2228
  skipSSRPaths,
1945
2229
  originalFetch: globalThis.fetch
1946
2230
  }) : null;
2231
+ let sessionScript = "";
2232
+ if (sessionResolver) {
2233
+ try {
2234
+ const sessionResult = await sessionResolver(request);
2235
+ if (sessionResult) {
2236
+ const scripts = [];
2237
+ scripts.push(createSessionScript(sessionResult.session));
2238
+ if (sessionResult.accessSet != null) {
2239
+ scripts.push(createAccessSetScript(sessionResult.accessSet));
2240
+ }
2241
+ sessionScript = scripts.join(`
2242
+ `);
2243
+ }
2244
+ } catch (resolverErr) {
2245
+ console.warn("[Server] Session resolver failed:", resolverErr instanceof Error ? resolverErr.message : resolverErr);
2246
+ }
2247
+ }
1947
2248
  const doRender = async () => {
1948
2249
  logger.log("ssr", "render-start", { url: pathname });
1949
2250
  const ssrStart = performance.now();
1950
- const result = await ssrRenderToString(ssrMod, pathname, { ssrTimeout: 300 });
2251
+ const result = await ssrRenderToString(ssrMod, pathname, {
2252
+ ssrTimeout: 300,
2253
+ fallbackMetrics: fontFallbackMetrics
2254
+ });
1951
2255
  logger.log("ssr", "render-done", {
1952
2256
  url: pathname,
1953
2257
  durationMs: Math.round(performance.now() - ssrStart),
1954
2258
  htmlBytes: result.html.length
1955
2259
  });
1956
2260
  const scriptTag = buildScriptTag(bundledScriptUrl, hmrBootstrapScript, clientSrc);
2261
+ const combinedHeadTags = [headTags, result.headTags].filter(Boolean).join(`
2262
+ `);
1957
2263
  const html = generateSSRPageHtml({
1958
2264
  title,
1959
2265
  css: result.css,
@@ -1961,7 +2267,8 @@ data: {}
1961
2267
  ssrData: result.ssrData,
1962
2268
  scriptTag,
1963
2269
  editor,
1964
- headTags
2270
+ headTags: combinedHeadTags,
2271
+ sessionScript
1965
2272
  });
1966
2273
  return new Response(html, {
1967
2274
  status: 200,
@@ -2193,6 +2500,18 @@ data: {}
2193
2500
  clearErrorForFileChange();
2194
2501
  }
2195
2502
  } catch {}
2503
+ if (stopped)
2504
+ return;
2505
+ if (filename.endsWith(".ts") || filename.endsWith(".tsx")) {
2506
+ const changedFilePath = resolve(srcDir, filename);
2507
+ try {
2508
+ const manifestStartMs = performance.now();
2509
+ const source = await Bun.file(changedFilePath).text();
2510
+ const { changed } = updateServerManifest(changedFilePath, source);
2511
+ const manifestDurationMs = Math.round(performance.now() - manifestStartMs);
2512
+ diagnostics.recordManifestUpdate(lastChangedFile, changed, manifestDurationMs);
2513
+ } catch {}
2514
+ }
2196
2515
  if (stopped)
2197
2516
  return;
2198
2517
  const cacheCleared = clearSSRRequireCache();
@@ -2205,6 +2524,11 @@ data: {}
2205
2524
  try {
2206
2525
  const freshMod = await import(`${ssrWrapperPath}?t=${Date.now()}`);
2207
2526
  ssrMod = freshMod;
2527
+ if (freshMod.theme?.fonts) {
2528
+ try {
2529
+ fontFallbackMetrics = await extractFontMetrics(freshMod.theme.fonts, projectRoot);
2530
+ } catch {}
2531
+ }
2208
2532
  const durationMs = Math.round(performance.now() - ssrReloadStart);
2209
2533
  diagnostics.recordSSRReload(true, durationMs);
2210
2534
  logger.log("watcher", "ssr-reload", { status: "ok", durationMs });
@@ -2225,6 +2549,11 @@ data: {}
2225
2549
  try {
2226
2550
  const freshMod = await import(`${ssrWrapperPath}?t=${Date.now()}`);
2227
2551
  ssrMod = freshMod;
2552
+ if (freshMod.theme?.fonts) {
2553
+ try {
2554
+ fontFallbackMetrics = await extractFontMetrics(freshMod.theme.fonts, projectRoot);
2555
+ } catch {}
2556
+ }
2228
2557
  const durationMs = Math.round(performance.now() - ssrReloadStart);
2229
2558
  diagnostics.recordSSRReload(true, durationMs);
2230
2559
  logger.log("watcher", "ssr-reload", { status: "ok", durationMs, retry: true });