@design.estate/wcctools 6.2.0 → 7.0.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/changelog.md +21 -0
- package/dist_shell/bundle.js +4 -4
- package/dist_shell/bundle.js.map +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/capture/classes.capturebrowser.d.ts +65 -0
- package/dist_ts/capture/classes.capturebrowser.js +251 -0
- package/dist_ts/capture/classes.captureservice.d.ts +39 -0
- package/dist_ts/capture/classes.captureservice.js +119 -0
- package/dist_ts/capture/classes.screencast.d.ts +47 -0
- package/dist_ts/capture/classes.screencast.js +127 -0
- package/dist_ts/capture/errors.d.ts +14 -0
- package/dist_ts/capture/errors.js +23 -0
- package/dist_ts/capture/images.d.ts +3 -0
- package/dist_ts/capture/images.js +58 -0
- package/dist_ts/capture/index.d.ts +4 -0
- package/dist_ts/capture/index.js +5 -0
- package/dist_ts/capture/interaction.d.ts +11 -0
- package/dist_ts/capture/interaction.js +138 -0
- package/dist_ts/capture/navigationguard.d.ts +44 -0
- package/dist_ts/capture/navigationguard.js +129 -0
- package/dist_ts/capture/previewpage.d.ts +29 -0
- package/dist_ts/capture/previewpage.js +96 -0
- package/dist_ts/capture/request.d.ts +69 -0
- package/dist_ts/capture/request.js +239 -0
- package/dist_ts/classes.devserver.d.ts +25 -9
- package/dist_ts/classes.devserver.js +45 -14
- package/dist_ts/classes.hostpolicy.d.ts +63 -0
- package/dist_ts/classes.hostpolicy.js +171 -0
- package/dist_ts/cli.js +25 -3
- package/dist_ts/cli.screenshot.d.ts +6 -0
- package/dist_ts/cli.screenshot.js +89 -0
- package/dist_ts/plugins.d.ts +6 -2
- package/dist_ts/plugins.js +8 -3
- package/dist_ts_interfaces/capture.d.ts +151 -0
- package/dist_ts_interfaces/capture.js +2 -0
- package/dist_ts_interfaces/index.d.ts +1 -0
- package/dist_ts_interfaces/index.js +2 -1
- package/dist_ts_interfaces/requests.d.ts +24 -0
- package/dist_ts_shell/elements/wcc-recording-panel.d.ts +9 -0
- package/dist_ts_shell/elements/wcc-recording-panel.js +12 -1
- package/dist_ts_shell/services/framesampler.service.d.ts +23 -0
- package/dist_ts_shell/services/framesampler.service.js +101 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +2 -1
- package/readme.md +104 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/capture/classes.capturebrowser.ts +290 -0
- package/ts/capture/classes.captureservice.ts +152 -0
- package/ts/capture/classes.screencast.ts +152 -0
- package/ts/capture/errors.ts +25 -0
- package/ts/capture/images.ts +61 -0
- package/ts/capture/index.ts +4 -0
- package/ts/capture/interaction.ts +146 -0
- package/ts/capture/navigationguard.ts +147 -0
- package/ts/capture/previewpage.ts +130 -0
- package/ts/capture/request.ts +304 -0
- package/ts/classes.devserver.ts +55 -13
- package/ts/classes.hostpolicy.ts +201 -0
- package/ts/cli.screenshot.ts +95 -0
- package/ts/cli.ts +25 -2
- package/ts/plugins.ts +9 -2
- package/ts_interfaces/capture.ts +144 -0
- package/ts_interfaces/index.ts +1 -0
- package/ts_interfaces/requests.ts +39 -0
- package/ts_shell/elements/wcc-recording-panel.ts +12 -0
- package/ts_shell/services/framesampler.service.ts +112 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { WccCaptureRequestError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
type IWccCaptureSubject = plugins.interfaces.IWccCaptureSubject;
|
|
5
|
+
type TWccCaptureFraming = plugins.interfaces.TWccCaptureFraming;
|
|
6
|
+
type TWccInteractionStep = plugins.interfaces.TWccInteractionStep;
|
|
7
|
+
|
|
8
|
+
/** The limits every capture request is held to. */
|
|
9
|
+
export const captureLimits = {
|
|
10
|
+
minWidth: 200,
|
|
11
|
+
maxWidth: 3840,
|
|
12
|
+
minHeight: 200,
|
|
13
|
+
maxHeight: 4320,
|
|
14
|
+
defaultHeight: 800,
|
|
15
|
+
/** Full-page captures are cut at this height in CSS pixels. */
|
|
16
|
+
maxFullPageHeight: 10_000,
|
|
17
|
+
defaultJpegQuality: 80,
|
|
18
|
+
maxSteps: 20,
|
|
19
|
+
stepTimeoutMs: 5_000,
|
|
20
|
+
maxWaitMs: 3_000,
|
|
21
|
+
defaultObserveMs: 500,
|
|
22
|
+
maxObserveMs: 5_000,
|
|
23
|
+
defaultMaxFrames: 12,
|
|
24
|
+
maxMaxFrames: 40,
|
|
25
|
+
defaultFrameQuality: 70,
|
|
26
|
+
maxSelectorLength: 500,
|
|
27
|
+
maxTextLength: 2_000,
|
|
28
|
+
maxKeyLength: 32,
|
|
29
|
+
maxSubjectLength: 200,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** The capture widths of the named viewports: the dees-domtools breakpoints. */
|
|
33
|
+
export const captureViewportWidths: Readonly<Record<plugins.interfaces.TWccCaptureViewportPreset, number>> = {
|
|
34
|
+
phone: plugins.deesDomtools.breakpoints.phone,
|
|
35
|
+
phablet: plugins.deesDomtools.breakpoints.phablet,
|
|
36
|
+
tablet: plugins.deesDomtools.breakpoints.tablet,
|
|
37
|
+
desktop: plugins.deesDomtools.breakpoints.desktop,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isViewportPreset = (valueArg: unknown): valueArg is plugins.interfaces.TWccCaptureViewportPreset => {
|
|
41
|
+
return typeof valueArg === 'string' && Object.hasOwn(captureViewportWidths, valueArg);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const framings: readonly TWccCaptureFraming[] = ['element', 'viewport', 'fullpage'];
|
|
45
|
+
|
|
46
|
+
/** The preview set-up of a capture, validated and with its defaults applied. */
|
|
47
|
+
export interface IWccResolvedView {
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
theme: plugins.interfaces.TWccTheme;
|
|
51
|
+
scale: 1 | 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface IWccResolvedImageOptions {
|
|
55
|
+
/** Undefined: the default framing of what the demo rendered. */
|
|
56
|
+
framing?: TWccCaptureFraming;
|
|
57
|
+
format: 'png' | 'jpeg';
|
|
58
|
+
/** JPEG quality; undefined for PNG. */
|
|
59
|
+
quality?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface IWccResolvedScreenshotRequest {
|
|
63
|
+
subject: IWccCaptureSubject;
|
|
64
|
+
view: IWccResolvedView;
|
|
65
|
+
image: IWccResolvedImageOptions;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface IWccResolvedInteractionRequest {
|
|
69
|
+
subject: IWccCaptureSubject;
|
|
70
|
+
view: IWccResolvedView;
|
|
71
|
+
steps: TWccInteractionStep[];
|
|
72
|
+
observeMs: number;
|
|
73
|
+
maxFrames: number;
|
|
74
|
+
frameQuality: number;
|
|
75
|
+
finalScreenshot: IWccResolvedImageOptions | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isInteger = (valueArg: unknown): valueArg is number => typeof valueArg === 'number' && Number.isInteger(valueArg);
|
|
79
|
+
|
|
80
|
+
const requireIntegerIn = (valueArg: unknown, minArg: number, maxArg: number, nameArg: string): number => {
|
|
81
|
+
if (!isInteger(valueArg) || valueArg < minArg || valueArg > maxArg) {
|
|
82
|
+
throw new WccCaptureRequestError(`wcctools: ${nameArg} must be a whole number from ${minArg} to ${maxArg}.`);
|
|
83
|
+
}
|
|
84
|
+
return valueArg;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const requireText = (valueArg: unknown, maxLengthArg: number, nameArg: string, allowEmptyArg = false): string => {
|
|
88
|
+
if (typeof valueArg !== 'string' || (!allowEmptyArg && valueArg.trim() === '') || valueArg.length > maxLengthArg) {
|
|
89
|
+
throw new WccCaptureRequestError(`wcctools: ${nameArg} must be ${allowEmptyArg ? 'a' : 'a non-empty'} string of at most ${maxLengthArg} characters.`);
|
|
90
|
+
}
|
|
91
|
+
return valueArg;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const resolveSubject = (subjectArg: unknown): IWccCaptureSubject => {
|
|
95
|
+
if (typeof subjectArg !== 'object' || subjectArg === null) {
|
|
96
|
+
throw new WccCaptureRequestError('wcctools: a capture needs a subject: the catalog entry to render.');
|
|
97
|
+
}
|
|
98
|
+
const subject = subjectArg as Partial<IWccCaptureSubject>;
|
|
99
|
+
const resolved: IWccCaptureSubject = {
|
|
100
|
+
itemName: requireText(subject.itemName, captureLimits.maxSubjectLength, 'the subject\'s itemName'),
|
|
101
|
+
demoIndex: subject.demoIndex === undefined ? 0 : requireIntegerIn(subject.demoIndex, 0, 1_000, 'the demo index'),
|
|
102
|
+
};
|
|
103
|
+
if (subject.sectionName !== undefined) {
|
|
104
|
+
resolved.sectionName = requireText(subject.sectionName, captureLimits.maxSubjectLength, 'the subject\'s sectionName');
|
|
105
|
+
}
|
|
106
|
+
return resolved;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Reads a viewport: a named one, or a width in CSS pixels. */
|
|
110
|
+
export const resolveViewportWidth = (viewportArg: unknown): number => {
|
|
111
|
+
if (viewportArg === undefined) {
|
|
112
|
+
return captureViewportWidths.desktop;
|
|
113
|
+
}
|
|
114
|
+
if (isViewportPreset(viewportArg)) {
|
|
115
|
+
return captureViewportWidths[viewportArg];
|
|
116
|
+
}
|
|
117
|
+
if (isInteger(viewportArg)) {
|
|
118
|
+
return requireIntegerIn(viewportArg, captureLimits.minWidth, captureLimits.maxWidth, 'the viewport width');
|
|
119
|
+
}
|
|
120
|
+
throw new WccCaptureRequestError(
|
|
121
|
+
`wcctools: the viewport must be one of ${Object.keys(captureViewportWidths).join(', ')} or a width from ${captureLimits.minWidth} to ${captureLimits.maxWidth}.`,
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const resolveView = (requestArg: plugins.interfaces.IWccCaptureView): IWccResolvedView => {
|
|
126
|
+
const theme = requestArg.theme ?? 'dark';
|
|
127
|
+
if (theme !== 'dark' && theme !== 'bright') {
|
|
128
|
+
throw new WccCaptureRequestError('wcctools: the theme must be dark or bright.');
|
|
129
|
+
}
|
|
130
|
+
const scale = requestArg.scale ?? 1;
|
|
131
|
+
if (scale !== 1 && scale !== 2) {
|
|
132
|
+
throw new WccCaptureRequestError('wcctools: the scale must be 1 or 2.');
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
width: resolveViewportWidth(requestArg.viewport),
|
|
136
|
+
height: requestArg.height === undefined
|
|
137
|
+
? captureLimits.defaultHeight
|
|
138
|
+
: requireIntegerIn(requestArg.height, captureLimits.minHeight, captureLimits.maxHeight, 'the height'),
|
|
139
|
+
theme,
|
|
140
|
+
scale,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const resolveImageOptions = (
|
|
145
|
+
optionsArg: { framing?: unknown; format?: unknown; quality?: unknown },
|
|
146
|
+
): IWccResolvedImageOptions => {
|
|
147
|
+
const framing = optionsArg.framing;
|
|
148
|
+
if (framing !== undefined && !framings.includes(framing as TWccCaptureFraming)) {
|
|
149
|
+
throw new WccCaptureRequestError(`wcctools: the framing must be one of ${framings.join(', ')}.`);
|
|
150
|
+
}
|
|
151
|
+
const format = optionsArg.format ?? 'png';
|
|
152
|
+
if (format !== 'png' && format !== 'jpeg') {
|
|
153
|
+
throw new WccCaptureRequestError('wcctools: the format must be png or jpeg.');
|
|
154
|
+
}
|
|
155
|
+
if (format === 'png' && optionsArg.quality !== undefined) {
|
|
156
|
+
throw new WccCaptureRequestError('wcctools: a quality applies to jpeg only.');
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
framing: framing as TWccCaptureFraming | undefined,
|
|
160
|
+
format,
|
|
161
|
+
quality: format === 'jpeg'
|
|
162
|
+
? (optionsArg.quality === undefined ? captureLimits.defaultJpegQuality : requireIntegerIn(optionsArg.quality, 1, 100, 'the quality'))
|
|
163
|
+
: undefined,
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Validates a screenshot request and applies its defaults. */
|
|
168
|
+
export const resolveScreenshotRequest = (requestArg: plugins.interfaces.IWccScreenshotRequest): IWccResolvedScreenshotRequest => {
|
|
169
|
+
if (typeof requestArg !== 'object' || requestArg === null) {
|
|
170
|
+
throw new WccCaptureRequestError('wcctools: the screenshot request is missing.');
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
subject: resolveSubject(requestArg.subject),
|
|
174
|
+
view: resolveView(requestArg),
|
|
175
|
+
image: resolveImageOptions(requestArg),
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const resolveStep = (stepArg: unknown, indexArg: number): TWccInteractionStep => {
|
|
180
|
+
const name = `step ${indexArg}`;
|
|
181
|
+
if (typeof stepArg !== 'object' || stepArg === null) {
|
|
182
|
+
throw new WccCaptureRequestError(`wcctools: ${name} must be an object with an action.`);
|
|
183
|
+
}
|
|
184
|
+
const step = stepArg as Record<string, unknown>;
|
|
185
|
+
const selector = (requiredArg: boolean) => {
|
|
186
|
+
if (!requiredArg && step.selector === undefined) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return requireText(step.selector, captureLimits.maxSelectorLength, `the selector of ${name}`);
|
|
190
|
+
};
|
|
191
|
+
switch (step.action) {
|
|
192
|
+
case 'click':
|
|
193
|
+
case 'hover':
|
|
194
|
+
return { action: step.action, selector: selector(true) };
|
|
195
|
+
case 'fill':
|
|
196
|
+
return {
|
|
197
|
+
action: 'fill',
|
|
198
|
+
selector: selector(true),
|
|
199
|
+
text: requireText(step.text, captureLimits.maxTextLength, `the text of ${name}`, true),
|
|
200
|
+
};
|
|
201
|
+
case 'press': {
|
|
202
|
+
const pressStep: TWccInteractionStep = {
|
|
203
|
+
action: 'press',
|
|
204
|
+
key: requireText(step.key, captureLimits.maxKeyLength, `the key of ${name}`),
|
|
205
|
+
};
|
|
206
|
+
const pressSelector = selector(false);
|
|
207
|
+
return pressSelector === undefined ? pressStep : { ...pressStep, selector: pressSelector };
|
|
208
|
+
}
|
|
209
|
+
case 'waitFor': {
|
|
210
|
+
const state = step.state ?? 'visible';
|
|
211
|
+
if (state !== 'visible' && state !== 'hidden') {
|
|
212
|
+
throw new WccCaptureRequestError(`wcctools: the state of ${name} must be visible or hidden.`);
|
|
213
|
+
}
|
|
214
|
+
return { action: 'waitFor', selector: selector(true), state };
|
|
215
|
+
}
|
|
216
|
+
case 'wait':
|
|
217
|
+
return { action: 'wait', ms: requireIntegerIn(step.ms, 0, captureLimits.maxWaitMs, `the time of ${name}`) };
|
|
218
|
+
default:
|
|
219
|
+
throw new WccCaptureRequestError(`wcctools: the action of ${name} must be one of click, hover, fill, press, waitFor, wait.`);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Validates an interaction request and applies its defaults. */
|
|
224
|
+
export const resolveInteractionRequest = (requestArg: plugins.interfaces.IWccInteractionRequest): IWccResolvedInteractionRequest => {
|
|
225
|
+
if (typeof requestArg !== 'object' || requestArg === null) {
|
|
226
|
+
throw new WccCaptureRequestError('wcctools: the interaction request is missing.');
|
|
227
|
+
}
|
|
228
|
+
if (!Array.isArray(requestArg.steps) || requestArg.steps.length > captureLimits.maxSteps) {
|
|
229
|
+
throw new WccCaptureRequestError(`wcctools: an interaction has a list of at most ${captureLimits.maxSteps} steps.`);
|
|
230
|
+
}
|
|
231
|
+
if (requestArg.finalScreenshot !== undefined && (typeof requestArg.finalScreenshot !== 'object' || requestArg.finalScreenshot === null)) {
|
|
232
|
+
throw new WccCaptureRequestError('wcctools: finalScreenshot must be an object.');
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
subject: resolveSubject(requestArg.subject),
|
|
236
|
+
view: resolveView(requestArg),
|
|
237
|
+
steps: requestArg.steps.map((stepArg, indexArg) => resolveStep(stepArg, indexArg)),
|
|
238
|
+
observeMs: requestArg.observeMs === undefined
|
|
239
|
+
? captureLimits.defaultObserveMs
|
|
240
|
+
: requireIntegerIn(requestArg.observeMs, 0, captureLimits.maxObserveMs, 'observeMs'),
|
|
241
|
+
maxFrames: requestArg.maxFrames === undefined
|
|
242
|
+
? captureLimits.defaultMaxFrames
|
|
243
|
+
: requireIntegerIn(requestArg.maxFrames, 1, captureLimits.maxMaxFrames, 'maxFrames'),
|
|
244
|
+
frameQuality: requestArg.frameQuality === undefined
|
|
245
|
+
? captureLimits.defaultFrameQuality
|
|
246
|
+
: requireIntegerIn(requestArg.frameQuality, 1, 100, 'frameQuality'),
|
|
247
|
+
finalScreenshot: requestArg.finalScreenshot ? resolveImageOptions(requestArg.finalScreenshot) : null,
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const describeEntry = (sectionNameArg: string, entryNameArg: string) => `${sectionNameArg}/${entryNameArg}`;
|
|
252
|
+
|
|
253
|
+
const listSome = (namesArg: string[]): string => {
|
|
254
|
+
const shown = namesArg.slice(0, 20).join(', ');
|
|
255
|
+
return namesArg.length > 20 ? `${shown}, … (${namesArg.length} in all)` : shown;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolves a subject to one demo of the catalog: an entry named by its name or its element's tag,
|
|
260
|
+
* in the named section or, without one, in exactly one section.
|
|
261
|
+
*/
|
|
262
|
+
export const resolveSelection = (
|
|
263
|
+
manifestArg: plugins.interfaces.IWccCatalogManifest,
|
|
264
|
+
subjectArg: IWccCaptureSubject,
|
|
265
|
+
): plugins.interfaces.IWccSelection => {
|
|
266
|
+
const sections = subjectArg.sectionName === undefined
|
|
267
|
+
? manifestArg.sections
|
|
268
|
+
: manifestArg.sections.filter((sectionArg) => sectionArg.name === subjectArg.sectionName);
|
|
269
|
+
if (sections.length === 0) {
|
|
270
|
+
throw new WccCaptureRequestError(
|
|
271
|
+
`wcctools: the catalog has no section "${subjectArg.sectionName}". Sections: ${listSome(manifestArg.sections.map((sectionArg) => sectionArg.name))}.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const wanted = subjectArg.itemName;
|
|
275
|
+
const matchesName = (entryArg: { name: string; tagName: string | null }) => {
|
|
276
|
+
return entryArg.name === wanted || (entryArg.tagName !== null && entryArg.tagName === wanted.toLowerCase());
|
|
277
|
+
};
|
|
278
|
+
const matches = sections.flatMap((sectionArg) => sectionArg.entries
|
|
279
|
+
.filter(matchesName)
|
|
280
|
+
.map((entryArg) => ({ section: sectionArg, entry: entryArg })));
|
|
281
|
+
if (matches.length === 0) {
|
|
282
|
+
const withoutDemo = sections.flatMap((sectionArg) => sectionArg.entriesWithoutDemo
|
|
283
|
+
.filter(matchesName)
|
|
284
|
+
.map((entryArg) => describeEntry(sectionArg.name, entryArg.name)));
|
|
285
|
+
if (withoutDemo.length > 0) {
|
|
286
|
+
throw new WccCaptureRequestError(`wcctools: ${listSome(withoutDemo)} has no demo to capture.`);
|
|
287
|
+
}
|
|
288
|
+
const known = sections.flatMap((sectionArg) => sectionArg.entries.map((entryArg) => describeEntry(sectionArg.name, entryArg.name)));
|
|
289
|
+
throw new WccCaptureRequestError(`wcctools: the catalog has no entry "${wanted}". Entries: ${listSome(known)}.`);
|
|
290
|
+
}
|
|
291
|
+
if (matches.length > 1) {
|
|
292
|
+
throw new WccCaptureRequestError(
|
|
293
|
+
`wcctools: "${wanted}" names ${matches.length} entries (${listSome(matches.map((matchArg) => describeEntry(matchArg.section.name, matchArg.entry.name)))}); name its section.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const [{ section, entry }] = matches;
|
|
297
|
+
const demoIndex = subjectArg.demoIndex ?? 0;
|
|
298
|
+
if (demoIndex >= entry.demoCount) {
|
|
299
|
+
throw new WccCaptureRequestError(
|
|
300
|
+
`wcctools: ${describeEntry(section.name, entry.name)} has ${entry.demoCount} demo${entry.demoCount === 1 ? '' : 's'}; demo ${demoIndex} does not exist (demos count from 0).`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return { sectionName: section.name, itemName: entry.name, demoIndex };
|
|
304
|
+
};
|
package/ts/classes.devserver.ts
CHANGED
|
@@ -2,6 +2,8 @@ import * as plugins from './plugins.js';
|
|
|
2
2
|
import * as paths from './paths.js';
|
|
3
3
|
import { commitinfo } from './00_commitinfo_data.js';
|
|
4
4
|
import { WccDevApi } from './classes.devapi.js';
|
|
5
|
+
import { WccHostPolicy, defaultListenHost, formatUrlHost } from './classes.hostpolicy.js';
|
|
6
|
+
import { WccCaptureService } from './capture/index.js';
|
|
5
7
|
import { loadShellContent, shellPathPrefix } from './shellcontent.js';
|
|
6
8
|
|
|
7
9
|
export interface IWccDevServerOptions {
|
|
@@ -12,12 +14,23 @@ export interface IWccDevServerOptions {
|
|
|
12
14
|
* tswatch bundles and watches in it (default: `process.cwd()` when the server is constructed).
|
|
13
15
|
*/
|
|
14
16
|
cwd?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The interface address to listen on (default `127.0.0.1`, this machine only). `0.0.0.0` or
|
|
19
|
+
* `::` listen on every interface; the machine's interface addresses are then allowed hosts.
|
|
20
|
+
*/
|
|
21
|
+
host?: string;
|
|
22
|
+
/** Further hostnames or addresses the server answers, besides localhost and the listen address. */
|
|
23
|
+
allowedHosts?: readonly string[];
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export interface IWccDevServerAddress {
|
|
18
27
|
port: number;
|
|
19
28
|
/** The shell's address. */
|
|
20
29
|
url: string;
|
|
30
|
+
/** The interface address the server listens on. */
|
|
31
|
+
host: string;
|
|
32
|
+
/** The hostnames the server answers; requests for any other Host are refused. */
|
|
33
|
+
allowedHosts: string[];
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
/** tswatch's default port and serve directory, used when the configuration names none. */
|
|
@@ -30,6 +43,7 @@ const securityHeaders: plugins.typedserver.ISecurityHeaders = {
|
|
|
30
43
|
crossOriginEmbedderPolicy: 'require-corp',
|
|
31
44
|
};
|
|
32
45
|
|
|
46
|
+
/** Every surface takes every hostname: the server-wide Host admission refuses the ones it does not serve. */
|
|
33
47
|
const acceptAnyHostname = () => true;
|
|
34
48
|
|
|
35
49
|
/**
|
|
@@ -39,7 +53,12 @@ const acceptAnyHostname = () => true;
|
|
|
39
53
|
* - the shell on `/wcctools/` (`/` redirects there) and its routes `/wcctools-route/...`,
|
|
40
54
|
* - the typed API on `/wcctools/typedrequest`, including the bundles' status,
|
|
41
55
|
* - the catalog's preview document and bundle from the tswatch serve directory on every other
|
|
42
|
-
* path, with live reload that reloads only the preview document
|
|
56
|
+
* path, with live reload that reloads only the preview document,
|
|
57
|
+
* - captures of single demos in a headless browser (`capture`, also on the typed API).
|
|
58
|
+
*
|
|
59
|
+
* It listens on loopback unless told otherwise and admits requests as its `WccHostPolicy`
|
|
60
|
+
* describes: only the hosts it serves, and state-changing requests and WebSockets only from its
|
|
61
|
+
* own origin.
|
|
43
62
|
*
|
|
44
63
|
* The server owns no process lifecycle: while it runs, a process shutdown through smartexit's
|
|
45
64
|
* `ProcessLifecycle` stops it. The entry point installs that lifecycle with
|
|
@@ -48,6 +67,10 @@ const acceptAnyHostname = () => true;
|
|
|
48
67
|
export class WccDevServer {
|
|
49
68
|
/** The typed API on `/wcctools/typedrequest`; register further handlers before `start()`. */
|
|
50
69
|
public readonly api = new WccDevApi();
|
|
70
|
+
/** Screenshots and interaction captures of the served catalog; available while the server runs. */
|
|
71
|
+
public readonly capture: WccCaptureService;
|
|
72
|
+
/** Who may talk to the server: the hosts it answers and the origin of its requests. */
|
|
73
|
+
public readonly hostPolicy: WccHostPolicy;
|
|
51
74
|
/** The project directory everything resolves against. */
|
|
52
75
|
public readonly cwd: string;
|
|
53
76
|
/** The project's tswatch configuration, or the `element` preset; read once, on construction. */
|
|
@@ -67,6 +90,9 @@ export class WccDevServer {
|
|
|
67
90
|
const configHandler = new plugins.tswatch.ConfigHandler(this.cwd);
|
|
68
91
|
this.config = configHandler.loadConfig() ?? this.getElementPreset(configHandler);
|
|
69
92
|
this.tsWatch = new plugins.tswatch.TsWatch({ ...this.config, server: { enabled: false } }, { cwd: this.cwd });
|
|
93
|
+
this.hostPolicy = new WccHostPolicy({ listenHost: options.host ?? defaultListenHost, allowedHosts: options.allowedHosts });
|
|
94
|
+
this.capture = new WccCaptureService(this.api.bundleStatus);
|
|
95
|
+
this.capture.addTypedHandlers(this.api.typedrouter);
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
/**
|
|
@@ -134,8 +160,11 @@ export class WccDevServer {
|
|
|
134
160
|
// requests until the server stops listening are refused too: the shell backs off
|
|
135
161
|
this.api.bundleStatus.close();
|
|
136
162
|
try {
|
|
163
|
+
// Captures end and the capture browser closes before the server stops answering its pages
|
|
164
|
+
await this.capture.close();
|
|
137
165
|
await typedServer?.stop();
|
|
138
166
|
} finally {
|
|
167
|
+
this.hostPolicy.setPort(null);
|
|
139
168
|
try {
|
|
140
169
|
await this.tsWatch.stop();
|
|
141
170
|
} finally {
|
|
@@ -156,7 +185,15 @@ export class WccDevServer {
|
|
|
156
185
|
projectName: await this.readProjectName(),
|
|
157
186
|
});
|
|
158
187
|
const surfaces = [await this.createShellSurface(), this.createPreviewSurface(serveDir)];
|
|
159
|
-
const typedServer = new plugins.typedserver.TypedServer({
|
|
188
|
+
const typedServer = new plugins.typedserver.TypedServer({
|
|
189
|
+
cors: false,
|
|
190
|
+
port,
|
|
191
|
+
listenHostname: this.hostPolicy.listenHost,
|
|
192
|
+
// Requests and WebSocket upgrades for a Host the server does not serve are refused
|
|
193
|
+
requestAdmission: this.hostPolicy.admitHost,
|
|
194
|
+
websocketAdmission: this.hostPolicy.admitHost,
|
|
195
|
+
surfaces,
|
|
196
|
+
});
|
|
160
197
|
this.typedServer = typedServer;
|
|
161
198
|
|
|
162
199
|
this.api.bundleStatus.open();
|
|
@@ -166,7 +203,14 @@ export class WccDevServer {
|
|
|
166
203
|
await this.tsWatch.start();
|
|
167
204
|
await typedServer.start();
|
|
168
205
|
const boundPort = typedServer.listeningPort ?? port;
|
|
169
|
-
|
|
206
|
+
this.hostPolicy.setPort(boundPort);
|
|
207
|
+
this.capture.open(`http://${formatUrlHost(this.hostPolicy.getLocalHost())}:${boundPort}`);
|
|
208
|
+
return {
|
|
209
|
+
port: boundPort,
|
|
210
|
+
url: `http://${formatUrlHost(this.hostPolicy.getDisplayHost())}:${boundPort}${shellPathPrefix}/`,
|
|
211
|
+
host: this.hostPolicy.listenHost,
|
|
212
|
+
allowedHosts: [...this.hostPolicy.allowedHostnames],
|
|
213
|
+
};
|
|
170
214
|
}
|
|
171
215
|
|
|
172
216
|
private getElementPreset(configHandlerArg: plugins.tswatch.ConfigHandler): plugins.tswatch.ITswatchConfig {
|
|
@@ -178,14 +222,8 @@ export class WccDevServer {
|
|
|
178
222
|
}
|
|
179
223
|
|
|
180
224
|
/**
|
|
181
|
-
* The shell: the prebuilt shell bundle and the typed API
|
|
182
|
-
*
|
|
183
|
-
* Seam for side-effecting APIs (assistant, capture, standards fixes): before any handler with
|
|
184
|
-
* side effects is registered here, the server binds to localhost by default (another interface
|
|
185
|
-
* only on explicit request), validates the Host header against the addresses it serves instead
|
|
186
|
-
* of accepting any hostname, and admits typedsocket connections and typed requests only from the
|
|
187
|
-
* shell's own origin (a `websocketAdmission`/`requestAdmission`), keeping surface mode's strict
|
|
188
|
-
* authority validation. Until then every handler here is read-only.
|
|
225
|
+
* The shell: the prebuilt shell bundle and the typed API, whose requests (captures among them)
|
|
226
|
+
* are admitted only from the server's own origin.
|
|
189
227
|
*/
|
|
190
228
|
private async createShellSurface(): Promise<plugins.typedserver.ITypedServerSurface> {
|
|
191
229
|
return {
|
|
@@ -198,6 +236,8 @@ export class WccDevServer {
|
|
|
198
236
|
spaFallback: true,
|
|
199
237
|
httpTypedRouter: this.api.typedrouter,
|
|
200
238
|
typedRequestPath: `${shellPathPrefix}/typedrequest`,
|
|
239
|
+
requestAdmission: this.hostPolicy.admitRequestOrigin,
|
|
240
|
+
websocketAdmission: this.hostPolicy.admitWebsocketOrigin,
|
|
201
241
|
noCache: true,
|
|
202
242
|
securityHeaders,
|
|
203
243
|
};
|
|
@@ -221,8 +261,10 @@ export class WccDevServer {
|
|
|
221
261
|
watch: true,
|
|
222
262
|
// Lets the reload client get pushed changes instead of polling
|
|
223
263
|
websocketTypedRouter: new plugins.typedrequest.TypedRouter(),
|
|
224
|
-
|
|
225
|
-
|
|
264
|
+
requestAdmission: this.hostPolicy.admitRequestOrigin,
|
|
265
|
+
websocketAdmission: this.hostPolicy.admitWebsocketOrigin,
|
|
266
|
+
// Shell and preview share one origin, so no other origin may read the catalog
|
|
267
|
+
cors: false,
|
|
226
268
|
httpHandler: async (contextArg) => {
|
|
227
269
|
if (contextArg.path === '/' && (contextArg.method === 'GET' || contextArg.method === 'HEAD')) {
|
|
228
270
|
return new Response(null, { status: 302, headers: { Location: `${shellPathPrefix}/` } });
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
type TRequestContext = plugins.typedserver.IRequestContext;
|
|
4
|
+
type TAdmissionResult = plugins.typedserver.TRequestAdmissionResult;
|
|
5
|
+
|
|
6
|
+
/** The interface address `wcctools dev` listens on unless told otherwise. */
|
|
7
|
+
export const defaultListenHost = '127.0.0.1';
|
|
8
|
+
|
|
9
|
+
/** The hostnames every dev server answers: the loopback names. */
|
|
10
|
+
const loopbackHostnames = ['localhost', '127.0.0.1', '::1'];
|
|
11
|
+
|
|
12
|
+
const wildcardHosts = new Set(['0.0.0.0', '::']);
|
|
13
|
+
|
|
14
|
+
/** Methods that never change state, which the Origin check leaves to the Host check alone. */
|
|
15
|
+
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
16
|
+
|
|
17
|
+
/** Canonical form of a hostname: lower case, IPv6 without brackets, no trailing dot. */
|
|
18
|
+
export const normalizeHostname = (hostnameArg: string): string => {
|
|
19
|
+
let hostname = hostnameArg.trim().toLowerCase();
|
|
20
|
+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
21
|
+
hostname = hostname.slice(1, -1);
|
|
22
|
+
}
|
|
23
|
+
if (hostname.endsWith('.') && hostname.length > 1) {
|
|
24
|
+
hostname = hostname.slice(0, -1);
|
|
25
|
+
}
|
|
26
|
+
return hostname;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Whether the address is the wildcard address of all interfaces. */
|
|
30
|
+
export const isWildcardHost = (hostArg: string): boolean => wildcardHosts.has(normalizeHostname(hostArg));
|
|
31
|
+
|
|
32
|
+
/** Whether a peer address is a loopback address (IPv4, IPv6 or IPv4-mapped IPv6). */
|
|
33
|
+
export const isLoopbackAddress = (addressArg: string): boolean => {
|
|
34
|
+
const address = normalizeHostname(addressArg);
|
|
35
|
+
return address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A hostname as it appears in a URL: IPv6 addresses in brackets. */
|
|
39
|
+
export const formatUrlHost = (hostnameArg: string): string => {
|
|
40
|
+
return hostnameArg.includes(':') ? `[${hostnameArg}]` : hostnameArg;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** An invalid `--host` or `--allowed-host` value. */
|
|
44
|
+
export class WccHostPolicyError extends Error {
|
|
45
|
+
constructor(messageArg: string) {
|
|
46
|
+
super(messageArg);
|
|
47
|
+
this.name = 'WccHostPolicyError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IWccHostPolicyOptions {
|
|
52
|
+
/** The interface address the server listens on. */
|
|
53
|
+
listenHost: string;
|
|
54
|
+
/** Further hostnames or addresses the server answers (`--allowed-host`). */
|
|
55
|
+
allowedHosts?: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Who may talk to a `wcctools dev` server. The server listens on loopback unless told otherwise
|
|
60
|
+
* and answers only the hostnames it serves: the loopback names, the address it listens on, the
|
|
61
|
+
* machine's interface addresses when it listens on all of them, and the hosts allowed explicitly.
|
|
62
|
+
* Every other Host is refused, which defeats DNS rebinding. Requests that can change state
|
|
63
|
+
* (any method but GET, HEAD and OPTIONS) and WebSocket connections are further admitted only from
|
|
64
|
+
* the server's own origin, so other web pages cannot drive the API; a request without an Origin
|
|
65
|
+
* is admitted only from a loopback peer (local tools, such as the CLI and tests).
|
|
66
|
+
*/
|
|
67
|
+
export class WccHostPolicy {
|
|
68
|
+
/** The canonical hostnames the server answers. */
|
|
69
|
+
public readonly allowedHostnames: ReadonlySet<string>;
|
|
70
|
+
public readonly listenHost: string;
|
|
71
|
+
private port: number | null = null;
|
|
72
|
+
|
|
73
|
+
constructor(optionsArg: IWccHostPolicyOptions) {
|
|
74
|
+
this.listenHost = WccHostPolicy.parseHost(optionsArg.listenHost);
|
|
75
|
+
const hostnames = new Set(loopbackHostnames);
|
|
76
|
+
if (isWildcardHost(this.listenHost)) {
|
|
77
|
+
for (const address of WccHostPolicy.getInterfaceAddresses()) {
|
|
78
|
+
hostnames.add(address);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
hostnames.add(this.listenHost);
|
|
82
|
+
}
|
|
83
|
+
for (const host of optionsArg.allowedHosts ?? []) {
|
|
84
|
+
hostnames.add(WccHostPolicy.parseHost(host));
|
|
85
|
+
}
|
|
86
|
+
this.allowedHostnames = hostnames;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A hostname or address, as a URL would carry it; anything else is refused. */
|
|
90
|
+
private static parseHost(hostArg: string): string {
|
|
91
|
+
const hostname = normalizeHostname(hostArg);
|
|
92
|
+
let parsed: string | null = null;
|
|
93
|
+
try {
|
|
94
|
+
parsed = normalizeHostname(new URL(`http://${formatUrlHost(hostname)}/`).hostname);
|
|
95
|
+
} catch {
|
|
96
|
+
parsed = null;
|
|
97
|
+
}
|
|
98
|
+
if (!hostname || parsed !== hostname) {
|
|
99
|
+
throw new WccHostPolicyError(`wcctools: "${hostArg}" is not a hostname or address.`);
|
|
100
|
+
}
|
|
101
|
+
return hostname;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every address of the machine's network interfaces. */
|
|
105
|
+
private static getInterfaceAddresses(): string[] {
|
|
106
|
+
const addresses: string[] = [];
|
|
107
|
+
for (const interfaceAddresses of Object.values(plugins.os.networkInterfaces())) {
|
|
108
|
+
for (const interfaceAddress of interfaceAddresses ?? []) {
|
|
109
|
+
addresses.push(normalizeHostname(interfaceAddress.address));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return addresses;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The port the server listens on, once it does: origins are compared against it. */
|
|
116
|
+
public setPort(portArg: number | null) {
|
|
117
|
+
this.port = portArg;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The host the server's own tools reach it on: the loopback address when it listens on all
|
|
122
|
+
* interfaces or on loopback, otherwise the address it listens on.
|
|
123
|
+
*/
|
|
124
|
+
public getLocalHost(): string {
|
|
125
|
+
if (isWildcardHost(this.listenHost) || isLoopbackAddress(this.listenHost)) {
|
|
126
|
+
return '127.0.0.1';
|
|
127
|
+
}
|
|
128
|
+
return this.listenHost;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The host shown to the user: `localhost` for a loopback or all-interface server. */
|
|
132
|
+
public getDisplayHost(): string {
|
|
133
|
+
if (isWildcardHost(this.listenHost) || isLoopbackAddress(this.listenHost) || this.listenHost === 'localhost') {
|
|
134
|
+
return 'localhost';
|
|
135
|
+
}
|
|
136
|
+
return this.listenHost;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Server-wide HTTP and WebSocket admission: the Host must be one the server answers. */
|
|
140
|
+
public readonly admitHost = (contextArg: TRequestContext): TAdmissionResult => {
|
|
141
|
+
const hostname = normalizeHostname(contextArg.url.hostname);
|
|
142
|
+
if (this.allowedHostnames.has(hostname)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return this.refuse(
|
|
146
|
+
`wcctools: the dev server does not answer the host "${hostname}". Open it on localhost, or restart it with --allowed-host ${hostname}.`,
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Surface HTTP admission: a request that can change state must come from the server's origin. */
|
|
151
|
+
public readonly admitRequestOrigin = (contextArg: TRequestContext): TAdmissionResult => {
|
|
152
|
+
if (safeMethods.has(contextArg.method)) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return this.admitOrigin(contextArg);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Surface WebSocket admission: a connection must come from the server's origin. */
|
|
159
|
+
public readonly admitWebsocketOrigin = (contextArg: TRequestContext): TAdmissionResult => {
|
|
160
|
+
return this.admitOrigin(contextArg);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Whether an Origin header names this server: http, an allowed host and the server's port. */
|
|
164
|
+
public isOwnOrigin(originArg: string): boolean {
|
|
165
|
+
if (this.port === null) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
let origin: URL;
|
|
169
|
+
try {
|
|
170
|
+
origin = new URL(originArg);
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const port = origin.port === '' ? 80 : Number(origin.port);
|
|
175
|
+
return origin.protocol === 'http:'
|
|
176
|
+
&& port === this.port
|
|
177
|
+
&& this.allowedHostnames.has(normalizeHostname(origin.hostname));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private admitOrigin(contextArg: TRequestContext): TAdmissionResult {
|
|
181
|
+
const origin = contextArg.headers.get('origin');
|
|
182
|
+
if (origin === null) {
|
|
183
|
+
const peer = contextArg.connectionInfo?.remoteAddr ?? 'unknown';
|
|
184
|
+
if (isLoopbackAddress(peer)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return this.refuse('wcctools: requests without an Origin are admitted only from this machine.');
|
|
188
|
+
}
|
|
189
|
+
if (this.isOwnOrigin(origin)) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return this.refuse(`wcctools: requests from the origin "${origin}" are not admitted.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private refuse(messageArg: string): Response {
|
|
196
|
+
return new Response(`${messageArg}\n`, {
|
|
197
|
+
status: 403,
|
|
198
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|