@webstir-io/webstir-frontend 0.1.40
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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/assets/assetManifest.d.ts +16 -0
- package/dist/assets/assetManifest.js +31 -0
- package/dist/assets/imageOptimizer.d.ts +6 -0
- package/dist/assets/imageOptimizer.js +93 -0
- package/dist/assets/precompression.d.ts +1 -0
- package/dist/assets/precompression.js +21 -0
- package/dist/builders/contentBuilder.d.ts +2 -0
- package/dist/builders/contentBuilder.js +1052 -0
- package/dist/builders/cssBuilder.d.ts +2 -0
- package/dist/builders/cssBuilder.js +439 -0
- package/dist/builders/htmlBuilder.d.ts +2 -0
- package/dist/builders/htmlBuilder.js +430 -0
- package/dist/builders/index.d.ts +2 -0
- package/dist/builders/index.js +14 -0
- package/dist/builders/jsBuilder.d.ts +2 -0
- package/dist/builders/jsBuilder.js +300 -0
- package/dist/builders/staticAssetsBuilder.d.ts +2 -0
- package/dist/builders/staticAssetsBuilder.js +158 -0
- package/dist/builders/types.d.ts +12 -0
- package/dist/builders/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +105 -0
- package/dist/config/manifest.d.ts +7 -0
- package/dist/config/manifest.js +17 -0
- package/dist/config/paths.d.ts +3 -0
- package/dist/config/paths.js +11 -0
- package/dist/config/schema.d.ts +413 -0
- package/dist/config/schema.js +44 -0
- package/dist/config/setup.d.ts +2 -0
- package/dist/config/setup.js +12 -0
- package/dist/config/workspace.d.ts +2 -0
- package/dist/config/workspace.js +131 -0
- package/dist/config/workspaceManifest.d.ts +23 -0
- package/dist/config/workspaceManifest.js +1 -0
- package/dist/core/constants.d.ts +70 -0
- package/dist/core/constants.js +70 -0
- package/dist/core/diagnostics.d.ts +15 -0
- package/dist/core/diagnostics.js +21 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +3 -0
- package/dist/core/pages.d.ts +6 -0
- package/dist/core/pages.js +23 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +115 -0
- package/dist/html/criticalCss.d.ts +4 -0
- package/dist/html/criticalCss.js +192 -0
- package/dist/html/htmlSecurity.d.ts +5 -0
- package/dist/html/htmlSecurity.js +73 -0
- package/dist/html/lazyLoad.d.ts +6 -0
- package/dist/html/lazyLoad.js +21 -0
- package/dist/html/pageScaffold.d.ts +10 -0
- package/dist/html/pageScaffold.js +51 -0
- package/dist/html/resourceHints.d.ts +7 -0
- package/dist/html/resourceHints.js +64 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/modes/ssg/index.d.ts +4 -0
- package/dist/modes/ssg/index.js +4 -0
- package/dist/modes/ssg/metadata.d.ts +5 -0
- package/dist/modes/ssg/metadata.js +50 -0
- package/dist/modes/ssg/routing.d.ts +2 -0
- package/dist/modes/ssg/routing.js +186 -0
- package/dist/modes/ssg/seo.d.ts +4 -0
- package/dist/modes/ssg/seo.js +208 -0
- package/dist/modes/ssg/validation.d.ts +3 -0
- package/dist/modes/ssg/validation.js +27 -0
- package/dist/modes/ssg/views.d.ts +2 -0
- package/dist/modes/ssg/views.js +236 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +102 -0
- package/dist/pipeline.d.ts +7 -0
- package/dist/pipeline.js +71 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +176 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/utils/changedFile.d.ts +8 -0
- package/dist/utils/changedFile.js +26 -0
- package/dist/utils/fs.d.ts +11 -0
- package/dist/utils/fs.js +39 -0
- package/dist/utils/hash.d.ts +1 -0
- package/dist/utils/hash.js +5 -0
- package/dist/utils/pagePaths.d.ts +5 -0
- package/dist/utils/pagePaths.js +36 -0
- package/dist/utils/pathMatch.d.ts +3 -0
- package/dist/utils/pathMatch.js +29 -0
- package/dist/watch/frontendFiles.d.ts +3 -0
- package/dist/watch/frontendFiles.js +25 -0
- package/dist/watch/hotUpdateTracker.d.ts +51 -0
- package/dist/watch/hotUpdateTracker.js +205 -0
- package/dist/watch/pipelineHelpers.d.ts +26 -0
- package/dist/watch/pipelineHelpers.js +177 -0
- package/dist/watch/types.d.ts +27 -0
- package/dist/watch/types.js +1 -0
- package/dist/watch/watchCoordinator.d.ts +36 -0
- package/dist/watch/watchCoordinator.js +551 -0
- package/dist/watch/watchDaemon.d.ts +17 -0
- package/dist/watch/watchDaemon.js +127 -0
- package/dist/watch/watchReporter.d.ts +21 -0
- package/dist/watch/watchReporter.js +64 -0
- package/package.json +92 -0
- package/scripts/publish.sh +101 -0
- package/scripts/smoke.mjs +35 -0
- package/scripts/update-contract.sh +121 -0
- package/src/assets/assetManifest.ts +51 -0
- package/src/assets/imageOptimizer.ts +112 -0
- package/src/assets/precompression.ts +25 -0
- package/src/builders/contentBuilder.ts +1400 -0
- package/src/builders/cssBuilder.ts +552 -0
- package/src/builders/htmlBuilder.ts +540 -0
- package/src/builders/index.ts +16 -0
- package/src/builders/jsBuilder.ts +358 -0
- package/src/builders/staticAssetsBuilder.ts +174 -0
- package/src/builders/types.ts +15 -0
- package/src/cli.ts +108 -0
- package/src/config/manifest.ts +24 -0
- package/src/config/paths.ts +14 -0
- package/src/config/schema.ts +49 -0
- package/src/config/setup.ts +14 -0
- package/src/config/workspace.ts +150 -0
- package/src/config/workspaceManifest.ts +27 -0
- package/src/core/constants.ts +73 -0
- package/src/core/diagnostics.ts +40 -0
- package/src/core/index.ts +3 -0
- package/src/core/pages.ts +31 -0
- package/src/hooks.ts +175 -0
- package/src/html/criticalCss.ts +214 -0
- package/src/html/htmlSecurity.ts +86 -0
- package/src/html/lazyLoad.ts +30 -0
- package/src/html/pageScaffold.ts +70 -0
- package/src/html/resourceHints.ts +91 -0
- package/src/index.ts +5 -0
- package/src/modes/ssg/index.ts +4 -0
- package/src/modes/ssg/metadata.ts +63 -0
- package/src/modes/ssg/routing.ts +230 -0
- package/src/modes/ssg/seo.ts +261 -0
- package/src/modes/ssg/validation.ts +37 -0
- package/src/modes/ssg/views.ts +309 -0
- package/src/operations.ts +138 -0
- package/src/pipeline.ts +88 -0
- package/src/provider.ts +249 -0
- package/src/types.ts +67 -0
- package/src/utils/changedFile.ts +39 -0
- package/src/utils/fs.ts +48 -0
- package/src/utils/hash.ts +6 -0
- package/src/utils/pagePaths.ts +43 -0
- package/src/utils/pathMatch.ts +36 -0
- package/src/watch/frontendFiles.ts +32 -0
- package/src/watch/hotUpdateTracker.ts +285 -0
- package/src/watch/pipelineHelpers.ts +242 -0
- package/src/watch/types.ts +23 -0
- package/src/watch/watchCoordinator.ts +666 -0
- package/src/watch/watchDaemon.ts +144 -0
- package/src/watch/watchReporter.ts +98 -0
- package/tests/add-page-defaults.test.js +64 -0
- package/tests/content-pages.test.js +81 -0
- package/tests/css-app-imports.test.js +64 -0
- package/tests/css-page-imports.test.js +100 -0
- package/tests/diagnostics.test.js +48 -0
- package/tests/features.test.js +63 -0
- package/tests/hooks.test.js +71 -0
- package/tests/provider.integration.test.js +137 -0
- package/tests/ssg-defaults.test.js +201 -0
- package/tests/ssg-guardrails.test.js +69 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { context as createEsbuildContext } from 'esbuild';
|
|
4
|
+
import type { BuildContext, BuildResult } from 'esbuild';
|
|
5
|
+
import { FOLDERS, FILES, FILE_NAMES, EXTENSIONS } from '../core/constants.js';
|
|
6
|
+
import { getPages, type PageInfo } from '../core/pages.js';
|
|
7
|
+
import { emitDiagnostic } from '../core/diagnostics.js';
|
|
8
|
+
import type { EnableFlags, FrontendConfig } from '../types.js';
|
|
9
|
+
import { prepareWorkspaceConfig } from '../config/setup.js';
|
|
10
|
+
import { ensureDir, readJson } from '../utils/fs.js';
|
|
11
|
+
import { shouldProcess, isPathInside } from '../utils/changedFile.js';
|
|
12
|
+
import { findPageFromChangedFile } from '../utils/pathMatch.js';
|
|
13
|
+
import { createCssBuilder } from '../builders/cssBuilder.js';
|
|
14
|
+
import { createHtmlBuilder } from '../builders/htmlBuilder.js';
|
|
15
|
+
import { createStaticAssetsBuilder } from '../builders/staticAssetsBuilder.js';
|
|
16
|
+
import type { Builder, BuilderContext } from '../builders/types.js';
|
|
17
|
+
import type { WatchChangeIntent, WatchCoordinatorOptions } from './types.js';
|
|
18
|
+
import { WatchReporter, serializeMessages, type SerializedMessage } from './watchReporter.js';
|
|
19
|
+
import { HotUpdateTracker, type HotAsset, type HotUpdateDetails, type HotUpdateStats } from './hotUpdateTracker.js';
|
|
20
|
+
import {
|
|
21
|
+
runBuilderWithDiagnostics,
|
|
22
|
+
emitPipelineSuccess,
|
|
23
|
+
serializeSummary,
|
|
24
|
+
emitJavaScriptFailure,
|
|
25
|
+
JavaScriptBuildError,
|
|
26
|
+
type JavaScriptBuildSummary,
|
|
27
|
+
type AdditionalBuildResult
|
|
28
|
+
} from './pipelineHelpers.js';
|
|
29
|
+
import { resolveEntryPoint, copyRefreshScript } from './frontendFiles.js';
|
|
30
|
+
|
|
31
|
+
interface PageBuildContext {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly entryPoint: string;
|
|
34
|
+
readonly context: BuildContext;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const JAVASCRIPT_EXTENSIONS = [EXTENSIONS.ts, EXTENSIONS.js, '.tsx', '.jsx'] as const;
|
|
38
|
+
|
|
39
|
+
interface WorkspacePackageJson {
|
|
40
|
+
readonly webstir?: {
|
|
41
|
+
readonly mode?: string;
|
|
42
|
+
readonly enable?: EnableFlags;
|
|
43
|
+
readonly moduleManifest?: {
|
|
44
|
+
readonly views?: ReadonlyArray<{
|
|
45
|
+
readonly renderMode?: string;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class WatchCoordinator {
|
|
52
|
+
private readonly workspaceRoot: string;
|
|
53
|
+
private readonly jsContexts = new Map<string, PageBuildContext>();
|
|
54
|
+
private readonly verbose: boolean;
|
|
55
|
+
private readonly hmrVerbose: boolean;
|
|
56
|
+
private readonly reporter: WatchReporter;
|
|
57
|
+
private readonly hotUpdateTracker: HotUpdateTracker;
|
|
58
|
+
private readonly hmrTotals = { hotUpdates: 0, reloadFallbacks: 0 };
|
|
59
|
+
private config?: FrontendConfig;
|
|
60
|
+
private isSsgWorkspace = false;
|
|
61
|
+
private enable?: EnableFlags;
|
|
62
|
+
private isStopping = false;
|
|
63
|
+
private queue: Promise<void> = Promise.resolve();
|
|
64
|
+
|
|
65
|
+
public constructor(options: WatchCoordinatorOptions) {
|
|
66
|
+
this.workspaceRoot = options.workspaceRoot;
|
|
67
|
+
this.verbose = options.verbose ?? false;
|
|
68
|
+
this.hmrVerbose = options.hmrVerbose ?? false;
|
|
69
|
+
this.reporter = new WatchReporter({ verbose: this.verbose });
|
|
70
|
+
this.hotUpdateTracker = new HotUpdateTracker({ workspaceRoot: this.workspaceRoot });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public async start(): Promise<void> {
|
|
74
|
+
if (this.config) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.reporter.emitVerbose({
|
|
79
|
+
code: 'frontend.watch.starting',
|
|
80
|
+
kind: 'watch-daemon',
|
|
81
|
+
stage: 'startup',
|
|
82
|
+
severity: 'info',
|
|
83
|
+
message: 'Starting frontend watch daemon...'
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.config = await prepareWorkspaceConfig(this.workspaceRoot);
|
|
87
|
+
const workspaceSettings = await this.readWorkspaceSettings(this.workspaceRoot);
|
|
88
|
+
this.isSsgWorkspace = workspaceSettings.isSsg;
|
|
89
|
+
this.enable = workspaceSettings.enable;
|
|
90
|
+
await this.refreshJavaScriptContexts();
|
|
91
|
+
const pipelineReady = await this.runFullBuildCycle();
|
|
92
|
+
|
|
93
|
+
if (pipelineReady) {
|
|
94
|
+
this.reporter.emitVerbose({
|
|
95
|
+
code: 'frontend.watch.ready',
|
|
96
|
+
kind: 'watch-daemon',
|
|
97
|
+
stage: 'startup',
|
|
98
|
+
severity: 'info',
|
|
99
|
+
message: 'Frontend watch daemon is ready.'
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public async reload(): Promise<void> {
|
|
105
|
+
await this.enqueue(async () => {
|
|
106
|
+
if (!this.config) {
|
|
107
|
+
await this.start();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.reporter.emitVerbose({
|
|
112
|
+
code: 'frontend.watch.reload',
|
|
113
|
+
kind: 'watch-daemon',
|
|
114
|
+
stage: 'startup',
|
|
115
|
+
severity: 'info',
|
|
116
|
+
message: 'Reloading frontend watch contexts...'
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await this.refreshJavaScriptContexts();
|
|
120
|
+
const workspaceSettings = await this.readWorkspaceSettings(this.workspaceRoot);
|
|
121
|
+
this.isSsgWorkspace = workspaceSettings.isSsg;
|
|
122
|
+
this.enable = workspaceSettings.enable;
|
|
123
|
+
const pipelineSucceeded = await this.runFullBuildCycle();
|
|
124
|
+
|
|
125
|
+
if (pipelineSucceeded) {
|
|
126
|
+
this.reporter.emitVerbose({
|
|
127
|
+
code: 'frontend.watch.reload.complete',
|
|
128
|
+
kind: 'watch-daemon',
|
|
129
|
+
stage: 'startup',
|
|
130
|
+
severity: 'info',
|
|
131
|
+
message: 'Frontend watch contexts reloaded.'
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public async handleChange(intent: WatchChangeIntent): Promise<void> {
|
|
138
|
+
await this.enqueue(async () => {
|
|
139
|
+
if (!this.config) {
|
|
140
|
+
await this.start();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const resolvedChange = this.resolveChangedFile(intent.path);
|
|
144
|
+
if (resolvedChange && path.resolve(resolvedChange) === path.resolve(this.workspaceRoot, FILES.packageJson)) {
|
|
145
|
+
const workspaceSettings = await this.readWorkspaceSettings(this.workspaceRoot);
|
|
146
|
+
this.isSsgWorkspace = workspaceSettings.isSsg;
|
|
147
|
+
this.enable = workspaceSettings.enable;
|
|
148
|
+
}
|
|
149
|
+
await this.runFullBuildCycle(resolvedChange);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public async stop(): Promise<void> {
|
|
154
|
+
if (this.isStopping) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.isStopping = true;
|
|
159
|
+
await this.enqueue(async () => {
|
|
160
|
+
for (const entry of this.jsContexts.values()) {
|
|
161
|
+
await entry.context.dispose();
|
|
162
|
+
}
|
|
163
|
+
this.jsContexts.clear();
|
|
164
|
+
this.hotUpdateTracker.reset();
|
|
165
|
+
this.config = undefined;
|
|
166
|
+
});
|
|
167
|
+
this.isStopping = false;
|
|
168
|
+
|
|
169
|
+
this.reporter.emitVerbose({
|
|
170
|
+
code: 'frontend.watch.stopped',
|
|
171
|
+
kind: 'watch-daemon',
|
|
172
|
+
stage: 'shutdown',
|
|
173
|
+
severity: 'info',
|
|
174
|
+
message: 'Frontend watch daemon stopped.'
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async enqueue(task: () => Promise<void>): Promise<void> {
|
|
179
|
+
const runTask = async () => {
|
|
180
|
+
try {
|
|
181
|
+
await task();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.logUnexpectedError('queue-task', error);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
this.queue = this.queue.then(runTask, runTask);
|
|
188
|
+
await this.queue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async refreshJavaScriptContexts(): Promise<void> {
|
|
192
|
+
const config = this.requireConfig();
|
|
193
|
+
const pages = await getPages(config.paths.src.pages);
|
|
194
|
+
const observed = new Set<string>();
|
|
195
|
+
|
|
196
|
+
for (const page of pages) {
|
|
197
|
+
observed.add(page.name);
|
|
198
|
+
await this.ensureJavaScriptContext(config, page);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const existing of Array.from(this.jsContexts.keys())) {
|
|
202
|
+
if (!observed.has(existing)) {
|
|
203
|
+
const context = this.jsContexts.get(existing);
|
|
204
|
+
if (context) {
|
|
205
|
+
await context.context.dispose();
|
|
206
|
+
}
|
|
207
|
+
this.jsContexts.delete(existing);
|
|
208
|
+
this.hotUpdateTracker.removePage(existing);
|
|
209
|
+
this.reporter.emitVerbose({
|
|
210
|
+
code: 'frontend.watch.javascript.context.removed',
|
|
211
|
+
kind: 'watch-daemon',
|
|
212
|
+
stage: 'javascript',
|
|
213
|
+
severity: 'info',
|
|
214
|
+
message: `Removed watch context for page '${existing}'.`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async ensureJavaScriptContext(config: FrontendConfig, page: PageInfo): Promise<void> {
|
|
221
|
+
const entryPoint = await resolveEntryPoint(page.directory);
|
|
222
|
+
if (!entryPoint) {
|
|
223
|
+
if (!this.isSsgWorkspace) {
|
|
224
|
+
emitDiagnostic({
|
|
225
|
+
code: 'frontend.watch.javascript.entry.missing',
|
|
226
|
+
kind: 'watch-daemon',
|
|
227
|
+
stage: 'javascript',
|
|
228
|
+
severity: 'warning',
|
|
229
|
+
message: `No JavaScript entry point found for page '${page.name}'.`
|
|
230
|
+
});
|
|
231
|
+
} else if (this.verbose) {
|
|
232
|
+
emitDiagnostic({
|
|
233
|
+
code: 'frontend.watch.javascript.entry.missing',
|
|
234
|
+
kind: 'watch-daemon',
|
|
235
|
+
stage: 'javascript',
|
|
236
|
+
severity: 'info',
|
|
237
|
+
message: `No JavaScript entry point found for page '${page.name}' (ssg workspace).`
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (this.jsContexts.has(page.name)) {
|
|
241
|
+
const existing = this.jsContexts.get(page.name);
|
|
242
|
+
if (existing) {
|
|
243
|
+
await existing.context.dispose();
|
|
244
|
+
}
|
|
245
|
+
this.jsContexts.delete(page.name);
|
|
246
|
+
this.hotUpdateTracker.removePage(page.name);
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const existing = this.jsContexts.get(page.name);
|
|
252
|
+
if (existing && path.resolve(existing.entryPoint) === path.resolve(entryPoint)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (existing) {
|
|
257
|
+
await existing.context.dispose();
|
|
258
|
+
this.jsContexts.delete(page.name);
|
|
259
|
+
this.hotUpdateTracker.removePage(page.name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const outputDir = path.join(config.paths.build.frontend, FOLDERS.pages, page.name);
|
|
263
|
+
await ensureDir(outputDir);
|
|
264
|
+
|
|
265
|
+
const context = await createEsbuildContext({
|
|
266
|
+
entryPoints: [entryPoint],
|
|
267
|
+
bundle: true,
|
|
268
|
+
format: 'esm',
|
|
269
|
+
target: 'es2020',
|
|
270
|
+
platform: 'browser',
|
|
271
|
+
sourcemap: true,
|
|
272
|
+
outfile: path.join(outputDir, `${FILES.index}${EXTENSIONS.js}`),
|
|
273
|
+
logLevel: 'silent',
|
|
274
|
+
metafile: true
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
this.jsContexts.set(page.name, {
|
|
278
|
+
name: page.name,
|
|
279
|
+
entryPoint,
|
|
280
|
+
context
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
this.reporter.emitVerbose({
|
|
284
|
+
code: 'frontend.watch.javascript.context.created',
|
|
285
|
+
kind: 'watch-daemon',
|
|
286
|
+
stage: 'javascript',
|
|
287
|
+
severity: 'info',
|
|
288
|
+
message: `Created watch context for page '${page.name}'.`
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async runFullBuildCycle(changedFile?: string): Promise<boolean> {
|
|
293
|
+
const summary = await this.runJavaScriptBuild(changedFile);
|
|
294
|
+
if (!summary) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const assetsResult = await this.runAdditionalBuilders(changedFile);
|
|
299
|
+
if (!assetsResult.succeeded) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const requiresReload = !changedFile || summary.requiresReload || assetsResult.requiresReload;
|
|
304
|
+
const fallbackReasons = this.combineFallbackReasons(summary.fallbackReasons, assetsResult.fallbackReasons);
|
|
305
|
+
const relativeChange = this.getRelativeChange(changedFile);
|
|
306
|
+
const baseHotUpdate = {
|
|
307
|
+
modules: summary.modules,
|
|
308
|
+
styles: assetsResult.styles,
|
|
309
|
+
requiresReload,
|
|
310
|
+
fallbackReasons,
|
|
311
|
+
changedFile
|
|
312
|
+
};
|
|
313
|
+
const stats = this.recordHotUpdateOutcome(changedFile, relativeChange, baseHotUpdate);
|
|
314
|
+
const hotUpdate: HotUpdateDetails = stats
|
|
315
|
+
? {
|
|
316
|
+
...baseHotUpdate,
|
|
317
|
+
stats
|
|
318
|
+
}
|
|
319
|
+
: baseHotUpdate;
|
|
320
|
+
|
|
321
|
+
if (changedFile && requiresReload) {
|
|
322
|
+
this.emitHotUpdateFallback(relativeChange ?? changedFile, hotUpdate);
|
|
323
|
+
}
|
|
324
|
+
emitPipelineSuccess(summary, assetsResult, changedFile, relativeChange, hotUpdate);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private async runAdditionalBuilders(changedFile?: string): Promise<AdditionalBuildResult> {
|
|
329
|
+
const config = this.requireConfig();
|
|
330
|
+
const context: BuilderContext = { config, changedFile, enable: this.enable };
|
|
331
|
+
const builders: Builder[] = [
|
|
332
|
+
createCssBuilder(context),
|
|
333
|
+
createHtmlBuilder(context),
|
|
334
|
+
createStaticAssetsBuilder(context)
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
const executed: string[] = [];
|
|
338
|
+
const styles: HotAsset[] = [];
|
|
339
|
+
let succeeded = true;
|
|
340
|
+
let requiresReload = false;
|
|
341
|
+
const pageNames = Array.from(this.jsContexts.keys());
|
|
342
|
+
const relativeChange = this.getRelativeChange(changedFile);
|
|
343
|
+
const fallbackReasons: string[] = [];
|
|
344
|
+
const normalizedChange = changedFile ? path.resolve(changedFile) : undefined;
|
|
345
|
+
const appTemplatePath = path.resolve(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
|
|
346
|
+
const isHtmlChange = Boolean(normalizedChange && (
|
|
347
|
+
(path.extname(normalizedChange).toLowerCase() === EXTENSIONS.html
|
|
348
|
+
&& (isPathInside(normalizedChange, config.paths.src.pages) || isPathInside(normalizedChange, config.paths.src.app)))
|
|
349
|
+
|| normalizedChange === appTemplatePath
|
|
350
|
+
));
|
|
351
|
+
const staticAssetDirectories = [
|
|
352
|
+
config.paths.src.images,
|
|
353
|
+
config.paths.src.fonts,
|
|
354
|
+
config.paths.src.media
|
|
355
|
+
].filter((directory): directory is string => Boolean(directory)).map((directory) => path.resolve(directory));
|
|
356
|
+
const robotsPath = path.resolve(config.paths.src.frontend, FILES.robotsTxt);
|
|
357
|
+
const isStaticAssetChange = Boolean(normalizedChange && (
|
|
358
|
+
staticAssetDirectories.some(directory => isPathInside(normalizedChange!, directory))
|
|
359
|
+
|| normalizedChange === robotsPath
|
|
360
|
+
));
|
|
361
|
+
|
|
362
|
+
for (const builder of builders) {
|
|
363
|
+
executed.push(builder.name);
|
|
364
|
+
const builderSucceeded = await runBuilderWithDiagnostics(
|
|
365
|
+
builder,
|
|
366
|
+
this.reporter,
|
|
367
|
+
context,
|
|
368
|
+
changedFile,
|
|
369
|
+
relativeChange
|
|
370
|
+
);
|
|
371
|
+
if (!builderSucceeded) {
|
|
372
|
+
succeeded = false;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (builder.name === 'css') {
|
|
377
|
+
const cssResult = await this.hotUpdateTracker.collectCssChanges(context, pageNames);
|
|
378
|
+
styles.push(...cssResult.styles);
|
|
379
|
+
if (cssResult.requiresReload) {
|
|
380
|
+
requiresReload = true;
|
|
381
|
+
}
|
|
382
|
+
fallbackReasons.push(...cssResult.fallbackReasons);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (builder.name === 'html') {
|
|
386
|
+
if (!changedFile || isHtmlChange) {
|
|
387
|
+
requiresReload = true;
|
|
388
|
+
fallbackReasons.push('builder.html.reload');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (builder.name === 'static-assets') {
|
|
393
|
+
if (!changedFile || isStaticAssetChange) {
|
|
394
|
+
requiresReload = true;
|
|
395
|
+
fallbackReasons.push('builder.static-assets.reload');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
succeeded,
|
|
402
|
+
assets: executed,
|
|
403
|
+
styles,
|
|
404
|
+
requiresReload,
|
|
405
|
+
fallbackReasons: this.combineFallbackReasons([], fallbackReasons)
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private getRelativeChange(changedFile?: string): string | undefined {
|
|
410
|
+
if (!changedFile) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return path.relative(this.workspaceRoot, changedFile);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async runJavaScriptBuild(changedFile?: string): Promise<JavaScriptBuildSummary | null> {
|
|
418
|
+
const config = this.requireConfig();
|
|
419
|
+
const context: BuilderContext = { config, changedFile, enable: this.enable };
|
|
420
|
+
const shouldRun = shouldProcess(context, [
|
|
421
|
+
{
|
|
422
|
+
directory: config.paths.src.frontend,
|
|
423
|
+
extensions: JAVASCRIPT_EXTENSIONS
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
directory: config.paths.src.pages,
|
|
427
|
+
extensions: JAVASCRIPT_EXTENSIONS
|
|
428
|
+
}
|
|
429
|
+
]);
|
|
430
|
+
|
|
431
|
+
const relativeChange = this.getRelativeChange(changedFile);
|
|
432
|
+
|
|
433
|
+
if (shouldRun) {
|
|
434
|
+
this.reporter.emitVerbose({
|
|
435
|
+
code: 'frontend.watch.javascript.build.start',
|
|
436
|
+
kind: 'watch-daemon',
|
|
437
|
+
stage: 'javascript',
|
|
438
|
+
severity: 'info',
|
|
439
|
+
message: `Starting JavaScript rebuild${relativeChange ? ` (${relativeChange})` : ''}.`,
|
|
440
|
+
data: changedFile ? { changedFile } : undefined
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const summary = shouldRun
|
|
446
|
+
? await this.executeJavaScriptBuild(changedFile)
|
|
447
|
+
: { pagesBuilt: [], warnings: [], modules: [], requiresReload: false, fallbackReasons: [] };
|
|
448
|
+
const skipped = !shouldRun;
|
|
449
|
+
const message = skipped
|
|
450
|
+
? `JavaScript rebuild not required${relativeChange ? ` (${relativeChange})` : ''}.`
|
|
451
|
+
: `JavaScript rebuild completed (${summary.pagesBuilt.length} page(s))${relativeChange ? ` (${relativeChange})` : ''}.`;
|
|
452
|
+
|
|
453
|
+
this.reporter.emitVerbose({
|
|
454
|
+
code: 'frontend.watch.javascript.build.success',
|
|
455
|
+
kind: 'watch-daemon',
|
|
456
|
+
stage: 'javascript',
|
|
457
|
+
severity: 'info',
|
|
458
|
+
message,
|
|
459
|
+
data: serializeSummary(summary, changedFile, skipped)
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return summary;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
emitJavaScriptFailure(error, changedFile);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private async executeJavaScriptBuild(changedFile?: string): Promise<JavaScriptBuildSummary> {
|
|
470
|
+
const config = this.requireConfig();
|
|
471
|
+
const targetPages = this.resolveTargetPages(changedFile);
|
|
472
|
+
if (targetPages.length === 0) {
|
|
473
|
+
return { pagesBuilt: [], warnings: [], modules: [], requiresReload: false, fallbackReasons: [] };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const warnings: SerializedMessage[] = [];
|
|
477
|
+
const builtPages: string[] = [];
|
|
478
|
+
const modules: HotAsset[] = [];
|
|
479
|
+
let requiresReload = false;
|
|
480
|
+
const fallbackReasons: string[] = [];
|
|
481
|
+
for (const pageName of targetPages) {
|
|
482
|
+
const pageContext = this.jsContexts.get(pageName);
|
|
483
|
+
if (!pageContext) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const start = performance.now();
|
|
489
|
+
const result = await pageContext.context.rebuild();
|
|
490
|
+
const duration = performance.now() - start;
|
|
491
|
+
builtPages.push(pageName);
|
|
492
|
+
warnings.push(...serializeMessages(result.warnings ?? []));
|
|
493
|
+
this.reporter.emitJavaScriptStats(pageName, result, duration);
|
|
494
|
+
|
|
495
|
+
const outputDetails = await this.hotUpdateTracker.processJavaScriptResult(pageName, result, config);
|
|
496
|
+
modules.push(...outputDetails.modules);
|
|
497
|
+
if (outputDetails.requiresReload) {
|
|
498
|
+
requiresReload = true;
|
|
499
|
+
}
|
|
500
|
+
fallbackReasons.push(...outputDetails.fallbackReasons);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
throw new JavaScriptBuildError(pageName, error);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (builtPages.length > 0) {
|
|
507
|
+
await copyRefreshScript(this.requireConfig(), this.enable);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
pagesBuilt: builtPages,
|
|
512
|
+
warnings,
|
|
513
|
+
modules,
|
|
514
|
+
requiresReload,
|
|
515
|
+
fallbackReasons: this.combineFallbackReasons([], fallbackReasons)
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async readWorkspaceSettings(workspaceRoot: string): Promise<{ isSsg: boolean; enable?: EnableFlags }> {
|
|
520
|
+
const pkgPath = path.join(workspaceRoot, FILES.packageJson);
|
|
521
|
+
const pkg = await readJson<WorkspacePackageJson>(pkgPath);
|
|
522
|
+
if (!pkg?.webstir) {
|
|
523
|
+
return { isSsg: false, enable: undefined };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const stringMode = pkg.webstir.mode;
|
|
527
|
+
if (typeof stringMode === 'string' && stringMode.toLowerCase() === 'ssg') {
|
|
528
|
+
return { isSsg: true, enable: pkg.webstir.enable };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const views = pkg.webstir.moduleManifest?.views;
|
|
532
|
+
const hasSsgView = Array.isArray(views) && views.some(view => view.renderMode?.toLowerCase() === 'ssg');
|
|
533
|
+
return { isSsg: hasSsgView, enable: pkg.webstir.enable };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private resolveTargetPages(changedFile?: string): string[] {
|
|
537
|
+
if (!changedFile) {
|
|
538
|
+
return Array.from(this.jsContexts.keys());
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const config = this.requireConfig();
|
|
542
|
+
const targetPage = findPageFromChangedFile(changedFile, config.paths.src.pages);
|
|
543
|
+
if (targetPage && this.jsContexts.has(targetPage)) {
|
|
544
|
+
return [targetPage];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return Array.from(this.jsContexts.keys());
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private resolveChangedFile(changedFile?: string): string | undefined {
|
|
551
|
+
if (!changedFile) {
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (path.isAbsolute(changedFile)) {
|
|
556
|
+
return changedFile;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return path.resolve(this.workspaceRoot, changedFile);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private emitHotUpdateFallback(changedFile: string, hotUpdate: HotUpdateDetails): void {
|
|
563
|
+
if (hotUpdate.fallbackReasons.length === 0) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
emitDiagnostic({
|
|
568
|
+
code: 'frontend.watch.pipeline.hmrfallback',
|
|
569
|
+
kind: 'watch-daemon',
|
|
570
|
+
stage: 'pipeline',
|
|
571
|
+
severity: 'info',
|
|
572
|
+
message: `Hot update fallback triggered for '${changedFile}' (${hotUpdate.fallbackReasons.join(', ')}).`,
|
|
573
|
+
data: {
|
|
574
|
+
changedFile,
|
|
575
|
+
reasons: hotUpdate.fallbackReasons,
|
|
576
|
+
modules: hotUpdate.modules.map(asset => asset.url),
|
|
577
|
+
styles: hotUpdate.styles.map(asset => asset.url)
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private recordHotUpdateOutcome(
|
|
583
|
+
changedFile: string | undefined,
|
|
584
|
+
relativeChange: string | undefined,
|
|
585
|
+
hotUpdate: Omit<HotUpdateDetails, 'stats'>
|
|
586
|
+
): HotUpdateStats | undefined {
|
|
587
|
+
if (!changedFile) {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (hotUpdate.requiresReload) {
|
|
592
|
+
this.hmrTotals.reloadFallbacks += 1;
|
|
593
|
+
} else {
|
|
594
|
+
this.hmrTotals.hotUpdates += 1;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const snapshot: HotUpdateStats = {
|
|
598
|
+
hotUpdates: this.hmrTotals.hotUpdates,
|
|
599
|
+
reloadFallbacks: this.hmrTotals.reloadFallbacks
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
if (hotUpdate.requiresReload && hotUpdate.fallbackReasons.length > 0) {
|
|
603
|
+
this.reporter.emitVerbose({
|
|
604
|
+
code: 'frontend.watch.hmr.fallback.detail',
|
|
605
|
+
kind: 'watch-daemon',
|
|
606
|
+
stage: 'pipeline',
|
|
607
|
+
severity: 'info',
|
|
608
|
+
message: `Hot update declined for '${relativeChange ?? changedFile}'.`,
|
|
609
|
+
data: {
|
|
610
|
+
changedFile: relativeChange ?? changedFile,
|
|
611
|
+
fallbackReasons: hotUpdate.fallbackReasons
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (this.hmrVerbose) {
|
|
617
|
+
const identifier = relativeChange ?? changedFile;
|
|
618
|
+
const modules = hotUpdate.modules.map(asset => asset.relativePath);
|
|
619
|
+
const styles = hotUpdate.styles.map(asset => asset.relativePath);
|
|
620
|
+
emitDiagnostic({
|
|
621
|
+
code: 'frontend.watch.hmr.summary',
|
|
622
|
+
kind: 'watch-daemon',
|
|
623
|
+
stage: 'pipeline',
|
|
624
|
+
severity: 'info',
|
|
625
|
+
message: hotUpdate.requiresReload
|
|
626
|
+
? `HMR fallback required for '${identifier}'.`
|
|
627
|
+
: `Hot update applied for '${identifier}'.`,
|
|
628
|
+
data: {
|
|
629
|
+
changedFile: identifier,
|
|
630
|
+
requiresReload: hotUpdate.requiresReload,
|
|
631
|
+
fallbackReasons: hotUpdate.fallbackReasons,
|
|
632
|
+
modules,
|
|
633
|
+
styles,
|
|
634
|
+
totals: snapshot
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return snapshot;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private combineFallbackReasons(
|
|
643
|
+
first: readonly string[],
|
|
644
|
+
second: readonly string[]
|
|
645
|
+
): readonly string[] {
|
|
646
|
+
return Array.from(new Set([...first, ...second].filter(Boolean)));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private requireConfig(): FrontendConfig {
|
|
650
|
+
if (!this.config) {
|
|
651
|
+
throw new Error('Watch coordinator not initialized.');
|
|
652
|
+
}
|
|
653
|
+
return this.config;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private logUnexpectedError(stage: string, error: unknown): void {
|
|
657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
658
|
+
emitDiagnostic({
|
|
659
|
+
code: 'frontend.watch.unexpected',
|
|
660
|
+
kind: 'watch-daemon',
|
|
661
|
+
stage,
|
|
662
|
+
severity: 'error',
|
|
663
|
+
message: `Unexpected watch daemon error: ${message}`
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|