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.
@@ -0,0 +1,272 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { html } from "lit";
4
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
5
+
6
+ const SEGMENT_MARKER_PREFIX = "evolit:segment";
7
+ const SEGMENT_MARKER_PATTERN = /<!--evolit:segment:(start|end):([A-Za-z0-9_-]+)-->/g;
8
+
9
+ function normalizeModulePath(projectRoot, modulePath) {
10
+ return path.relative(projectRoot, modulePath).split(path.sep).join("/");
11
+ }
12
+
13
+ function createSegmentId(kind, modulePath, projectRoot) {
14
+ const relativeModulePath = normalizeModulePath(projectRoot, modulePath);
15
+ const digest = crypto
16
+ .createHash("sha256")
17
+ .update(`${kind}:${relativeModulePath}`)
18
+ .digest("base64url")
19
+ .slice(0, 12);
20
+
21
+ return `${kind}-${digest}`;
22
+ }
23
+
24
+ export function createRouteSegmentPlan(route, projectRoot, options = {}) {
25
+ const layouts = Array.isArray(route?.layouts) ? route.layouts : [];
26
+ const segments = layouts.map((modulePath, index) => ({
27
+ id: createSegmentId("layout", modulePath, projectRoot),
28
+ kind: "layout",
29
+ depth: index,
30
+ modulePath: normalizeModulePath(projectRoot, modulePath),
31
+ }));
32
+
33
+ if (typeof route?.page === "string") {
34
+ segments.push({
35
+ id: createSegmentId("page", route.page, projectRoot),
36
+ kind: "page",
37
+ depth: segments.length,
38
+ modulePath: normalizeModulePath(projectRoot, route.page),
39
+ });
40
+ } else if (typeof options.boundaryModule === "string") {
41
+ segments.push({
42
+ id: createSegmentId("boundary", options.boundaryModule, projectRoot),
43
+ kind: "boundary",
44
+ depth: segments.length,
45
+ modulePath: normalizeModulePath(projectRoot, options.boundaryModule),
46
+ });
47
+ }
48
+
49
+ return segments;
50
+ }
51
+
52
+ export function wrapRouteSegment(segment, value) {
53
+ if (!segment?.id) {
54
+ return value;
55
+ }
56
+
57
+ const startMarker = `<!--${SEGMENT_MARKER_PREFIX}:start:${segment.id}-->`;
58
+ const endMarker = `<!--${SEGMENT_MARKER_PREFIX}:end:${segment.id}-->`;
59
+ if (typeof value === "string") {
60
+ return `${startMarker}${value}${endMarker}`;
61
+ }
62
+
63
+ return html`${unsafeHTML(startMarker)}${value}${unsafeHTML(endMarker)}`;
64
+ }
65
+
66
+ export function createRouteSegmentPayload(routeResult) {
67
+ const segments = Array.isArray(routeResult?.segments) ? routeResult.segments : [];
68
+ if (segments.length === 0) {
69
+ return null;
70
+ }
71
+
72
+ return {
73
+ version: 1,
74
+ url: routeResult.cacheKey ?? routeResult.route?.pathname ?? null,
75
+ pathname: routeResult.cacheKey?.split("?")[0] ?? routeResult.route?.pathname ?? null,
76
+ cachePolicy: routeResult.cachePolicy
77
+ ? {
78
+ mode: routeResult.cachePolicy.mode,
79
+ ...(routeResult.cachePolicy.mode === "revalidate"
80
+ ? { ttlSeconds: routeResult.cachePolicy.ttlSeconds }
81
+ : {}),
82
+ }
83
+ : null,
84
+ segments: segments.map(({ id, kind, depth, modulePath }) => ({
85
+ id,
86
+ kind,
87
+ depth,
88
+ modulePath,
89
+ })),
90
+ };
91
+ }
92
+
93
+ export function renderRouteSegmentPayload(payload, scriptId = "__EVOLIT_ROUTE__") {
94
+ if (!payload) {
95
+ return "";
96
+ }
97
+
98
+ const serialized = JSON.stringify(payload)
99
+ .replaceAll("<", "\\u003C")
100
+ .replaceAll(">", "\\u003E")
101
+ .replaceAll("&", "\\u0026");
102
+ return `<script type="application/json" id="${scriptId}">${serialized}</script>`;
103
+ }
104
+
105
+ /**
106
+ * Extracts every rendered projection of every route segment from a composed
107
+ * SSR document. The marker protocol belongs to Evolit; LitSX only renders the
108
+ * values that contain the markers.
109
+ */
110
+ export function extractRouteSegmentArtifacts(documentHtml) {
111
+ if (typeof documentHtml !== "string" || documentHtml.length === 0) {
112
+ return new Map();
113
+ }
114
+
115
+ const artifacts = new Map();
116
+ const openSegments = [];
117
+ SEGMENT_MARKER_PATTERN.lastIndex = 0;
118
+
119
+ for (const match of documentHtml.matchAll(SEGMENT_MARKER_PATTERN)) {
120
+ const [marker, boundary, segmentId] = match;
121
+ const markerStart = match.index ?? 0;
122
+ const markerEnd = markerStart + marker.length;
123
+
124
+ if (boundary === "start") {
125
+ openSegments.push({ segmentId, contentStart: markerEnd });
126
+ continue;
127
+ }
128
+
129
+ const openIndex = openSegments.map((entry) => entry.segmentId).lastIndexOf(segmentId);
130
+ if (openIndex < 0) {
131
+ continue;
132
+ }
133
+
134
+ const [{ contentStart }] = openSegments.splice(openIndex, 1);
135
+ const projections = artifacts.get(segmentId) ?? [];
136
+ projections.push({
137
+ projectionId: `${segmentId}:${projections.length}`,
138
+ html: documentHtml.slice(contentStart, markerStart),
139
+ });
140
+ artifacts.set(segmentId, projections);
141
+ }
142
+
143
+ return artifacts;
144
+ }
145
+
146
+ export function createRouteSegmentDelta(routeResult, documentHtml) {
147
+ const payload = createRouteSegmentPayload(routeResult);
148
+ if (!payload) {
149
+ return null;
150
+ }
151
+
152
+ const artifacts = extractRouteSegmentArtifacts(documentHtml);
153
+ return {
154
+ ...payload,
155
+ segments: payload.segments.map((segment) => ({
156
+ ...segment,
157
+ projections: artifacts.get(segment.id) ?? [],
158
+ })),
159
+ };
160
+ }
161
+
162
+ export function createNavigationResponseFromDocument(response) {
163
+ if (response?.headers?.location && response.status >= 300 && response.status < 400) {
164
+ return {
165
+ ...response,
166
+ headers: {
167
+ ...(response.headers ?? {}),
168
+ "content-type": "application/vnd.evolit.navigation+json; charset=utf-8",
169
+ "cache-control": "no-store",
170
+ vary: appendVaryHeader(response.headers?.vary, "accept"),
171
+ },
172
+ body: JSON.stringify({
173
+ version: 1,
174
+ type: "redirect",
175
+ location: response.headers.location,
176
+ }),
177
+ };
178
+ }
179
+
180
+ if (typeof response?.body !== "string") {
181
+ return response;
182
+ }
183
+
184
+ const routeMatch = response.body.match(
185
+ /<script type="application\/json" id="__EVOLIT_ROUTE__">([\s\S]*?)<\/script>/,
186
+ );
187
+ if (!routeMatch) {
188
+ return response;
189
+ }
190
+
191
+ let route;
192
+ try {
193
+ route = JSON.parse(routeMatch[1]);
194
+ } catch {
195
+ return response;
196
+ }
197
+
198
+ const artifacts = extractRouteSegmentArtifacts(response.body);
199
+ const hydrationMatch = response.body.match(
200
+ /<script type="application\/json" id="__LITSX_HYDRATION__">([\s\S]*?)<\/script>/,
201
+ );
202
+ const titleMatch = response.body.match(/<title>([\s\S]*?)<\/title>/i);
203
+ const head = extractRouteHeadMarkup(response.body);
204
+ const documentState = extractDocumentState(response.body);
205
+ const headAssets = extractRouteHeadAssets(response.body);
206
+ const body = JSON.stringify({
207
+ version: 1,
208
+ type: "route",
209
+ url: route.url,
210
+ route: {
211
+ ...route,
212
+ segments: route.segments.map((segment) => ({
213
+ ...segment,
214
+ projections: artifacts.get(segment.id) ?? [],
215
+ })),
216
+ },
217
+ ...(titleMatch ? { title: titleMatch[1] } : {}),
218
+ ...(head != null ? { head } : {}),
219
+ ...(documentState ? { document: documentState } : {}),
220
+ headAssets,
221
+ ...(hydrationMatch ? { hydrationData: JSON.parse(hydrationMatch[1]) } : {}),
222
+ });
223
+
224
+ return {
225
+ ...response,
226
+ headers: {
227
+ ...(response.headers ?? {}),
228
+ "content-type": "application/vnd.evolit.navigation+json; charset=utf-8",
229
+ "cache-control": "no-store",
230
+ vary: appendVaryHeader(response.headers?.vary, "accept"),
231
+ },
232
+ body,
233
+ };
234
+ }
235
+
236
+ export function extractDocumentState(documentHtml) {
237
+ if (typeof documentHtml !== "string") return null;
238
+ const match = documentHtml.match(/<script type="application\/json" id="__EVOLIT_DOCUMENT__">([\s\S]*?)<\/script>/i);
239
+ if (!match) return null;
240
+ try { return JSON.parse(match[1]); } catch { return null; }
241
+ }
242
+
243
+ export function extractRouteHeadMarkup(documentHtml) {
244
+ if (typeof documentHtml !== "string") return null;
245
+ const match = documentHtml.match(/<!--evolit:route-head:start-->([\s\S]*?)<!--evolit:route-head:end-->/i);
246
+ return match?.[1] ?? null;
247
+ }
248
+
249
+ export function extractRouteHeadAssets(documentHtml) {
250
+ const assets = { styles: [], preloads: [] };
251
+ if (typeof documentHtml !== "string") return assets;
252
+
253
+ for (const match of documentHtml.matchAll(/<link\b[^>]*>/gi)) {
254
+ const tag = match[0];
255
+ const kind = tag.match(/\bdata-evolit-route-asset="([^"]+)"/i)?.[1];
256
+ const href = tag.match(/\bhref="([^"]+)"/i)?.[1];
257
+ if (!href) continue;
258
+ if (kind === "style") assets.styles.push(href);
259
+ if (kind === "preload") assets.preloads.push(href);
260
+ }
261
+
262
+ return assets;
263
+ }
264
+
265
+ function appendVaryHeader(value, field) {
266
+ const fields = String(value ?? "")
267
+ .split(",")
268
+ .map((item) => item.trim())
269
+ .filter(Boolean);
270
+ if (!fields.some((item) => item.toLowerCase() === field.toLowerCase())) fields.push(field);
271
+ return fields.join(", ");
272
+ }
package/src/scaffold.js CHANGED
@@ -49,6 +49,10 @@ export async function scaffoldSite(targetDirectory, options = {}) {
49
49
  }
