evolit 0.2.1 → 0.3.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
@@ -46,6 +46,26 @@ Generated applications declare `"@/*": ["./*"]` in `jsconfig.json`, so editors a
46
46
  use the same convention. An explicit `@/*` mapping in `jsconfig.json` or `tsconfig.json` takes
47
47
  priority when an application needs a different source root.
48
48
 
49
+ ### Interpolated dynamic imports
50
+
51
+ Relative dynamic imports may use a template literal when every possible module can be discovered
52
+ from the literal path at build time:
53
+
54
+ ```tsx
55
+ const module = await import(`./templates/${locale}/${name}.tsx`);
56
+ ```
57
+
58
+ Evolit treats each interpolation as one wildcard that matches a single path segment. The example
59
+ above discovers `./templates/*/*.tsx`, compiles every matching module, and emits a finite runtime
60
+ dispatcher whose cases preserve the original source specifiers. Interpolations never cross a `/`,
61
+ and there is no framework-imposed candidate limit.
62
+
63
+ The template must be relative, end in a supported authored module extension, and match at least one
64
+ module during compilation. A runtime value that was not among the discovered candidates rejects the
65
+ import. Development watches the matched directory tree, so adding a new candidate invalidates the
66
+ affected graph. Arbitrary computed specifiers that do not use this template-literal form remain
67
+ outside the statically discoverable graph.
68
+
49
69
  ### Package CSS and static assets
50
70
 
51
71
  Applications can import stylesheets exposed through package `exports` in the same way as local
@@ -301,6 +321,32 @@ server components and helpers that do not receive route props directly. It retur
301
321
  Reading `getRouteState().url` has the same dynamic-rendering semantics as `requestUrl()`; reading
302
322
  `params` and `searchParams` participates in the normal segment-cache key tracking.
303
323
 
324
+ ## Server Setup
325
+
326
+ Server integrations that need one-time application setup can be registered with
327
+ `server.setup`. A module specifier is compiled as part of the server graph, so the
328
+ setup may be authored in JavaScript or TypeScript:
329
+
330
+ ```js
331
+ // evolit.config.js
332
+ export default {
333
+ server: {
334
+ setup: "./src/server/setup.ts",
335
+ },
336
+ };
337
+ ```
338
+
339
+ The module exports `setup` (or a default function). Evolit invokes it once per
340
+ runtime and once before build-time prerendering with `{ projectRoot, mode }`.
341
+ It may return a cleanup function or `{ dispose() }`; runtime cleanup runs from
342
+ `runtime.close()`.
343
+
344
+ When `@litsx/urql` is installed, Evolit opens its SSR resource around the whole
345
+ route render and supplies `{ request, responseHeaders }`. Request-derived URQL
346
+ configuration therefore stays isolated per render, extracted SSR data is read
347
+ before the scope closes, and integration response headers are included before
348
+ the response is cached.
349
+
304
350
  ## Extensions
305
351
 
306
352
  Optional integrations are configured explicitly in `evolit.config.js`. Core only coordinates their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolit",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -71,7 +71,7 @@
71
71
  "ws": "^8.18.3"
72
72
  },
73
73
  "peerDependencies": {
74
- "@litsx/urql": "^0.3.0"
74
+ "@litsx/urql": "^0.4.0"
75
75
  },
