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/README.md +73 -3
- package/package.json +13 -6
- package/src/build.js +37 -9
- package/src/cli.js +16 -9
- package/src/client-assets.js +326 -70
- package/src/compiler.js +176 -9
- package/src/deployment-runtime.js +246 -43
- package/src/development-events.js +96 -0
- package/src/navigation-client.js +519 -0
- package/src/navigation-server.js +3 -0
- package/src/navigation-url.js +28 -0
- package/src/render.js +328 -19
- package/src/route-config.js +3 -1
- package/src/route-segments.js +272 -0
- package/src/scaffold.js +4 -0
- package/src/server.js +156 -13
- package/src/ssr-adapter.js +127 -12
- package/src/terminal.js +32 -0
- package/templates/default/yarnrc.yml.template +1 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
53
|
-
void watchNewDirectory(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
174
|
-
await writeResponseBody(res,
|
|
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) {
|