@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.
Files changed (67) hide show
  1. package/changelog.md +21 -0
  2. package/dist_shell/bundle.js +4 -4
  3. package/dist_shell/bundle.js.map +1 -1
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/capture/classes.capturebrowser.d.ts +65 -0
  6. package/dist_ts/capture/classes.capturebrowser.js +251 -0
  7. package/dist_ts/capture/classes.captureservice.d.ts +39 -0
  8. package/dist_ts/capture/classes.captureservice.js +119 -0
  9. package/dist_ts/capture/classes.screencast.d.ts +47 -0
  10. package/dist_ts/capture/classes.screencast.js +127 -0
  11. package/dist_ts/capture/errors.d.ts +14 -0
  12. package/dist_ts/capture/errors.js +23 -0
  13. package/dist_ts/capture/images.d.ts +3 -0
  14. package/dist_ts/capture/images.js +58 -0
  15. package/dist_ts/capture/index.d.ts +4 -0
  16. package/dist_ts/capture/index.js +5 -0
  17. package/dist_ts/capture/interaction.d.ts +11 -0
  18. package/dist_ts/capture/interaction.js +138 -0
  19. package/dist_ts/capture/navigationguard.d.ts +44 -0
  20. package/dist_ts/capture/navigationguard.js +129 -0
  21. package/dist_ts/capture/previewpage.d.ts +29 -0
  22. package/dist_ts/capture/previewpage.js +96 -0
  23. package/dist_ts/capture/request.d.ts +69 -0
  24. package/dist_ts/capture/request.js +239 -0
  25. package/dist_ts/classes.devserver.d.ts +25 -9
  26. package/dist_ts/classes.devserver.js +45 -14
  27. package/dist_ts/classes.hostpolicy.d.ts +63 -0
  28. package/dist_ts/classes.hostpolicy.js +171 -0
  29. package/dist_ts/cli.js +25 -3
  30. package/dist_ts/cli.screenshot.d.ts +6 -0
  31. package/dist_ts/cli.screenshot.js +89 -0
  32. package/dist_ts/plugins.d.ts +6 -2
  33. package/dist_ts/plugins.js +8 -3
  34. package/dist_ts_interfaces/capture.d.ts +151 -0
  35. package/dist_ts_interfaces/capture.js +2 -0
  36. package/dist_ts_interfaces/index.d.ts +1 -0
  37. package/dist_ts_interfaces/index.js +2 -1
  38. package/dist_ts_interfaces/requests.d.ts +24 -0
  39. package/dist_ts_shell/elements/wcc-recording-panel.d.ts +9 -0
  40. package/dist_ts_shell/elements/wcc-recording-panel.js +12 -1
  41. package/dist_ts_shell/services/framesampler.service.d.ts +23 -0
  42. package/dist_ts_shell/services/framesampler.service.js +101 -0
  43. package/dist_ts_web/00_commitinfo_data.js +1 -1
  44. package/package.json +2 -1
  45. package/readme.md +104 -6
  46. package/ts/00_commitinfo_data.ts +1 -1
  47. package/ts/capture/classes.capturebrowser.ts +290 -0
  48. package/ts/capture/classes.captureservice.ts +152 -0
  49. package/ts/capture/classes.screencast.ts +152 -0
  50. package/ts/capture/errors.ts +25 -0
  51. package/ts/capture/images.ts +61 -0
  52. package/ts/capture/index.ts +4 -0
  53. package/ts/capture/interaction.ts +146 -0
  54. package/ts/capture/navigationguard.ts +147 -0
  55. package/ts/capture/previewpage.ts +130 -0
  56. package/ts/capture/request.ts +304 -0
  57. package/ts/classes.devserver.ts +55 -13
  58. package/ts/classes.hostpolicy.ts +201 -0
  59. package/ts/cli.screenshot.ts +95 -0
  60. package/ts/cli.ts +25 -2
  61. package/ts/plugins.ts +9 -2
  62. package/ts_interfaces/capture.ts +144 -0
  63. package/ts_interfaces/index.ts +1 -0
  64. package/ts_interfaces/requests.ts +39 -0
  65. package/ts_shell/elements/wcc-recording-panel.ts +12 -0
  66. package/ts_shell/services/framesampler.service.ts +112 -0
  67. 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
+ };
@@ -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({ cors: false, port, surfaces });
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
- return { port: boundPort, url: `http://localhost:${boundPort}${shellPathPrefix}/` };
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
- // As tswatch serves catalogs
225
- cors: true,
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
+ }