76
76
  "peerDependenciesMeta": {
77
77
  "@litsx/urql": {
package/src/build.js CHANGED
@@ -51,7 +51,13 @@ import {
51
51
  import { serializeRouteCachePolicy } from "./route-config.js";
52
52
  import { createSsrAdapter, renderRouteTreeWithAdapter } from "./ssr-adapter.js";
53
53
  import { ensureDirectory, writeJson } from "./fs-utils.js";
54
- import { appendSsrUrqlData, runWithOptionalSsrUrqlScope } from "./urql-ssr.js";
54
+ import {
55
+ appendSsrUrqlData,
56
+ applySsrUrqlRequestContext,
57
+ createSsrUrqlRequestContext,
58
+ runWithOptionalSsrUrqlScope,
59
+ } from "./urql-ssr.js";
60
+ import { runServerSetup } from "./server-setup.js";
55
61
 
56
62
  const CONTENT_TYPE_BY_EXTENSION = new Map([
57
63
  [".css", "text/css; charset=utf-8"],
@@ -157,6 +163,11 @@ async function writeDeploymentRuntimeEntry(buildRoot) {
157
163
 
158
164
  export async function buildProject(projectRoot, options = {}) {
159
165
  const evolitConfig = await loadEvolitConfig(projectRoot);
166
+ const serverSetupCleanup = await runServerSetup({
167
+ projectRoot,
168
+ mode: "production",
169
+ evolitConfig,
170
+ });
160
171
  const extensions = resolveEvolitExtensions(evolitConfig);
161
172
  const extensionClientDescriptors = getExtensionClientDescriptors(extensions);
162
173
  const packageClientSpecifiers = new Set(
@@ -541,23 +552,32 @@ export async function buildProject(projectRoot, options = {}) {
541
552
  seenPrerenderTargets.add(targetPathname);
542
553
 
543
554
  const targetRequest = new Request(`http://evolit.local${targetPathname}`);
544
- const { routeResult, response } = await runWithOptionalSsrUrqlScope(async (urqlAdapter) => {
555
+ const urqlRequestContext = createSsrUrqlRequestContext(targetRequest);
556
+ const { routeResult, response } = await runWithOptionalSsrUrqlScope(urqlRequestContext, async (urqlAdapter) => {
545
557
  const resolvedRouteResult = await routeResolver.resolveRequest(targetRequest);
546
558
  if (resolvedRouteResult.type !== "route" || resolvedRouteResult.cachePolicy.mode === "dynamic") {
547
559
  return { routeResult: resolvedRouteResult, response: null };
548
560
  }
549
561
 
550
562
  const renderedResponse = await renderRouteTreeWithAdapter(resolvedRouteResult, ssrAdapter);
563
+ const responseWithData = urqlAdapter
564
+ ? appendSsrUrqlData(renderedResponse, await urqlAdapter.getUrqlSsrData())
565
+ : renderedResponse;
551
566
  return {
552
567
  routeResult: resolvedRouteResult,
553
- response: urqlAdapter
554
- ? appendSsrUrqlData(renderedResponse, await urqlAdapter.getUrqlSsrData())
555
- : renderedResponse,
568
+ response: applySsrUrqlRequestContext(
569
+ resolvedRouteResult,
570
+ responseWithData,
571
+ urqlRequestContext,
572
+ ),
556
573
  };
557
574
  });
558
575
  if (!response) {
559
576
  continue;
560
577
  }
578
+ if (routeResult.cachePolicy.mode === "dynamic") {
579
+ continue;
580
+ }
561
581
  if (response.status !== 200) {
562
582
  continue;
563
583
  }
@@ -656,5 +676,7 @@ export async function buildProject(projectRoot, options = {}) {
656
676
  await writeJson(path.join(buildRoot, DEPLOY_ASSETS_MANIFEST_FILENAME), deployAssets);
657
677
  await writeJson(path.join(buildRoot, DEPLOY_SERVER_MANIFEST_FILENAME), deployServer);
658
678
 
679
+ await serverSetupCleanup?.();
680
+
659
681
  return manifestPath;
660
682
  }
package/src/compiler.js CHANGED
@@ -20,6 +20,7 @@ import { ensureDirectory } from "./fs-utils.js";
20
20
  const MODULE_SPECIFIER_PATTERN =
21
21
  /\b(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
22
22
  const CSS_DEPENDENCY_PATTERN = /(?:@import\s+(?:url\(\s*)?["']([^"']+)["']\s*\)?|url\(\s*["']?([^"')]+)["']?\s*\))/g;
23
+ const DYNAMIC_IMPORT_WILDCARD = "\u0000EVOLIT_DYNAMIC_IMPORT_WILDCARD\u0000";
23
24
  const RESOLVABLE_IMPORT_EXTENSIONS = [
24
25
  ...MODULE_EXTENSIONS,
25
26
  ...STATIC_ASSET_EXTENSIONS,
@@ -388,17 +389,30 @@ function getCompiledServerComponentExports(code, sourcePath) {
388
389
  return exports;
389
390
  }
390
391
 
391
- function collectImportedNamesBySpecifier(code, sourcePath) {
392
- const sourceFile = ts.createSourceFile(sourcePath, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
392
+ function analyzeModuleReferences(code, sourcePath, scriptKind = ts.ScriptKind.JS) {
393
+ const sourceFile = ts.createSourceFile(sourcePath, code, ts.ScriptTarget.Latest, true, scriptKind);
393
394
  const bySpecifier = new Map();
395
+ const staticReferences = [];
396
+ const dynamicReferences = [];
394
397
  const add = (specifier, name) => {
395
398
  const names = bySpecifier.get(specifier) ?? new Set();
396
399
  names.add(name);
397
400
  bySpecifier.set(specifier, names);
398
401
  };
402
+ const addStaticReference = (node, importedName = null) => {
403
+ const specifier = node.text;
404
+ staticReferences.push({
405
+ specifier,
406
+ start: node.getStart(sourceFile) + 1,
407
+ end: node.end - 1,
408
+ });
409
+ if (importedName) add(specifier, importedName);
410
+ };
411
+
399
412
  for (const statement of sourceFile.statements) {
400
- if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
413
+ if (ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier)) {
401
414
  const specifier = statement.moduleSpecifier.text;
415
+ addStaticReference(statement.moduleSpecifier);
402
416
  if (statement.importClause?.name) add(specifier, "default");
403
417
  if (statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)) {
404
418
  for (const element of statement.importClause.namedBindings.elements) {
@@ -408,9 +422,10 @@ function collectImportedNamesBySpecifier(code, sourcePath) {
408
422
  } else if (
409
423
  ts.isExportDeclaration(statement)
410
424
  && statement.moduleSpecifier
411
- && ts.isStringLiteral(statement.moduleSpecifier)
425
+ && ts.isStringLiteralLike(statement.moduleSpecifier)
412
426
  ) {
413
427
  const specifier = statement.moduleSpecifier.text;
428
+ addStaticReference(statement.moduleSpecifier);
414
429
  if (!statement.exportClause) add(specifier, "*");
415
430
  else if (ts.isNamedExports(statement.exportClause)) {
416
431
  for (const element of statement.exportClause.elements) add(specifier, element.propertyName?.text ?? element.name.text);
@@ -421,13 +436,119 @@ function collectImportedNamesBySpecifier(code, sourcePath) {
421
436
  if (
422
437
  ts.isCallExpression(node)
423
438
  && node.expression.kind === ts.SyntaxKind.ImportKeyword
424
- && node.arguments.length === 1
425
- && ts.isStringLiteral(node.arguments[0])
426
- ) add(node.arguments[0].text, "*");
439
+ && node.arguments.length >= 1
440
+ ) {
441
+ const argument = node.arguments[0];
442
+ if (ts.isStringLiteralLike(argument)) {
443
+ addStaticReference(argument, "*");
444
+ } else if (ts.isTemplateExpression(argument)) {
445
+ const staticParts = [
446
+ argument.head.text,
447
+ ...argument.templateSpans.map((span) => span.literal.text),
448
+ ];
449
+ dynamicReferences.push({
450
+ pattern: staticParts.join(DYNAMIC_IMPORT_WILDCARD),
451
+ displayPattern: staticParts.join("*"),
452
+ expressionText: code.slice(argument.getStart(sourceFile), argument.end),
453
+ optionsText: node.arguments.length > 1
454
+ ? code.slice(node.arguments[1].getStart(sourceFile), node.arguments.at(-1).end)
455
+ : null,
456
+ start: node.getStart(sourceFile),
457
+ end: node.end,
458
+ });
459
+ }
460
+ }
427
461
  ts.forEachChild(node, visit);
428
462
  };
429
463
  visit(sourceFile);
430
- return bySpecifier;
464
+
465
+ return {
466
+ importedNamesBySpecifier: bySpecifier,
467
+ staticReferences,
468
+ dynamicReferences,
469
+ };
470
+ }
471
+
472
+ function escapeRegExp(value) {
473
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
474
+ }
475
+
476
+ function createDynamicImportSegmentPattern(segment) {
477
+ return new RegExp(
478
+ `^${segment.split(DYNAMIC_IMPORT_WILDCARD).map(escapeRegExp).join("[^/]+")}$`,
479
+ );
480
+ }
481
+
482
+ async function discoverDynamicImportCandidates({
483
+ projectRoot,
484
+ sourcePath,
485
+ pattern,
486
+ displayPattern,
487
+ managedSourceRoots,
488
+ }) {
489
+ const normalizedPattern = pattern.replaceAll("\\", "/");
490
+ if (!isRelativeSpecifier(normalizedPattern)) {
491
+ throw new Error(
492
+ `Dynamic import pattern ${JSON.stringify(displayPattern)} in ${sourcePath} must be relative.`,
493
+ );
494
+ }
495
+ if (!MODULE_EXTENSIONS.some((extension) => normalizedPattern.endsWith(extension))) {
496
+ throw new Error(
497
+ `Dynamic import pattern ${JSON.stringify(displayPattern)} in ${sourcePath} must end in a supported module extension.`,
498
+ );
499
+ }
500
+
501
+ const segments = normalizedPattern.split("/");
502
+ const firstDynamicSegment = segments.findIndex((segment) => segment.includes(DYNAMIC_IMPORT_WILDCARD));
503
+ if (firstDynamicSegment < 0) return { candidates: [], watchedDirectories: [] };
504
+
505
+ const scanRoot = path.resolve(path.dirname(sourcePath), ...segments.slice(0, firstDynamicSegment));
506
+ if (isPathOutsideManagedRoots(projectRoot, managedSourceRoots, scanRoot)) {
507
+ throw new Error(
508
+ `Dynamic import pattern ${JSON.stringify(displayPattern)} in ${sourcePath} resolves outside the managed source roots.`,
509
+ );
510
+ }
511
+
512
+ const segmentPatterns = segments
513
+ .slice(firstDynamicSegment)
514
+ .map(createDynamicImportSegmentPattern);
515
+ const candidates = [];
516
+ const watchedDirectories = new Set();
517
+
518
+ async function visitDirectory(directory, segmentIndex) {
519
+ watchedDirectories.add(directory);
520
+ let entries;
521
+ try {
522
+ entries = await fs.readdir(directory, { withFileTypes: true });
523
+ } catch (error) {
524
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return;
525
+ throw error;
526
+ }
527
+
528
+ const matcher = segmentPatterns[segmentIndex];
529
+ const isFinalSegment = segmentIndex === segmentPatterns.length - 1;
530
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
531
+ if (!matcher.test(entry.name)) continue;
532
+ const candidatePath = path.join(directory, entry.name);
533
+ if (isFinalSegment) {
534
+ if (entry.isFile()) candidates.push(candidatePath);
535
+ } else if (entry.isDirectory()) {
536
+ await visitDirectory(candidatePath, segmentIndex + 1);
537
+ }
538
+ }
539
+ }
540
+
541
+ await visitDirectory(scanRoot, 0);
542
+ if (candidates.length === 0) {
543
+ throw new Error(
544
+ `Dynamic import pattern ${JSON.stringify(displayPattern)} in ${sourcePath} did not match any modules.`,
545
+ );
546
+ }
547
+
548
+ return {
549
+ candidates,
550
+ watchedDirectories: [...watchedDirectories],
551
+ };
431
552
  }
432
553
 
433
554
  function createClientServerComponentImportError(entryPath, sourcePath, parents) {
@@ -841,18 +962,17 @@ async function rewriteRelativeSpecifiers({
841
962
  managedSourceRoots,
842
963
  serverExportsByModule,
843
964
  packageImports,
965
+ dynamicImportDirectories,
844
966
  }) {
845
967
  const magicSource = new MagicString(code);
846
968
  let didRewrite = false;
969
+ const moduleReferences = analyzeModuleReferences(code, sourcePath);
847
970
  const importedNamesBySpecifier = target === "client"
848
- ? collectImportedNamesBySpecifier(code, sourcePath)
971
+ ? moduleReferences.importedNamesBySpecifier
849
972
  : new Map();
850
973
 
851
- for (const match of code.matchAll(MODULE_SPECIFIER_PATTERN)) {
852
- const specifier = match[1] ?? match[2] ?? null;
853
- if (!specifier) {
854
- continue;
855
- }
974
+ for (const reference of moduleReferences.staticReferences) {
975
+ const { specifier } = reference;
856
976
 
857
977
  const aliasedImportPath = isBareSpecifier(specifier)
858
978
  ? await resolveProjectMappedImport(projectRoot, sourcePath, specifier)
@@ -996,18 +1116,99 @@ async function rewriteRelativeSpecifiers({
996
1116
  ) {
997
1117
  normalizedReplacement = `${normalizedReplacement}?${serverImportQuery}`;
998
1118
  }
999
- const quotedSpecifierIndex = match.index + match[0].indexOf(specifier);
1000
1119
  const replacementValue = normalizedReplacement.split(path.sep).join("/");
1001
1120
  if (replacementValue !== specifier) {
1002
1121
  didRewrite = true;
1003
1122
  magicSource.update(
1004
- quotedSpecifierIndex,
1005
- quotedSpecifierIndex + specifier.length,
1123
+ reference.start,
1124
+ reference.end,
1006
1125
  replacementValue,
1007
1126
  );
1008
1127
  }
1009
1128
  }
1010
1129
 
1130
+ const dynamicImportHelpers = [];
1131
+ for (const [index, reference] of moduleReferences.dynamicReferences.entries()) {
1132
+ const discovery = await discoverDynamicImportCandidates({
1133
+ projectRoot,
1134
+ sourcePath,
1135
+ pattern: reference.pattern,
1136
+ displayPattern: reference.displayPattern,
1137
+ managedSourceRoots,
1138
+ });
1139
+ discovery.watchedDirectories.forEach((directory) => dynamicImportDirectories.add(directory));
1140
+
1141
+ const cases = [];
1142
+ for (const resolvedImportPath of discovery.candidates) {
1143
+ const compiledImportPath = await compileModule(resolvedImportPath);
1144
+ const serverExports = serverExportsByModule?.get(resolvedImportPath) ?? new Set();
1145
+ if (target === "client" && serverExports.size > 0) {
1146
+ throw new Error(
1147
+ `Client module ${sourcePath} dynamically imports Server Component export(s) from ${resolvedImportPath}: `
1148
+ + `${[...serverExports].join(", ")}. Split the server exports into a separate module.`,
1149
+ );
1150
+ }
1151
+
1152
+ const importerOutputPath = toOutputPath(projectRoot, outputRoot, sourcePath);
1153
+ let replacementPath = path.relative(path.dirname(importerOutputPath), compiledImportPath);
1154
+ if (!replacementPath.startsWith(".")) replacementPath = `./${replacementPath}`;
1155
+ replacementPath = replacementPath.split(path.sep).join("/");
1156
+ if (
1157
+ target === "server"
1158
+ && mode === "development"
1159
+ && typeof serverImportQuery === "string"
1160
+ && serverImportQuery.length > 0
1161
+ ) {
1162
+ replacementPath = `${replacementPath}?${serverImportQuery}`;
1163
+ }
1164
+
1165
+ let sourceSpecifier = path.relative(path.dirname(sourcePath), resolvedImportPath)
1166
+ .split(path.sep)
1167
+ .join("/");
1168
+ if (!sourceSpecifier.startsWith(".")) sourceSpecifier = `./${sourceSpecifier}`;
1169
+ cases.push(
1170
+ ` case ${JSON.stringify(sourceSpecifier)}: return import(${JSON.stringify(replacementPath)}${reference.optionsText ? ", options" : ""});`,
1171
+ );
1172
+
1173
+ if (target === "client") {
1174
+ const sourceMetadata = moduleMetadata.get(sourcePath) ?? {
1175
+ moduleImports: new Set(),
1176
+ vendorImports: new Set(),
1177
+ styleImports: new Set(),
1178
+ assetImports: new Set(),
1179
+ };
1180
+ sourceMetadata.moduleImports.add(
1181
+ path.relative(outputRoot, compiledImportPath).split(path.sep).join("/"),
1182
+ );
1183
+ moduleMetadata.set(sourcePath, sourceMetadata);
1184
+ }
1185
+ }
1186
+
1187
+ let helperName = `__evolit_dynamic_import_${index}`;
1188
+ while (code.includes(helperName)) helperName = `_${helperName}`;
1189
+ const sourceModuleId = path.relative(projectRoot, sourcePath).split(path.sep).join("/");
1190
+ const unknownImportPrefix = `Unknown dynamic import in ${sourceModuleId}: `;
1191
+ dynamicImportHelpers.push([
1192
+ `function ${helperName}(specifier${reference.optionsText ? ", options" : ""}) {`,
1193
+ " switch (specifier) {",
1194
+ ...cases,
1195
+ " default:",
1196
+ ` return Promise.reject(new Error(${JSON.stringify(unknownImportPrefix)} + specifier));`,
1197
+ " }",
1198
+ "}",
1199
+ ].join("\n"));
1200
+ magicSource.update(
1201
+ reference.start,
1202
+ reference.end,
1203
+ `${helperName}(${reference.expressionText}${reference.optionsText ? `, ${reference.optionsText}` : ""})`,
1204
+ );
1205
+ didRewrite = true;
1206
+ }
1207
+
1208
+ if (dynamicImportHelpers.length > 0) {
1209
+ magicSource.append(`\n\n${dynamicImportHelpers.join("\n\n")}\n`);
1210
+ }
1211
+
1011
1212
  const rewrittenCode = magicSource.toString();
1012
1213
  if (!sourceMaps || !didRewrite) {
1013
1214
  return {
@@ -1131,7 +1332,10 @@ export function invalidateDevelopmentCompilationCache(projectRoot, changedPaths
1131
1332
  const shouldInvalidate =
1132
1333
  normalizedChangedPaths == null ||
1133
1334
  !dependencies ||
1134
- [...dependencies].some((dependency) => normalizedChangedPaths.has(dependency));
1335
+ [...dependencies.files].some((dependency) => normalizedChangedPaths.has(dependency)) ||
1336
+ [...dependencies.directories].some((directory) =>
1337
+ [...normalizedChangedPaths].some((changedPath) => !isPathOutsideProject(directory, changedPath))
1338
+ );
1135
1339
  if (shouldInvalidate) {
1136
1340
  developmentGraphCache.delete(key);
1137
1341
  developmentGraphDependencies.delete(key);
@@ -1163,6 +1367,7 @@ async function compileModuleGraphUncached(entryPath, options = {}) {
1163
1367
  const moduleMetadata = new Map();
1164
1368
  const serverExportsByModule = new Map();
1165
1369
  const packageImports = new Set();
1370
+ const dynamicImportDirectories = new Set();
1166
1371
  const serverImportQuery = target === "server" && mode === "development"
1167
1372
  ? `t=${Date.now()}`
1168
1373
  : null;
@@ -1233,6 +1438,7 @@ async function compileModuleGraphUncached(entryPath, options = {}) {
1233
1438
  managedSourceRoots,
1234
1439
  serverExportsByModule,
1235
1440
  packageImports,
1441
+ dynamicImportDirectories,
1236
1442
  });
1237
1443
 
1238
1444
  await fs.writeFile(outputPath, rewritten.code, "utf8");
@@ -1262,6 +1468,7 @@ async function compileModuleGraphUncached(entryPath, options = {}) {
1262
1468
  entrypoint: await compileModule(entryPath),
1263
1469
  outputRoot,
1264
1470
  sourceFiles: [...new Set([...visited.keys(), ...staticAssetFiles])],
1471
+ watchedDirectories: [...dynamicImportDirectories],
1265
1472
  packageImports: [...packageImports].sort(),
1266
1473
  };
1267
1474
  }
@@ -1286,7 +1493,10 @@ export async function compileModuleGraph(entryPath, options = {}) {
1286
1493
  developmentGraphCache.set(cacheKey, pendingCompile);
1287
1494
  pendingCompile.then((result) => {
1288
1495
  if (developmentGraphCache.get(cacheKey) === pendingCompile) {
1289
- developmentGraphDependencies.set(cacheKey, new Set(result.sourceFiles));
1496
+ developmentGraphDependencies.set(cacheKey, {
1497
+ files: new Set(result.sourceFiles),
1498
+ directories: new Set(result.watchedDirectories ?? []),
1499
+ });
1290
1500
  }
1291
1501
  }, () => {});
1292
1502
  }
@@ -1425,15 +1635,15 @@ export async function collectClientGraphInventory(entryPaths, options = {}) {
1425
1635
  if (isComponent && isServer) boundaries.add(sourcePath);
1426
1636
  if (!traverseNonBoundary) return;
1427
1637
  const componentImportSpecifiers = collectComponentImportSpecifiers(source, sourcePath);
1428
- for (const match of source.matchAll(MODULE_SPECIFIER_PATTERN)) {
1429
- const specifier = match[1] ?? match[2];
1430
- if (!specifier) continue;
1638
+ const moduleReferences = analyzeModuleReferences(source, sourcePath, ts.ScriptKind.TSX);
1639
+
1640
+ async function visitResolvedSpecifier(specifier) {
1431
1641
  const aliasResolved = isBareSpecifier(specifier)
1432
1642
  ? await resolveProjectMappedImport(projectRoot, sourcePath, specifier)
1433
1643
  : null;
1434
1644
  const resolved = aliasResolved
1435
1645
  ?? await resolveProjectModuleSpecifier(projectRoot, sourcePath, specifier);
1436
- if (!resolved) continue;
1646
+ if (!resolved) return;
1437
1647
  if (shouldCompileModule(resolved)) {
1438
1648
  if (isBareSpecifier(specifier) && aliasResolved == null) {
1439
1649
  if (componentImportSpecifiers.has(specifier)) {
@@ -1450,6 +1660,21 @@ export async function collectClientGraphInventory(entryPaths, options = {}) {
1450
1660
  assets.add(resolved);
1451
1661
  }
1452
1662
  }
1663
+
1664
+ for (const reference of moduleReferences.staticReferences) {
1665
+ await visitResolvedSpecifier(reference.specifier);
1666
+ }
1667
+ for (const reference of moduleReferences.dynamicReferences) {
1668
+ const discovery = await discoverDynamicImportCandidates({
1669
+ projectRoot,
1670
+ sourcePath,
1671
+ pattern: reference.pattern,
1672
+ displayPattern: reference.displayPattern,
1673
+ });
1674
+ for (const candidate of discovery.candidates) {
1675
+ await visit(candidate, true);
1676
+ }
1677
+ }
1453
1678
  }
1454
1679
 
1455
1680
  for (const entryPath of entryPaths) await visit(entryPath);
@@ -41,12 +41,18 @@ import {
41
41
  } from "./response-cache.js";
42
42
  import { createSsrAdapter, renderRouteTreeWithAdapter } from "./ssr-adapter.js";
43
43
  import { createNavigationResponseFromDocument } from "./route-segments.js";
44
- import { appendSsrUrqlData, runWithOptionalSsrUrqlScope } from "./urql-ssr.js";
44
+ import {
45
+ appendSsrUrqlData,
46
+ applySsrUrqlRequestContext,
47
+ createSsrUrqlRequestContext,
48
+ runWithOptionalSsrUrqlScope,
49
+ } from "./urql-ssr.js";
45
50
  import {
46
51
  getExtensionClientDescriptors,
47
52
  resolveEvolitExtensions,
48
53
  runRequestExtensions,
49
54
  } from "./extensions.js";
55
+ import { runServerSetup } from "./server-setup.js";
50
56
 
51
57
  function isBarePackageModuleId(value) {
52
58
  return typeof value === "string"
@@ -882,7 +888,8 @@ export async function createRequestRenderer({
882
888
  return effectiveRouteResolver.resolveRoutePolicy(request, options);
883
889
  },
884
890
  async renderRoute(request, routePolicyResult = null, options = {}) {
885
- return runWithOptionalSsrUrqlScope(async (urqlAdapter) => {
891
+ const urqlRequestContext = createSsrUrqlRequestContext(request);
892
+ return runWithOptionalSsrUrqlScope(urqlRequestContext, async (urqlAdapter) => {
886
893
  const shouldPrepareBeforeResolve = mode === "development" && !currentAssetManifest;
887
894
  let resolvedRoutePolicyResult = routePolicyResult;
888
895
  if (shouldPrepareBeforeResolve) {
@@ -895,9 +902,14 @@ export async function createRequestRenderer({
895
902
  await this.prepareRouteClientArtifacts(routeResult);
896
903
  }
897
904
  const renderedResponse = await renderRouteTreeWithAdapter(routeResult, ssrAdapter);
898
- const response = urqlAdapter
905
+ const responseWithData = urqlAdapter
899
906
  ? appendSsrUrqlData(renderedResponse, await urqlAdapter.getUrqlSsrData())
900
907
  : renderedResponse;
908
+ const response = applySsrUrqlRequestContext(
909
+ routeResult,
910
+ responseWithData,
911
+ urqlRequestContext,
912
+ );
901
913
  return {
902
914
  routeResult,
903
915
  response,
@@ -921,8 +933,10 @@ export async function createDeploymentRuntime({
921
933
  routeResolver,
922
934
  onDevelopmentEvent,
923
935
  managedSourceRoots,
936
+ evolitConfig: configuredEvolitConfig,
924
937
  } = {}) {
925
- const evolitConfig = await loadEvolitConfig(projectRoot);
938
+ const evolitConfig = configuredEvolitConfig ?? await loadEvolitConfig(projectRoot);
939
+ const serverSetupCleanup = await runServerSetup({ projectRoot, mode, evolitConfig });
926
940
  const extensions = resolveEvolitExtensions(evolitConfig);
927
941
  const extensionClientSpecifiers = getExtensionClientDescriptors(extensions).map((descriptor) => descriptor.module);
928
942
  const effectiveManagedSourceRoots = managedSourceRoots
@@ -1072,6 +1086,7 @@ export async function createDeploymentRuntime({
1072
1086
  if (revalidationTasks) {
1073
1087
  await Promise.all(revalidationTasks.values());
1074
1088
  }
1089
+ await serverSetupCleanup?.();
1075
1090
  },
1076
1091
  };
1077
1092
  }
@@ -0,0 +1,64 @@
1
+ import path from "node:path";
2
+ import { importCompiledModule, resolveProjectModuleSpecifier } from "./compiler.js";
3
+
4
+ function resolveSetupExport(moduleRecord, setupPath) {
5
+ const setup = moduleRecord.setup ?? moduleRecord.default;
6
+ if (typeof setup !== "function") {
7
+ throw new TypeError(
8
+ `Expected server setup module ${JSON.stringify(setupPath)} to export a setup function or a default function.`,
9
+ );
10
+ }
11
+ return setup;
12
+ }
13
+
14
+ function normalizeCleanup(result) {
15
+ if (result == null) return null;
16
+ if (typeof result === "function") return result;
17
+ if (typeof result === "object" && typeof result.dispose === "function") {
18
+ return () => result.dispose();
19
+ }
20
+ throw new TypeError("Evolit server setup must return nothing, a cleanup function, or an object with dispose().");
21
+ }
22
+
23
+ /** Runs the application server bootstrap once for a build or runtime instance. */
24
+ export async function runServerSetup({ projectRoot, mode, evolitConfig = {} }) {
25
+ const serverConfig = evolitConfig.server;
26
+ if (serverConfig == null) return null;
27
+ if (!serverConfig || typeof serverConfig !== "object" || Array.isArray(serverConfig)) {
28
+ throw new TypeError("Expected server in evolit.config.js to be an object.");
29
+ }
30
+
31
+ const configuredSetup = serverConfig.setup;
32
+ if (configuredSetup == null) return null;
33
+
34
+ let setup = configuredSetup;
35
+ if (typeof configuredSetup === "string") {
36
+ if (configuredSetup.length === 0) {
37
+ throw new TypeError("Expected server.setup to be a non-empty module specifier.");
38
+ }
39
+ const setupPath = await resolveProjectModuleSpecifier(
40
+ projectRoot,
41
+ path.join(projectRoot, "evolit.config.js"),
42
+ configuredSetup,
43
+ );
44
+ if (!setupPath) {
45
+ throw new Error(`Unable to resolve configured server setup ${JSON.stringify(configuredSetup)}.`);
46
+ }
47
+ const moduleRecord = await importCompiledModule(setupPath, {
48
+ projectRoot,
49
+ mode,
50
+ sourceMaps: mode === "development",
51
+ target: "server",
52
+ });
53
+ setup = resolveSetupExport(moduleRecord, configuredSetup);
54
+ }
55
+
56
+ if (typeof setup !== "function") {
57
+ throw new TypeError("Expected server.setup in evolit.config.js to be a function or module specifier.");
58
+ }
59
+
60
+ return normalizeCleanup(await setup({
61
+ projectRoot,
62
+ mode,
63
+ }));
64
+ }
package/src/server.js CHANGED
@@ -214,6 +214,7 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
214
214
  responseCacheRuntime,
215
215
  onDevelopmentEvent: reportDevelopmentEvent,
216
216
  managedSourceRoots,
217
+ evolitConfig,
217
218
  });
218
219
  const port = getPort(explicitPort);
219
220
  const liveReloadServer = mode === "development" ? new WebSocketServer({ noServer: true }) : null;
package/src/urql-ssr.js CHANGED
@@ -26,7 +26,18 @@ export async function getSsrUrqlAdapter() {
26
26
  * Evolit owns only lifecycle here; client creation and URQL configuration stay
27
27
  * entirely in the application and @litsx/urql.
28
28
  */
29
- export async function runWithOptionalSsrUrqlScope(callback, options = {}) {
29
+ export async function runWithOptionalSsrUrqlScope(
30
+ contextOrCallback,
31
+ callbackOrOptions = {},
32
+ maybeOptions = {},
33
+ ) {
34
+ const hasRequestContext = typeof contextOrCallback !== "function";
35
+ const requestContext = hasRequestContext ? contextOrCallback : null;
36
+ const callback = hasRequestContext ? callbackOrOptions : contextOrCallback;
37
+ const options = hasRequestContext ? maybeOptions : callbackOrOptions;
38
+ if (typeof callback !== "function") {
39
+ throw new TypeError("runWithOptionalSsrUrqlScope() expects a render callback.");
40
+ }
30
41
  const adapter = Object.hasOwn(options, "adapter")
31
42
  ? options.adapter
32
43
  : await getSsrUrqlAdapter();
@@ -34,7 +45,61 @@ export async function runWithOptionalSsrUrqlScope(callback, options = {}) {
34
45
  return callback(null);
35
46
  }
36
47
 
37
- return adapter.runWithUrqlScope(() => callback(adapter));
48
+ return hasRequestContext
49
+ ? adapter.runWithUrqlScope(requestContext, () => callback(adapter))
50
+ : adapter.runWithUrqlScope(() => callback(adapter));
51
+ }
52
+
53
+ /** Creates the HTTP context passed to a request-scoped URQL resource factory. */
54
+ export function createSsrUrqlRequestContext(request) {
55
+ let didUseRequestData = false;
56
+ const trackedRequest = new Proxy(request, {
57
+ get(target, property) {
58
+ if (property !== "constructor" && property !== Symbol.toStringTag) {
59
+ didUseRequestData = true;
60
+ }
61
+ const value = Reflect.get(target, property, target);
62
+ return typeof value === "function" ? value.bind(target) : value;
63
+ },
64
+ });
65
+ const responseHeaders = new Headers();
66
+ return {
67
+ request: trackedRequest,
68
+ responseHeaders,
69
+ didUseDynamicRequestData() {
70
+ return didUseRequestData || !responseHeaders.entries().next().done;
71
+ },
72
+ };
73
+ }
74
+
75
+ function responseHeadersToObject(headers) {
76
+ const values = Object.fromEntries(headers.entries());
77
+ if (typeof headers.getSetCookie === "function") {
78
+ const cookies = headers.getSetCookie();
79
+ if (cookies.length > 0) values["set-cookie"] = cookies;
80
+ }
81
+ return values;
82
+ }
83
+
84
+ /** Applies request-resource response state before Evolit caches the render. */
85
+ export function applySsrUrqlRequestContext(routeResult, response, context) {
86
+ if (!context) return response;
87
+ const urqlHeaders = responseHeadersToObject(context.responseHeaders);
88
+ if (context.didUseDynamicRequestData()) {
89
+ routeResult.cachePolicy = { mode: "dynamic" };
90
+ }
91
+ if (Object.keys(urqlHeaders).length === 0) return response;
92
+ routeResult.responseHeaders = {
93
+ ...(routeResult.responseHeaders ?? {}),
94
+ ...urqlHeaders,
95
+ };
96
+ return {
97
+ ...response,
98
+ headers: {
99
+ ...(response.headers ?? {}),
100
+ ...urqlHeaders,
101
+ },
102
+ };
38
103
  }
39
104
 
40
105
  function escapeJsonForHtml(value) {