evolit 0.1.0-alpha.8 → 0.1.0

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 CHANGED
@@ -76,6 +76,98 @@ The current runtime is split into a few small layers:
76
76
  - `src/scaffold.js`: creates new site projects from templates
77
77
  - `src/cli.js`: framework entrypoint
78
78
 
79
+ ## Client Navigation
80
+
81
+ Evolit hydrates a small browser router automatically for SSR page documents. It requests a route
82
+ delta, replaces only the changed route segment, and keeps parent layouts mounted when possible.
83
+ Plain `<a>` elements remain valid SSR HTML; same-origin links are progressively intercepted after
84
+ hydration.
85
+
86
+ Use `useNavigation()` inside a LitSX browser component for imperative navigation and pending UI:
87
+
88
+ ```jsx
89
+ import { useNavigation } from "evolit/navigation";
90
+
91
+ export default function CollectionControls() {
92
+ const navigation = useNavigation();
93
+
94
+ function changeSort(event) {
95
+ const searchParams = new URLSearchParams(window.location.search);
96
+ searchParams.delete("page");
97
+ searchParams.delete("skip");
98
+ searchParams.set("sort", event.target.value);
99
+ navigation.push(navigation.createHref("/explore/home-garden", searchParams));
100
+ }
101
+
102
+ return <select onChange={changeSort} disabled={navigation.status === "pending"}>…</select>;
103
+ }
104
+ ```
105
+
106
+ The hook returns `{ status, url, pendingUrl, error, push, replace, refresh, createHref }`:
107
+
108
+ - `push(target, { scroll: false })` adds a browser-history entry. Pass
109
+ `scroll: false` to keep the current viewport position.
110
+ - `replace(target, { scroll: false })` updates the current entry, useful for
111
+ visual-only query state. It accepts the same scroll option.
112
+ - `refresh()` bypasses the client delta cache for the current URL.
113
+ - `createHref(pathname, searchParams)` creates a relative internal URL. It accepts standard
114
+ `URLSearchParams`, preserving repeated keys such as `facet=brand&facet=material`.
115
+
116
+ `createHref` can also be imported directly from `evolit/navigation`; it is browser-free and safe to
117
+ share with server-evaluated route code. `useNavigation()` itself is browser-only and must only run
118
+ from a connected client component.
119
+
120
+ Client components can also read the active route state with browser-only hooks:
121
+
122
+ ```jsx
123
+ import { useParams, useSearchParams } from "evolit/navigation";
124
+
125
+ const { slug = [] } = useParams();
126
+ const searchParams = useSearchParams();
127
+ const selectedFacets = searchParams.getAll("facet");
128
+ ```
129
+
130
+ Both hooks update after client navigation. `useParams()` returns a read-only snapshot; the
131
+ `URLSearchParams` from `useSearchParams()` is a local snapshot, so build a new href and navigate to
132
+ it to update the URL.
133
+
134
+ ### Development refresh
135
+
136
+ In development, each browser subscribes its active URL over the Evolit WebSocket. After source
137
+ invalidation, the server renders the fresh SSR representation once per request context and pushes
138
+ the resulting delta directly to its subscribers. No follow-up browser request is needed. Server-only
139
+ module and static-asset changes update only the affected route segment while preserving the document,
140
+ scroll position, and persistent layouts.
141
+ Changes to a hydrated client boundary rebuild its browser artifact and update the stable Evolit
142
+ development proxy registered for that tag. Existing instances receive the new implementation
143
+ without redefining the Custom Element or replacing the document. Invalid deltas and failed hot
144
+ updates still fall back to a document reload.
145
+
146
+ ### Progressive links and forms
147
+
148
+ Links work without JavaScript. With JavaScript, Evolit intercepts ordinary same-origin left-clicks.
149
+ Set `data-evolit-navigation="false"` on a link to keep native navigation.
150
+
151
+ Internal `<form method="get">` elements are treated the same way: their successful controls become
152
+ `URLSearchParams` and navigate through a delta. Without JavaScript the browser submits the exact
153
+ same GET form normally. Evolit intentionally does not intercept `POST`, file-upload, external,
154
+ targeted, or opted-out forms.
155
+
156
+ ### Navigation cache and document updates
157
+
158
+ The browser cache is scoped to browser-history entries, not a global URL map. Going back or forward
159
+ can reuse the delta for that exact entry; opening a new branch after going back discards its known
160
+ forward branch. This avoids an unbounded catalog cache in a long-lived tab.
161
+
162
+ - `dynamic` routes are never cached in the browser.
163
+ - `revalidate` entries remain reusable only until their route TTL expires.
164
+ - `static` entries remain reusable while their history entry exists in the current tab session.
165
+
166
+ Each delta also synchronizes route `<title>`, route-specific `<head>` markup, managed styles and
167
+ module preloads, `html`/`body` attributes, scroll position, hash targets, and focus. If a response
168
+ cannot be represented as an Evolit delta —for example a 404 from another adapter— navigation falls
169
+ back to a normal document load.
170
+
79
171
  ## Route Cache Policies
