evolit 0.1.0-alpha.2 → 0.1.0-alpha.21

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/src/render.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { html } from "lit";
4
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
5
+ import { renderToString } from "@litsx/ssr";
3
6
  import { discoverAppRouteHandlers, discoverAppRoutes, matchRoute } from "./app-discovery.js";
4
7
  import { importCompiledModule } from "./compiler.js";
5
8
  import { APP_DIRECTORY, MODULE_EXTENSIONS } from "./constants.js";
6
9
  import { pathExists } from "./fs-utils.js";
7
10
  import { mergeRouteConfig, normalizeRouteCachePolicy } from "./route-config.js";
11
+ import { createRouteSegmentPlan, wrapRouteSegment } from "./route-segments.js";
8
12
  import {
9
13
  createRequestContext,
10
14
  applyRequestContextToResponse,
@@ -12,6 +16,7 @@ import {
12
16
  isEvolitHttpSignal,
13
17
  runWithRequestContext,
14
18
  } from "./request-context.js";
19
+ import { createDevelopmentEventReporter } from "./development-events.js";
15
20
  import {
16
21
  LITSX_COMPONENT,
17
22
  LITSX_SERVER_COMPONENT,
@@ -89,7 +94,12 @@ function createBoundaryError(error, { mode, route, reportRouteError }) {
89
94
  if (typeof reportRouteError === "function") {
90
95
  reportRouteError(report);
91
96
  } else {
92
- console.error(`[evolit] Route rendering failed (${digest}) at ${route.pathname}`, originalError);
97
+ createDevelopmentEventReporter()({
98
+ type: "route-error",
99
+ digest,
100
+ pathname: route.pathname,
101
+ error: originalError,
102
+ });
93
103
  }
94
104
 
95
105
  const boundaryError = new Error("An unexpected server error occurred.");
@@ -133,6 +143,7 @@ function createRouteModuleOptions(projectRoot, mode, options = {}) {
133
143
  mode,
134
144
  ssr: true,
135
145
  staticAssetPublicUrls: resolvedStaticAssetPublicUrls,
146
+ onDevelopmentEvent: options.onDevelopmentEvent,
136
147
  };
137
148
  }
138
149
 
@@ -153,26 +164,277 @@ function getNearestBoundary(route, boundaryName) {
153
164
  return (route?.[boundaryName] ?? []).at(-1)?.module ?? null;
154
165
  }
155
166
 
156
- async function renderComponentTree(component, layoutComponents, requestContext, request, extraProps = {}) {
157
- let renderedTree = await component({
158
- params: requestContext.params,
159
- searchParams: requestContext.searchParams,
160
- request,
161
- ...extraProps,
162
- });
167
+ async function renderComponentTree(
168
+ component,
169
+ layoutComponents,
170
+ requestContext,
171
+ request,
172
+ extraProps = {},
173
+ segments = [],
174
+ ) {
175
+ let renderedTree = wrapRouteSegment(
176
+ segments.at(-1),
177
+ await component({
178
+ params: requestContext.params,
179
+ searchParams: requestContext.searchParams,
180
+ request,
181
+ ...extraProps,
182
+ }),
183
+ );
163
184
 
164
185
  for (let index = layoutComponents.length - 1; index >= 0; index -= 1) {
165
- renderedTree = await layoutComponents[index]({
186
+ renderedTree = wrapRouteSegment(segments[index], await layoutComponents[index]({
166
187
  params: requestContext.params,
167
188
  searchParams: requestContext.searchParams,
168
189
  request,
169
190
  children: renderedTree,
170
- });
191
+ }));
171
192
  }
172
193
 
173
194
  return renderedTree;
174
195
  }
175
196
 
197
+ function createSegmentChildrenMarker(segment) {
198
+ return `<!--evolit:children:${segment.id}-->`;
199
+ }
200
+
201
+ function combineSegmentSsrArtifacts(results) {
202
+ const roots = [];
203
+ const payload = { roots: {}, instances: {} };
204
+ const clientImports = new Set();
205
+ const headTags = new Set();
206
+
207
+ for (const result of results) {
208
+ if (!result) continue;
209
+ for (const clientImport of result.clientImports ?? []) clientImports.add(clientImport);
210
+ for (const headTag of result.headTags ?? []) headTags.add(headTag);
211
+ if (!result.hydrationData) continue;
212
+ roots.push(...(result.hydrationData.roots ?? []));
213
+ Object.assign(payload.roots, result.hydrationData.payload?.roots ?? {});
214
+ Object.assign(payload.instances, result.hydrationData.payload?.instances ?? {});
215
+ }
216
+
217
+ return {
218
+ clientImports: [...clientImports],
219
+ headTags: [...headTags],
220
+ hydrationData: roots.length > 0
221
+ ? { version: 1, roots, payload, clientImports: [...clientImports] }
222
+ : null,
223
+ };
224
+ }
225
+
226
+ function createTrackedRouteValues(values) {
227
+ const accessedKeys = new Set();
228
+ let accessedAll = false;
229
+ const value = new Proxy(values, {
230
+ get(target, property, receiver) {
231
+ if (typeof property === "string") accessedKeys.add(property);
232
+ return Reflect.get(target, property, receiver);
233
+ },
234
+ has(target, property) {
235
+ if (typeof property === "string") accessedKeys.add(property);
236
+ return Reflect.has(target, property);
237
+ },
238
+ ownKeys(target) {
239
+ accessedAll = true;
240
+ return Reflect.ownKeys(target);
241
+ },
242
+ });
243
+
244
+ return {
245
+ value,
246
+ profile() {
247
+ return accessedAll ? { all: true, keys: [] } : { all: false, keys: [...accessedKeys].sort() };
248
+ },
249
+ };
250
+ }
251
+
252
+ function createSegmentCacheKey(segment, idPrefix, profile, params, searchParams) {
253
+ const select = (values) => Object.fromEntries(
254
+ (profile.all ? Object.keys(values) : profile.keys)
255
+ .sort()
256
+ .map((key) => [key, values[key]]),
257
+ );
258
+ return JSON.stringify({
259
+ modulePath: segment.modulePath,
260
+ idPrefix,
261
+ params: select(params),
262
+ searchParams: select(searchParams),
263
+ });
264
+ }
265
+
266
+ function getSegmentCacheExpiry(cachePolicy) {
267
+ if (cachePolicy?.mode === "static") return Number.POSITIVE_INFINITY;
268
+ if (cachePolicy?.mode === "revalidate" && Number.isFinite(cachePolicy.ttlSeconds)) {
269
+ return Date.now() + (cachePolicy.ttlSeconds * 1_000);
270
+ }
271
+ return null;
272
+ }
273
+
274
+ function createSegmentRenderCache(projectRoot, onDevelopmentEvent) {
275
+ const entries = new Map();
276
+ const profiles = new Map();
277
+
278
+ return {
279
+ get(segment, idPrefix, params, searchParams) {
280
+ const profile = profiles.get(segment.modulePath);
281
+ if (!profile) {
282
+ onDevelopmentEvent?.({ type: "segment-cache-miss", modulePath: segment.modulePath, reason: "cold" });
283
+ return null;
284
+ }
285
+ const key = createSegmentCacheKey(segment, idPrefix, profile, params, searchParams);
286
+ const entry = entries.get(key);
287
+ if (!entry || entry.expiresAt <= Date.now()) {
288
+ if (entry) entries.delete(key);
289
+ onDevelopmentEvent?.({
290
+ type: "segment-cache-miss",
291
+ modulePath: segment.modulePath,
292
+ reason: entry ? "expired" : "key-change",
293
+ });
294
+ return null;
295
+ }
296
+ onDevelopmentEvent?.({ type: "segment-cache-hit", modulePath: segment.modulePath });
297
+ return entry.result;
298
+ },
299
+ set(segment, idPrefix, params, searchParams, profile, result, cachePolicy) {
300
+ const expiresAt = getSegmentCacheExpiry(cachePolicy);
301
+ if (expiresAt == null) return;
302
+ profiles.set(segment.modulePath, profile);
303
+ const key = createSegmentCacheKey(segment, idPrefix, profile, params, searchParams);
304
+ entries.set(key, { result, expiresAt, modulePath: segment.modulePath });
305
+ if (entries.size > 256) entries.delete(entries.keys().next().value);
306
+ },
307
+ invalidate(changedPaths = null) {
308
+ if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
309
+ const entryCount = entries.size;
310
+ entries.clear();
311
+ profiles.clear();
312
+ return entryCount;
313
+ }
314
+ const normalizedPaths = new Set(changedPaths.map((changedPath) => path.resolve(changedPath)));
315
+ const hasSharedChange = [...normalizedPaths].some((changedPath) =>
316
+ !path.relative(projectRoot, changedPath).startsWith(`app${path.sep}`),
317
+ );
318
+ if (hasSharedChange) {
319
+ const entryCount = entries.size;
320
+ entries.clear();
321
+ profiles.clear();
322
+ return entryCount;
323
+ }
324
+ let entryCount = 0;
325
+ for (const [key, entry] of entries) {
326
+ if (normalizedPaths.has(path.resolve(projectRoot, entry.modulePath))) {
327
+ entries.delete(key);
328
+ entryCount += 1;
329
+ }
330
+ }
331
+ for (const modulePath of profiles.keys()) {
332
+ if (normalizedPaths.has(path.resolve(projectRoot, modulePath))) profiles.delete(modulePath);
333
+ }
334
+ return entryCount;
335
+ },
336
+ clear() {
337
+ const entryCount = entries.size;
338
+ entries.clear();
339
+ profiles.clear();
340
+ return entryCount;
341
+ },
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Renders route segments separately, then composes layout shells through an
347
+ * internal children marker. Authored layouts still receive a normal Lit
348
+ * renderable and can project children any number of times.
349
+ */
350
+ async function renderSegmentedComponentTree(
351
+ component,
352
+ layoutComponents,
353
+ requestContext,
354
+ request,
355
+ extraProps = {},
356
+ segments = [],
357
+ options = {},
358
+ ) {
359
+ if (segments.length !== layoutComponents.length + 1) return null;
360
+ const results = [];
361
+
362
+ async function renderSegmentAt(index, projectionPath) {
363
+ const segment = segments[index];
364
+ const idPrefix = `${segment.id}-p${projectionPath.join("-") || "0"}`;
365
+
366
+ if (index === layoutComponents.length) {
367
+ const pageValue = await component({
368
+ params: requestContext.params,
369
+ searchParams: requestContext.searchParams,
370
+ request,
371
+ ...extraProps,
372
+ });
373
+ const pageResult = await renderToString(wrapRouteSegment(segment, pageValue), {
374
+ assetResolver: options.assetResolver,
375
+ context: { idPrefix },
376
+ });
377
+ results.push(pageResult);
378
+ return pageResult.html;
379
+ }
380
+
381
+ const childrenMarker = createSegmentChildrenMarker(segment);
382
+ let layoutResult = options.segmentCache?.get(
383
+ segment,
384
+ idPrefix,
385
+ requestContext.params,
386
+ requestContext.searchParams,
387
+ ) ?? null;
388
+ if (!layoutResult) {
389
+ const trackedParams = createTrackedRouteValues(requestContext.params);
390
+ const trackedSearchParams = createTrackedRouteValues(requestContext.searchParams);
391
+ const didUseDynamicRequestData = requestContext.didUseDynamicRequestData;
392
+ const layoutValue = await layoutComponents[index]({
393
+ params: trackedParams.value,
394
+ searchParams: trackedSearchParams.value,
395
+ request,
396
+ children: html`${unsafeHTML(childrenMarker)}`,
397
+ });
398
+ layoutResult = await renderToString(wrapRouteSegment(segment, layoutValue), {
399
+ assetResolver: options.assetResolver,
400
+ context: { idPrefix },
401
+ });
402
+ if (!didUseDynamicRequestData && !requestContext.didUseDynamicRequestData) {
403
+ options.segmentCache?.set(
404
+ segment,
405
+ idPrefix,
406
+ requestContext.params,
407
+ requestContext.searchParams,
408
+ {
409
+ all: trackedParams.profile().all || trackedSearchParams.profile().all,
410
+ keys: [...new Set([
411
+ ...trackedParams.profile().keys,
412
+ ...trackedSearchParams.profile().keys,
413
+ ])].sort(),
414
+ },
415
+ layoutResult,
416
+ options.cachePolicy,
417
+ );
418
+ }
419
+ }
420
+ results.push(layoutResult);
421
+ const projectionCount = layoutResult.html.split(childrenMarker).length - 1;
422
+ if (projectionCount === 0) return null;
423
+
424
+ const childHtml = [];
425
+ for (let projection = 0; projection < projectionCount; projection += 1) {
426
+ const renderedChild = await renderSegmentAt(index + 1, [...projectionPath, projection]);
427
+ if (renderedChild == null) return null;
428
+ childHtml.push(renderedChild);
429
+ }
430
+ let projection = 0;
431
+ return layoutResult.html.replaceAll(childrenMarker, () => childHtml[projection++]);
432
+ }
433
+
434
+ const tree = await renderSegmentAt(0, []);
435
+ return tree == null ? null : { tree, ssrArtifacts: combineSegmentSsrArtifacts(results) };
436
+ }
437
+
176
438
  export async function resolveStaticParamsForRoute(route, projectRoot, mode = "production", options = {}) {
177
439
  const { pageModule, layoutModules } = await loadRouteModules(route, projectRoot, mode, options);
178
440
  const generators = [...layoutModules, pageModule]
@@ -217,6 +479,8 @@ export async function resolveStaticParamsForRoute(route, projectRoot, mode = "pr
217
479
  export async function createRouteResolver(projectRoot, mode = "development", resolverOptions = {}) {
218
480
  const routes = await discoverAppRoutes(projectRoot);
219
481
  const routeHandlers = await discoverAppRouteHandlers(projectRoot);
482
+ const segmentRenderCache = resolverOptions.segmentRenderCache
483
+ ?? createSegmentRenderCache(projectRoot, resolverOptions.onDevelopmentEvent);
220
484
  const rootLayoutPath = await findAppModule(projectRoot, "layout");
221
485
  const rootNotFoundPath = await findAppModule(projectRoot, "not-found");
222
486
 
@@ -384,7 +648,10 @@ export async function createRouteResolver(projectRoot, mode = "development", res
384
648
  return renderNotFound(request);
385
649
  }
386
650
 
387
- const { pageModule, layoutModules } = await loadRouteModules(
651
+ const preloadedModules = options.routePolicyResult?.route === match.route
652
+ ? options.routePolicyResult.__evolitLoadedModules
653
+ : null;
654
+ const { pageModule, layoutModules } = preloadedModules ?? await loadRouteModules(
388
655
  match.route,
389
656
  projectRoot,
390
657
  mode,
@@ -394,7 +661,7 @@ export async function createRouteResolver(projectRoot, mode = "development", res
394
661
  const cachePolicy = normalizeRouteCachePolicy(routeConfig);
395
662
 
396
663
  if (options.renderTree === false) {
397
- return {
664
+ const routePolicyResult = {
398
665
  type: "route",
399
666
  status: 200,
400
667
  route: match.route,
@@ -404,6 +671,10 @@ export async function createRouteResolver(projectRoot, mode = "development", res
404
671
  cachePolicy,
405
672
  cacheKey: createRequestCacheKey(url),
406
673
  };
674
+ Object.defineProperty(routePolicyResult, "__evolitLoadedModules", {
675
+ value: { pageModule, layoutModules },
676
+ });
677
+ return routePolicyResult;
407
678
  }
408
679
 
409
680
  const pageComponent = assertServerComponent(
@@ -425,8 +696,31 @@ export async function createRouteResolver(projectRoot, mode = "development", res
425
696
  });
426
697
 
427
698
  try {
428
- const tree = await runWithRequestContext(requestContext, () =>
429
- renderComponentTree(pageComponent, layoutComponents, requestContext, requestContext.routeRequest),
699
+ const segments = createRouteSegmentPlan(match.route, projectRoot);
700
+ const segmentedRender = await runWithRequestContext(requestContext, () =>
701
+ renderSegmentedComponentTree(
702
+ pageComponent,
703
+ layoutComponents,
704
+ requestContext,
705
+ requestContext.routeRequest,
706
+ {},
707
+ segments,
708
+ {
709
+ assetResolver: resolverOptions.assetResolver,
710
+ segmentCache: segmentRenderCache,
711
+ cachePolicy,
712
+ },
713
+ ),
714
+ );
715
+ const tree = segmentedRender?.tree ?? await runWithRequestContext(requestContext, () =>
716
+ renderComponentTree(
717
+ pageComponent,
718
+ layoutComponents,
719
+ requestContext,
720
+ requestContext.routeRequest,
721
+ {},
722
+ segments,
723
+ ),
430
724
  );
431
725
  const contextResponse = getRequestContextResponse(requestContext);
432
726
 
@@ -439,6 +733,8 @@ export async function createRouteResolver(projectRoot, mode = "development", res
439
733
  params: match.params,
440
734
  searchParams: requestContext.searchParams,
441
735
  routeConfig,
736
+ segments,
737
+ ssrArtifacts: segmentedRender?.ssrArtifacts ?? null,
442
738
  cachePolicy: contextResponse.didUseDynamicRequestData ? { mode: "dynamic" } : cachePolicy,
443
739
  cacheKey: createRequestCacheKey(url),
444
740
  responseHeaders: contextResponse.headers,
@@ -480,10 +776,18 @@ export async function createRouteResolver(projectRoot, mode = "development", res
480
776
  route: match.route,
481
777
  reportRouteError: resolverOptions.reportRouteError,
482
778
  });
779
+ const segments = createRouteSegmentPlan(match.route, projectRoot, {
780
+ boundaryModule: errorBoundaryPath,
781
+ });
483
782
  const tree = await runWithRequestContext(requestContext, () =>
484
- renderComponentTree(boundary.component, layoutComponents, requestContext, requestContext.routeRequest, {
485
- error: boundaryError,
486
- }),
783
+ renderComponentTree(
784
+ boundary.component,
785
+ layoutComponents,
786
+ requestContext,
787
+ requestContext.routeRequest,
788
+ { error: boundaryError },
789
+ segments,
790
+ ),
487
791
  );
488
792
  const contextResponse = getRequestContextResponse(requestContext);
489
793
  return {
@@ -491,6 +795,7 @@ export async function createRouteResolver(projectRoot, mode = "development", res
491
795
  status: 500,
492
796
  tree,
493
797
  boundaryModule: errorBoundaryPath,
798
+ segments,
494
799
  metadata: mergeMetadata([...layoutModules, boundary.moduleRecord]),
495
800
  route: match.route,
496
801
  params: match.params,
@@ -509,8 +814,12 @@ export async function createRouteResolver(projectRoot, mode = "development", res
509
814
  async resolveRoutePolicy(request) {
510
815
  return resolveMatchedRequest(request, { renderTree: false });
511
816
  },
512
- async resolveRequest(request) {
513
- return resolveMatchedRequest(request, { renderTree: true });
817
+ async resolveRequest(request, routePolicyResult = null) {
818
+ return resolveMatchedRequest(request, { renderTree: true, routePolicyResult });
819
+ },
820
+ invalidateSegmentCache(changedPaths = null) {
821
+ return segmentRenderCache.invalidate(changedPaths);
514
822
  },
823
+ segmentRenderCache,
515
824
  };
516
825
  }
@@ -1,3 +1,5 @@
1
+ export const DEFAULT_REVALIDATE_SECONDS = 60;
2
+
1
3
  function normalizeRevalidatePolicy(cacheConfig) {
2
4
  if (!cacheConfig || typeof cacheConfig !== "object" || Array.isArray(cacheConfig)) {
3
5
  throw new Error("Expected routeConfig.cache to be an object when using revalidate mode.");
@@ -15,7 +17,7 @@ function normalizeRevalidatePolicy(cacheConfig) {
15
17
  }
16
18
 
17
19
  export function normalizeRouteCachePolicy(routeConfig = {}) {
18
- const cacheConfig = routeConfig?.cache ?? "dynamic";
20
+ const cacheConfig = routeConfig?.cache ?? { revalidate: DEFAULT_REVALIDATE_SECONDS };
19
21
 
20
22
  if (cacheConfig === "dynamic") {
21
23
  return { mode: "dynamic" };