50
50
 
51
51
  await copyDirectory(TEMPLATE_ROOT, absoluteTargetDirectory);
52
+ await fs.rename(
53
+ path.join(absoluteTargetDirectory, "yarnrc.yml.template"),
54
+ path.join(absoluteTargetDirectory, ".yarnrc.yml"),
55
+ );
52
56
  await writeSitePackageJson(absoluteTargetDirectory, siteName);
53
57
  await fs.writeFile(
54
58
  path.join(absoluteTargetDirectory, "evolit.config.js"),
package/src/server.js CHANGED
@@ -2,12 +2,15 @@ import http from "node:http";
2
2
  import { watch } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { WebSocketServer, WebSocket } from "ws";
5
6
  import { loadEvolitConfig } from "./config.js";
6
7
  import { createDeploymentRuntime } from "./deployment-runtime.js";
7
- import { APP_DIRECTORY, BUILD_DIRECTORY, INTERNAL_DIRECTORY, MANIFEST_FILENAME } from "./constants.js";
8
+ import { BUILD_DIRECTORY, INTERNAL_DIRECTORY, MANIFEST_FILENAME } from "./constants.js";
8
9
  import { normalizeClientAssetManifest } from "./client-assets.js";
9
10
  import { pathExists, readJson } from "./fs-utils.js";
10
11
  import { createDefaultRouteCacheKey, resolveResponseCacheRuntime } from "./response-cache.js";
12
+ import { createDevelopmentEventReporter } from "./development-events.js";
13
+ import { createNavigationResponseFromDocument } from "./route-segments.js";
11
14
 
12
15
  function getPort(explicitPort) {
13
16
  const port = explicitPort ?? process.env.PORT ?? "3000";
@@ -35,22 +38,56 @@ async function writeResponseBody(response, body) {
35
38
  response.end();
36
39
  }
37
40
 
38
- async function createRecursiveDirectoryWatcher(rootDirectory, onChange) {
41
+ const LIVE_RELOAD_PATHNAME = "/_evolit/live-reload";
42
+
43
+ function getResponseHeader(headers, name) {
44
+ if (headers?.get) {
45
+ return headers.get(name);
46
+ }
47
+
48
+ return headers?.[name] ?? headers?.[name.toLowerCase()] ?? null;
49
+ }
50
+
51
+ function injectLiveReloadSnippet(response) {
52
+ if (
53
+ typeof response.body !== "string"
54
+ || !getResponseHeader(response.headers, "content-type")?.includes("text/html")
55
+ ) {
56
+ return response;
57
+ }
58
+
59
+ return {
60
+ ...response,
61
+ body: response.body.includes("</body>")
62
+ ? response.body.replace("</body>", `${createLiveReloadSnippet()}</body>`)
63
+ : `${response.body}${createLiveReloadSnippet()}`,
64
+ };
65
+ }
66
+
67
+ function createLiveReloadSnippet() {
68
+ return `<script data-evolit-live-reload>const evolitProtocol=window.location.protocol==="https:"?"wss:":"ws:";const evolitSocket=new WebSocket(evolitProtocol+"//"+window.location.host+${JSON.stringify(LIVE_RELOAD_PATHNAME)});evolitSocket.addEventListener("message",({data})=>{if(data==="reload")window.location.reload()});</script>`;
69
+ }
70
+
71
+ async function createRecursiveDirectoryWatcher(rootDirectory, onChange, options = {}) {
39
72
  const watchers = new Map();
73
+ const shouldIgnorePath = options.shouldIgnorePath ?? (() => false);
40
74
  let closed = false;
41
75
 
42
76
  async function watchDirectory(directory) {
43
- if (closed || watchers.has(directory)) {
77
+ if (closed || watchers.has(directory) || shouldIgnorePath(directory)) {
44
78
  return;
45
79
  }
46
80
 
47
81
  let watcher;
48
82
  try {
49
83
  watcher = watch(directory, (eventType, fileName) => {
50
- onChange();
84
+ const changedPath = fileName ? path.join(directory, String(fileName)) : null;
85
+ if (!changedPath || !shouldIgnorePath(changedPath)) {
86
+ onChange(changedPath);
87
+ }
51
88
 
52
- if (eventType === "rename" && fileName) {
53
- void watchNewDirectory(path.join(directory, fileName));
89
+ if (eventType === "rename" && changedPath && !shouldIgnorePath(changedPath)) {
90
+ void watchNewDirectory(changedPath);
54
91
  }
55
92
  });
56
93
  } catch (error) {
@@ -82,6 +119,10 @@ async function createRecursiveDirectoryWatcher(rootDirectory, onChange) {
82
119
  }
83
120
 
84
121
  async function watchNewDirectory(candidate) {
122
+ if (shouldIgnorePath(candidate)) {
123
+ return;
124
+ }
125
+
85
126
  try {
86
127
  const stat = await fs.stat(candidate);
87
128
  if (stat.isDirectory()) {
@@ -107,7 +148,34 @@ async function createRecursiveDirectoryWatcher(rootDirectory, onChange) {
107
148
  };
108
149
  }
109
150
 
151
+ function createProjectWatchIgnorePath(projectRoot) {
152
+ const ignoredDirectoryNames = new Set([
153
+ "node_modules",
154
+ INTERNAL_DIRECTORY,
155
+ ".git",
156
+ ]);
157
+
158
+ return (candidate) => {
159
+ const relativePath = path.relative(projectRoot, candidate);
160
+ if (
161
+ relativePath === ".."
162
+ || relativePath.startsWith(`..${path.sep}`)
163
+ || path.isAbsolute(relativePath)
164
+ ) {
165
+ return true;
166
+ }
167
+
168
+ return relativePath
169
+ .split(path.sep)
170
+ .some((segment) => ignoredDirectoryNames.has(segment));
171
+ };
172
+ }
173
+
110
174
  async function createServer(projectRoot, mode, explicitPort, options = {}) {
175
+ const reportDevelopmentEvent = mode === "development"
176
+ ? options.onDevelopmentEvent ?? createDevelopmentEventReporter()
177
+ : null;
178
+ reportDevelopmentEvent?.({ type: "server-starting" });
111
179
  const evolitConfig = options.evolitConfig ?? await loadEvolitConfig(projectRoot);
112
180
  const responseCacheRuntime = options.responseCacheStore
113
181
  ? {
@@ -120,14 +188,27 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
120
188
  mode,
121
189
  assetManifest: normalizeClientAssetManifest(options.assetManifest),
122
190
  responseCacheRuntime,
191
+ onDevelopmentEvent: reportDevelopmentEvent,
123
192
  });
124
193
  const port = getPort(explicitPort);
194
+ const liveReloadServer = mode === "development" ? new WebSocketServer({ noServer: true }) : null;
195
+ const liveReloadSockets = new Set();
196
+ liveReloadServer?.on("connection", (socket) => {
197
+ liveReloadSockets.add(socket);
198
+ socket.on("close", () => {
199
+ liveReloadSockets.delete(socket);
200
+ });
201
+ });
125
202
  let invalidationTimer = null;
126
203
  let pendingInvalidation = null;
127
204
  let resolvePendingInvalidation = null;
128
205
  let invalidationPromise = Promise.resolve();
206
+ const pendingChangedPaths = new Set();
129
207
 
130
- function scheduleDevelopmentInvalidation() {
208
+ function scheduleDevelopmentInvalidation(changedPath = null) {
209
+ if (changedPath) {
210
+ pendingChangedPaths.add(path.resolve(changedPath));
211
+ }
131
212
  if (invalidationTimer) {
132
213
  clearTimeout(invalidationTimer);
133
214
  }
@@ -141,43 +222,96 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
141
222
  invalidationTimer = setTimeout(() => {
142
223
  invalidationTimer = null;
143
224
  const resolve = resolvePendingInvalidation;
225
+ const changedPaths = pendingChangedPaths.size > 0
226
+ ? [...pendingChangedPaths]
227
+ : null;
228
+ pendingChangedPaths.clear();
144
229
  pendingInvalidation = null;
145
230
  resolvePendingInvalidation = null;
146
231
  invalidationPromise = invalidationPromise
147
232
  .catch(() => {})
148
- .then(() => deploymentRuntime.invalidateDevelopmentState());
149
- invalidationPromise.then(resolve, resolve);
233
+ .then(() => deploymentRuntime.invalidateDevelopmentState(changedPaths));
234
+ invalidationPromise.then(
235
+ () => {
236
+ for (const socket of liveReloadSockets) {
237
+ if (socket.readyState === WebSocket.OPEN) {
238
+ socket.send("reload");
239
+ }
240
+ }
241
+ resolve();
242
+ },
243
+ resolve,
244
+ );
150
245
  }, 40);
151
246
  }
152
247
 
153
248
  const watcher = mode === "development"
154
249
  ? await createRecursiveDirectoryWatcher(
155
- path.join(projectRoot, APP_DIRECTORY),
250
+ projectRoot,
156
251
  scheduleDevelopmentInvalidation,
252
+ { shouldIgnorePath: createProjectWatchIgnorePath(projectRoot) },
157
253
  )
158
254
  : null;
159
255
 
160
256
  const server = http.createServer(async (req, res) => {
161
257
  try {
258
+ const origin = `http://${req.headers.host ?? `localhost:${port}`}`;
259
+ const pathname = new URL(req.url ?? "/", origin).pathname;
162
260
  await pendingInvalidation;
163
261
  await invalidationPromise;
164
- const origin = `http://${req.headers.host ?? `localhost:${port}`}`;
165
262
  const method = req.method ?? "GET";
166
263
  const request = new Request(new URL(req.url ?? "/", origin), {
167
264
  method,
168
265
  headers: req.headers,
169
266
  ...(method === "GET" || method === "HEAD" ? {} : { body: req, duplex: "half" }),
170
267
  });
268
+ const requestStartedAt = performance.now();
171
269
  const response = await deploymentRuntime.handle(request);
270
+ const navigationResponse = String(req.headers.accept ?? "").includes("application/vnd.evolit.navigation+json")
271
+ ? createNavigationResponseFromDocument(response)
272
+ : response;
273
+ const servedResponse = mode === "development"
274
+ ? injectLiveReloadSnippet(navigationResponse)
275
+ : navigationResponse;
172
276
 
173
- res.writeHead(response.status, response.headers);
174
- await writeResponseBody(res, response.body);
277
+ res.writeHead(servedResponse.status, servedResponse.headers);
278
+ await writeResponseBody(res, servedResponse.body);
279
+ if (mode === "development" && !pathname.startsWith("/_evolit/")) {
280
+ const durationMs = Math.round(performance.now() - requestStartedAt);
281
+ reportDevelopmentEvent({
282
+ type: "request",
283
+ method,
284
+ pathname,
285
+ status: servedResponse.status,
286
+ durationMs,
287
+ cacheState: servedResponse.headers?.["x-evolit-cache"] ?? null,
288
+ });
289
+ }
175
290
  } catch (error) {
291
+ reportDevelopmentEvent?.({
292
+ type: "request-error",
293
+ method: req.method ?? "GET",
294
+ pathname: req.url ?? "/",
295
+ error,
296
+ });
176
297
  res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
177
298
  res.end(error instanceof Error ? error.stack ?? error.message : String(error));
178
299
  }
179
300
  });
180
301
 
302
+ server.on("upgrade", (request, socket, head) => {
303
+ const origin = `http://${request.headers.host ?? `localhost:${port}`}`;
304
+ const pathname = new URL(request.url ?? "/", origin).pathname;
305
+ if (!liveReloadServer || pathname !== LIVE_RELOAD_PATHNAME) {
306
+ socket.destroy();
307
+ return;
308
+ }
309
+
310
+ liveReloadServer.handleUpgrade(request, socket, head, (webSocket) => {
311
+ liveReloadServer.emit("connection", webSocket, request);
312
+ });
313
+ });
314
+
181
315
  return {
182
316
  port,
183
317
  deploymentRuntime,
@@ -185,6 +319,7 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
185
319
  await new Promise((resolve) => {
186
320
  server.listen(port, resolve);
187
321
  });
322
+ reportDevelopmentEvent?.({ type: "server-ready", port });
188
323
  return server;
189
324
  },
190
325
  async close() {
@@ -192,6 +327,14 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
192
327
  clearTimeout(invalidationTimer);
193
328
  }
194
329
  watcher?.close();
330
+ for (const socket of liveReloadSockets) {
331
+ socket.close();
332
+ }
333
+ if (liveReloadServer) {
334
+ await new Promise((resolve) => {
335
+ liveReloadServer.close(resolve);
336
+ });
337
+ }
195
338
  await new Promise((resolve, reject) => {
196
339
  server.close((error) => {
197
340
  if (error) {