80
172
 
81
173
  Route modules can export a `routeConfig` object with a `cache` policy:
@@ -104,12 +196,18 @@ export const routeConfig = {
104
196
  - `static`: prerender in `build` and serve from the response cache in `start`
105
197
  - `revalidate`: cache the HTML response for `N` seconds and regenerate on expiry
106
198
 
199
+ Pages without `routeConfig.cache` default to `{ revalidate: 60 }`. This caches a normal SSR
200
+ render by pathname and query string while keeping content fresh without requiring a cache
201
+ declaration on every catalog or CMS page. Declare `cache: "static"` for fully static pages or
202
+ `cache: "dynamic"` when a page must always render per request.
203
+
107
204
  The same semantics work in local development and in production runtimes. Only the backing cache
108
205
  store changes.
109
206
 
110
- The default cache key includes the pathname and query string. Reading the `request` prop, request
111
- headers, cookies, or `requestUrl()` makes the completed render dynamic; it is never stored in the
112
- HTML response cache. `routeConfig.cache` is the sole authority for HTML caching; setting a
207
+ The default cache key includes the pathname and query string, so `params` and `searchParams` are
208
+ cacheable by URL. Reading the `request` prop, request headers, cookies, or `requestUrl()` makes
209
+ the completed render dynamic; it is never stored in the HTML response cache. `routeConfig.cache`
210
+ is the sole authority for HTML caching; setting a
113
211
  `Cache-Control` response header does not alter that policy.
114
212
 
115
213
  ## Request APIs
@@ -141,6 +239,71 @@ headers. `redirect()` and `permanentRedirect()` end rendering with `307` and `30
141
239
  `notFound()` renders a `404`. Reading headers, cookies, or the request URL makes the completed
142
240
  render dynamic, so it is not stored by the route response cache.
143
241
 
242
+ ## Extensions
243
+
244
+ Optional integrations are configured explicitly in `evolit.config.js`. Core only coordinates their
245
+ request and browser-navigation lifecycles: it has no knowledge of tenants, locales, catalogues, or
246
+ message formats. Plugins run in declaration order. Each request hook receives the URL produced by
247
+ the preceding hook; a `rewrite` continues the chain, while a `redirect` or `Response` stops it.
248
+
249
+ ```js
250
+ // evolit.config.js
251
+ import { defineEvolitPlugin } from "evolit/extensions";
252
+
253
+ export default {
254
+ plugins: [defineEvolitPlugin({
255
+ name: "tenant-prefix",
256
+ onRequest({ pathname, headers, set }) {
257
+ const tenant = headers().get("x-tenant") ?? "public";
258
+ set("tenant", tenant);
259
+ if (pathname === "/shop") return { rewrite: `/tenants/${tenant}/shop` };
260
+ },
261
+ client: {
262
+ module: "@example/evolit-tenant/client",
263
+ options: { prefix: "/tenants" },
264
+ },
265
+ })],
266
+ };
267
+ ```
268
+
269
+ `onRequest` is server-only. It can read the `Request`, headers, cookies and URL, set JSON-serializable
270
+ request values, rewrite internally, or return `{ redirect, status? }` / a Web `Response`. Reading
271
+ request-bound data marks the request dynamic. Server components and handlers read the values without
272
+ prop drilling:
273
+
274
+ ```js
275
+ import { getRequestContext } from "evolit/server";
276
+
277
+ export default async function TenantPage() {
278
+ const { tenant } = getRequestContext();
279
+ return `<main>Tenant: ${tenant}</main>`;
280
+ }
281
+ ```
282
+
283
+ The `client.module` is a bare package specifier and is the only extension code included in the
284
+ browser build. It must export a named `navigation` object (or `default`) with optional hooks:
285
+
286
+ ```js
287
+ // @example/evolit-tenant/client
288
+ export const navigation = {
289
+ // Synchronous and idempotent: it may run for generated links and intercepted links.
290
+ transformUrl({ url, options }) {
291
+ return url.startsWith(options.prefix) ? url : `${options.prefix}${url}`;
292
+ },
293
+ async beforeNavigate({ url }) {
294
+ if (url.endsWith("/blocked")) return false;
295
+ },
296
+ afterNavigate({ from, url }) {
297
+ // Browser-only analytics or state synchronization.
298
+ },
299
+ };
300
+ ```
301
+
302
+ `transformUrl` runs for `createHref()` and before a SPA navigation; `beforeNavigate` can transform
303
+ or cancel a navigation, and `afterNavigate` runs after its delta is applied. Hooks are sequential
304
+ and later hooks receive the URL returned by earlier hooks. Browser modules must not import server
305
+ code. Request values are isolated with async request context and are discarded when rendering ends.
306
+
144
307
  ## Route Boundaries
145
308
 
146
309
  `not-found.litsx` and `error.litsx` are resolved from the current route directory up to `app/`.
@@ -231,6 +394,33 @@ export default {
231
394
  };
232
395
  ```
233
396
 
397
+ For monorepos, development watches only the application project by default. Additional source
398
+ trees can be opted into explicitly; generated output and dependency directories below every root
399
+ remain ignored:
400
+
401
+ ```js
402
+ export default {
403
+ development: {
404
+ managedSourceRoots: ["../packages/design-system/src"],
405
+ },
406
+ };
407
+ ```
408
+
409
+ Imports outside these roots remain unmanaged and produce the existing development warning.
410
+
411
+ Evolit normally discovers browser boundaries from the static module graph and reconciles that
412
+ inventory with the components actually emitted by SSR. A genuinely computed import cannot be
413
+ enumerated at build time, so production applications can declare only those exceptional modules:
414
+
415
+ ```js
416
+ export default {
417
+ clientBoundaries: ["./src/components/dynamic-card.litsx", "@acme/ui/product-card"],
418
+ };
419
+ ```
420
+
421
+ These modules are materialized as build artifacts, but are not automatically imported or
422
+ preloaded. They reach the browser only when an SSR hydration root references them.
423
+
234
424
  The framework also exports `ObjectStorageResponseCacheStore`, which is intended for object-store
235
425
  backends such as S3. A concrete app can wire it to the AWS SDK without pulling AWS dependencies
236
426
  into `evolit` itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolit",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0",
4
4
  "description": "A convention-driven application framework for LitSX and web components.",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.10.3",
@@ -14,22 +14,34 @@
14
14
  "homepage": "https://github.com/litsxdev/nextsx#readme",
15
15
  "files": [
16
16
  "src",
17
- "templates"
17
+ "templates/default/app/**",
18
+ "templates/default/jsconfig.json",
19
+ "templates/default/yarnrc.yml.template"
18
20
  ],
19
21
  "bin": "./src/cli.js",
20
22
  "main": "./src/index.js",
21
23
  "exports": {
22
24
  ".": "./src/index.js",
25
+ "./navigation": {
26
+ "browser": "./src/navigation-client.js",
27
+ "default": "./src/navigation-server.js"
28
+ },
23
29
  "./server": {
24
30
  "browser": "./src/request-context-browser.js",
25
31
  "default": "./src/server-api.js"
26
- }
32
+ },
33
+ "./extensions": {
34
+ "browser": "./src/extensions-client.js",
35
+ "default": "./src/extensions.js"
36
+ },
37
+ "./internal/development-hot": "./src/development-hot-client.js"
27
38
  },
28
39
  "scripts": {
29
40
  "dev": "node ./src/cli.js dev",
30
41
  "build": "node ./src/cli.js build",
31
42
  "start": "node ./src/cli.js start",
32
- "test": "node --test",
43
+ "test": "node --test test/*.test.js",
44
+ "test:browser": "playwright test",
33
45
  "typecheck": "litsx-tsc -p jsconfig.json --noEmit",
34
46
  "release:check": "yarn test && yarn typecheck && yarn pack --dry-run"
35
47
  },
@@ -47,10 +59,11 @@
47
59
  },
48
60
  "dependencies": {
49
61
  "@jridgewell/remapping": "^2.3.5",
50
- "@litsx/compiler": "0.9.5-canary-feat-ssr-20260726130435",
51
- "@litsx/core": "0.17.0-canary-feat-ssr-20260726130435",
52
- "@litsx/ssr": "0.2.0-canary-feat-ssr-20260726130435",
62
+ "@litsx/compiler": "0.10.0-canary-feat-ssr-20260802205539",
63
+ "@litsx/core": "0.17.0-canary-feat-ssr-20260802205539",
64
+ "@litsx/ssr": "0.2.0-canary-feat-ssr-20260802205539",
53
65
  "@litsx/typescript": "^0.9.0",
66
+ "@rollup/plugin-commonjs": "^29.0.0",
54
67
  "@rollup/plugin-node-resolve": "^16.0.3",
55
68
  "lit": "^3.3.3",
56
69
  "magic-string": "^1.1.0",
@@ -58,7 +71,17 @@
58
71
  "typescript": "^6.0.0",
59
72
  "ws": "^8.18.3"
60
73
  },
74
+ "peerDependencies": {
75
+ "@litsx/urql": "^0.3.0"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "@litsx/urql": {
79
+ "optional": true
80
+ }
81
+ },
61
82
  "devDependencies": {
83
+ "@playwright/test": "^1.62.0",
84
+ "@webcomponents/scoped-custom-element-registry": "^0.0.10",
62
85
  "vite": "^8.1.5"
63
86
  }
64
- }
87
+ }
package/src/build.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import {
4
5
  discoverAppRouteHandlers,
5
6
  discoverAppRoutes,
@@ -15,13 +16,22 @@ import {
15
16
  emitBundledClientAssets,
16
17
  emitHashedClientAssets,
17
18
  normalizeHydrationDataForClient,
18
- resolveRouteClientImports,
19
+ resolveServerStyleUrls,
20
+ resolveHydrationRootClientImports,
19
21
  resolveSharedVendorModuleUrl,
20
22
  rewriteHydrationDataScript,
21
23
  rewriteServerAssetPlaceholders,
22
24
  } from "./client-assets.js";
23
- import { compileModuleGraph, importCompiledModule } from "./compiler.js";
25
+ import {
26
+ collectClientGraphInventory,
27
+ compileModuleGraph,
28
+ emitClientStaticAssets,
29
+ getClientStaticAssetModule,
30
+ importCompiledModule,
31
+ resolveProjectModuleSpecifier,
32
+ } from "./compiler.js";
24
33
  import { loadEvolitConfig } from "./config.js";
34
+ import { getExtensionClientDescriptors, resolveEvolitExtensions } from "./extensions.js";
25
35
  import {
26
36
  BUILD_DIRECTORY,
27
37
  DEPLOY_ASSETS_MANIFEST_FILENAME,
@@ -39,6 +49,7 @@ import {
39
49
  import { serializeRouteCachePolicy } from "./route-config.js";
40
50
  import { createSsrAdapter, renderRouteTreeWithAdapter } from "./ssr-adapter.js";
41
51
  import { ensureDirectory, writeJson } from "./fs-utils.js";
52
+ import { appendSsrUrqlData, runWithOptionalSsrUrqlScope } from "./urql-ssr.js";
42
53
 
43
54
  const CONTENT_TYPE_BY_EXTENSION = new Map([
44
55
  [".css", "text/css; charset=utf-8"],
@@ -119,11 +130,46 @@ async function writeDeploymentRuntimeEntry(buildRoot) {
119
130
 
120
131
  export async function buildProject(projectRoot) {
121
132
  const evolitConfig = await loadEvolitConfig(projectRoot);
133
+ const extensions = resolveEvolitExtensions(evolitConfig);
134
+ const extensionClientDescriptors = getExtensionClientDescriptors(extensions);
135
+ const sharedVendorOptions = {
136
+ additionalEntrySpecifiers: extensionClientDescriptors.map((descriptor) => descriptor.module),
137
+ };
122
138
  const routes = await discoverAppRoutes(projectRoot);
123
139
  const routeHandlers = await discoverAppRouteHandlers(projectRoot);
124
140
  const buildRoot = path.join(projectRoot, INTERNAL_DIRECTORY, BUILD_DIRECTORY);
125
141
  const entryClientModules = new Set();
142
+ const serverAssetImportsByEntry = {};
143
+ const clientBoundariesByEntry = {};
126
144
  const deployHandlers = [];
145
+ const compiledClientBoundaries = new Map();
146
+ const inventoriesBySourceEntry = new Map();
147
+
148
+ function getEntryInventory(entryPath) {
149
+ let inventory = inventoriesBySourceEntry.get(entryPath);
150
+ if (!inventory) {
151
+ inventory = collectClientGraphInventory([entryPath], { projectRoot });
152
+ inventoriesBySourceEntry.set(entryPath, inventory);
153
+ }
154
+ return inventory;
155
+ }
156
+
157
+ async function compileProductionClientBoundary(sourcePath) {
158
+ let clientModule = compiledClientBoundaries.get(sourcePath);
159
+ if (clientModule) return clientModule;
160
+ const clientBuild = await compileModuleGraph(sourcePath, {
161
+ projectRoot,
162
+ mode: "production",
163
+ sourceMaps: true,
164
+ target: "client",
165
+ });
166
+ clientModule = path.relative(clientBuild.outputRoot, clientBuild.entrypoint)
167
+ .split(path.sep)
168
+ .join("/");
169
+ compiledClientBoundaries.set(sourcePath, clientModule);
170
+ entryClientModules.add(clientModule);
171
+ return clientModule;
172
+ }
127
173
 
128
174
  await ensureDirectory(buildRoot);
129
175
 
@@ -166,16 +212,6 @@ export async function buildProject(projectRoot) {
166
212
  target: "server",
167
213
  });
168
214
 
169
- const pageClientBuild = await compileModuleGraph(route.page, {
170
- projectRoot,
171
- mode: "production",
172
- sourceMaps: true,
173
- target: "client",
174
- });
175
- entryClientModules.add(
176
- path.relative(pageClientBuild.outputRoot, pageClientBuild.entrypoint).split(path.sep).join("/"),
177
- );
178
-
179
215
  for (const layoutPath of route.layouts) {
180
216
  await compileModuleGraph(layoutPath, {
181
217
  projectRoot,
@@ -185,15 +221,6 @@ export async function buildProject(projectRoot) {
185
221
  target: "server",
186
222
  });
187
223
 
188
- const layoutClientBuild = await compileModuleGraph(layoutPath, {
189
- projectRoot,
190
- mode: "production",
191
- sourceMaps: true,
192
- target: "client",
193
- });
194
- entryClientModules.add(
195
- path.relative(layoutClientBuild.outputRoot, layoutClientBuild.entrypoint).split(path.sep).join("/"),
196
- );
197
224
  }
198
225
 
199
226
  const boundaryModules = [
@@ -208,23 +235,70 @@ export async function buildProject(projectRoot) {
208
235
  ssr: true,
209
236
  target: "server",
210
237
  });
238
+ }
211
239
 
212
- const boundaryClientBuild = await compileModuleGraph(boundaryPath, {
240
+ const toProjectRelative = (filePath) => path.relative(projectRoot, filePath).split(path.sep).join("/");
241
+ const segmentEntries = [...new Set([route.page, ...route.layouts, ...boundaryModules])];
242
+ const inventoriesByEntry = new Map(await Promise.all(segmentEntries.map(async (entryPath) => [
243
+ entryPath,
244
+ await getEntryInventory(entryPath),
245
+ ])));
246
+ const allStyles = new Set();
247
+ const allAssets = new Set();
248
+ const allClientBoundaries = new Set();
249
+ for (const [entryPath, entryInventory] of inventoriesByEntry) {
250
+ serverAssetImportsByEntry[toProjectRelative(entryPath)] = {
251
+ styles: entryInventory.styles.map((filePath) => getClientStaticAssetModule(projectRoot, filePath)),
252
+ assets: entryInventory.assets.map((filePath) => getClientStaticAssetModule(projectRoot, filePath)),
253
+ };
254
+ entryInventory.styles.forEach((filePath) => allStyles.add(filePath));
255
+ entryInventory.assets.forEach((filePath) => allAssets.add(filePath));
256
+ entryInventory.clientBoundaries.forEach((filePath) => allClientBoundaries.add(filePath));
257
+ }
258
+ await emitClientStaticAssets([...allStyles, ...allAssets], {
259
+ projectRoot,
260
+ mode: "production",
261
+ });
262
+ const compiledBoundaryModules = new Map();
263
+ for (const clientBoundary of allClientBoundaries) {
264
+ compiledBoundaryModules.set(
265
+ clientBoundary,
266
+ await compileProductionClientBoundary(clientBoundary),
267
+ );
268
+ }
269
+ for (const [entryPath, entryInventory] of inventoriesByEntry) {
270
+ clientBoundariesByEntry[toProjectRelative(entryPath)] = entryInventory.clientBoundaries
271
+ .map((clientBoundary) => compiledBoundaryModules.get(clientBoundary))
272
+ .filter(Boolean)
273
+ .sort();
274
+ }
275
+ }
276
+
277
+ const configuredClientBoundaries = evolitConfig.clientBoundaries ?? [];
278
+ if (!Array.isArray(configuredClientBoundaries)) {
279
+ throw new Error("Expected clientBoundaries in evolit.config.js to be an array of module specifiers.");
280
+ }
281
+ for (const specifier of configuredClientBoundaries) {
282
+ if (typeof specifier !== "string" || specifier.length === 0) {
283
+ throw new Error("Expected every clientBoundaries entry to be a non-empty module specifier.");
284
+ }
285
+ const sourcePath = path.isAbsolute(specifier)
286
+ ? specifier
287
+ : await resolveProjectModuleSpecifier(
213
288
  projectRoot,
214
- mode: "production",
215
- sourceMaps: true,
216
- target: "client",
217
- });
218
- entryClientModules.add(
219
- path.relative(boundaryClientBuild.outputRoot, boundaryClientBuild.entrypoint)
220
- .split(path.sep)
221
- .join("/"),
289
+ path.join(projectRoot, "evolit.config.js"),
290
+ specifier,
222
291
  );
292
+ if (!sourcePath) {
293
+ throw new Error(`Unable to resolve configured client boundary ${JSON.stringify(specifier)}.`);
223
294
  }
295
+ await compileProductionClientBoundary(sourcePath);
224
296
  }
225
297
 
226
298
  const clientAssets = await emitBundledClientAssets(projectRoot, {
227
299
  entryClientModules,
300
+ serverAssetImportsByEntry,
301
+ clientBoundariesByEntry,
228
302
  });
229
303
  const staticAssetPublicUrls = createStaticAssetPublicUrlMap(clientAssets);
230
304
  await rewriteServerAssetPlaceholders(projectRoot, clientAssets);
@@ -239,33 +313,114 @@ export async function buildProject(projectRoot) {
239
313
  projectRoot,
240
314
  "production",
241
315
  "@litsx/ssr/hydration",
316
+ sharedVendorOptions,
242
317
  );
318
+ const navigationModuleUrl = await resolveSharedVendorModuleUrl(
319
+ projectRoot,
320
+ "production",
321
+ "evolit/navigation",
322
+ sharedVendorOptions,
323
+ );
324
+ const navigationExtensions = await Promise.all(extensionClientDescriptors.map(async (descriptor) => ({
325
+ ...descriptor,
326
+ module: await resolveSharedVendorModuleUrl(
327
+ projectRoot,
328
+ "production",
329
+ descriptor.module,
330
+ sharedVendorOptions,
331
+ ),
332
+ })));
243
333
  const ssrAdapter = createSsrAdapter({
244
334
  assetResolver,
335
+ async onSsrResult({ routeResult, result }) {
336
+ const renderedModules = [...new Set([
337
+ ...(Array.isArray(result.clientImports) ? result.clientImports : []),
338
+ ...(Array.isArray(result.hydrationData?.roots)
339
+ ? result.hydrationData.roots.map((root) => root?.moduleId)
340
+ : []),
341
+ ].filter((moduleId) => typeof moduleId === "string" && moduleId.length > 0))];
342
+ const unresolved = [];
343
+ for (const moduleId of renderedModules) {
344
+ if (assetResolver(moduleId) || clientAssets.byPublicPath?.[moduleId]) continue;
345
+ const importerPath = routeResult.boundaryModule
346
+ ?? routeResult.route?.page
347
+ ?? path.join(projectRoot, "app", "page.litsx");
348
+ let sourcePath = null;
349
+ if (moduleId.startsWith("file:")) {
350
+ try { sourcePath = fileURLToPath(moduleId); } catch {}
351
+ } else if (path.isAbsolute(moduleId) && !moduleId.startsWith("/app/") && !moduleId.startsWith("/src/")) {
352
+ sourcePath = moduleId;
353
+ } else if (moduleId.startsWith("/")) {
354
+ sourcePath = await resolveProjectModuleSpecifier(projectRoot, importerPath, `.${moduleId}`);
355
+ } else {
356
+ sourcePath = await resolveProjectModuleSpecifier(projectRoot, importerPath, moduleId);
357
+ }
358
+ const publicUrl = sourcePath ? assetResolver(sourcePath) : null;
359
+ if (!publicUrl) {
360
+ unresolved.push(moduleId);
361
+ continue;
362
+ }
363
+ for (const root of result.hydrationData?.roots ?? []) {
364
+ if (root?.moduleId === moduleId) root.moduleId = sourcePath;
365
+ }
366
+ if (Array.isArray(result.clientImports)) {
367
+ result.clientImports = result.clientImports.map((value) => value === moduleId ? publicUrl : value);
368
+ }
369
+ if (Array.isArray(result.hydrationData?.clientImports)) {
370
+ result.hydrationData.clientImports = result.hydrationData.clientImports
371
+ .map((value) => value === moduleId ? publicUrl : value);
372
+ }
373
+ }
374
+ if (unresolved.length > 0) {
375
+ throw new Error(
376
+ `SSR rendered client boundaries without build artifacts for ${routeResult.route?.pathname ?? "route"}: `
377
+ + `${unresolved.join(", ")}.`,
378
+ );
379
+ }
380
+ },
245
381
  async resolveAdditionalHead({ routeResult, result }) {
246
- const clientImports = [
382
+ const clientImports = [...new Set([
247
383
  ...(Array.isArray(result.clientImports) ? result.clientImports : []),
248
- ...resolveRouteClientImports(routeResult, projectRoot, clientAssets),
249
- ];
250
- const urls = collectTransitiveAssetPreloads(clientImports, clientAssets);
251
- const styleUrls = collectTransitiveStyleUrls(clientImports, clientAssets);
384
+ ...resolveHydrationRootClientImports(result.hydrationData, assetResolver),
385
+ ])];
386
+ const hydratedClientImports = clientImports;
387
+ const urls = hydratedClientImports.length > 0
388
+ ? [...new Set([
389
+ ...hydratedClientImports,
390
+ ...collectTransitiveAssetPreloads(hydratedClientImports, clientAssets),
391
+ ])]
392
+ : [];
393
+ const styleUrls = [...new Set([
394
+ ...collectTransitiveStyleUrls(clientImports, clientAssets),
395
+ ...resolveServerStyleUrls(routeResult, projectRoot, clientAssets),
396
+ ])];
252
397
 
253
398
  return [
254
- ...urls.map((href) => `<link rel="modulepreload" href="${href}">`),
255
- ...styleUrls.map((href) => `<link rel="stylesheet" href="${href}">`),
399
+ ...urls.map((href) => `<link rel="modulepreload" href="${href}" data-evolit-route-asset="preload">`),
400
+ ...styleUrls.map((href) => `<link rel="stylesheet" href="${href}" data-evolit-route-asset="style">`),
256
401
  ].join("\n");
257
402
  },
258
- resolveBootstrap({ result }) {
403
+ resolveBootstrap({ routeResult, result }) {
259
404
  return createHydrationBootstrap({
260
- hydrationData: normalizeHydrationDataForClient(result.hydrationData, projectRoot),
405
+ hydrationData: normalizeHydrationDataForClient(
406
+ result.hydrationData,
407
+ projectRoot,
408
+ resolveHydrationRootClientImports(result.hydrationData, assetResolver),
409
+ ),
261
410
  assetResolver,
262
411
  hydrationModuleUrl,
412
+ navigationModuleUrl,
413
+ navigationExtensions,
263
414
  });
264
415
  },
265
- transformDocument({ result, document }) {
416
+ transformDocument({ routeResult, result, document }) {
266
417
  return rewriteHydrationDataScript(
267
418
  document,
268
- normalizeHydrationDataForClient(result.hydrationData, projectRoot),
419
+ normalizeHydrationDataForClient(
420
+ result.hydrationData,
421
+ projectRoot,
422
+ resolveHydrationRootClientImports(result.hydrationData, assetResolver),
423
+ ),
269
424
  );
270
425
  },
271
426
  });
@@ -313,12 +468,23 @@ export async function buildProject(projectRoot) {
313
468
  seenPrerenderTargets.add(targetPathname);
314
469
 
315
470
  const targetRequest = new Request(`http://evolit.local${targetPathname}`);
316
- const routeResult = await routeResolver.resolveRequest(targetRequest);
317
- if (routeResult.type !== "route" || routeResult.cachePolicy.mode === "dynamic") {
471
+ const { routeResult, response } = await runWithOptionalSsrUrqlScope(async (urqlAdapter) => {
472
+ const resolvedRouteResult = await routeResolver.resolveRequest(targetRequest);
473
+ if (resolvedRouteResult.type !== "route" || resolvedRouteResult.cachePolicy.mode === "dynamic") {
474
+ return { routeResult: resolvedRouteResult, response: null };
475
+ }
476
+
477
+ const renderedResponse = await renderRouteTreeWithAdapter(resolvedRouteResult, ssrAdapter);
478
+ return {
479
+ routeResult: resolvedRouteResult,
480
+ response: urqlAdapter
481
+ ? appendSsrUrqlData(renderedResponse, await urqlAdapter.getUrqlSsrData())
482
+ : renderedResponse,
483
+ };
484
+ });
485
+ if (!response) {
318
486
  continue;
319
487
  }
320
-
321
- const response = await renderRouteTreeWithAdapter(routeResult, ssrAdapter);
322
488
  if (response.status !== 200) {
323
489
  continue;
324
490
  }