@design.estate/wcctools 6.1.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 (112) hide show
  1. package/changelog.md +41 -0
  2. package/dist_shell/bundle.js +405 -368
  3. package/dist_shell/bundle.js.map +1 -1
  4. package/dist_shell/bundle.js.third-party-notices.json +52 -0
  5. package/dist_shell/bundle.js.third-party-notices.txt +107 -1
  6. package/dist_shell/index.html +1 -1
  7. package/dist_ts/00_commitinfo_data.js +1 -1
  8. package/dist_ts/capture/classes.capturebrowser.d.ts +65 -0
  9. package/dist_ts/capture/classes.capturebrowser.js +251 -0
  10. package/dist_ts/capture/classes.captureservice.d.ts +39 -0
  11. package/dist_ts/capture/classes.captureservice.js +119 -0
  12. package/dist_ts/capture/classes.screencast.d.ts +47 -0
  13. package/dist_ts/capture/classes.screencast.js +127 -0
  14. package/dist_ts/capture/errors.d.ts +14 -0
  15. package/dist_ts/capture/errors.js +23 -0
  16. package/dist_ts/capture/images.d.ts +3 -0
  17. package/dist_ts/capture/images.js +58 -0
  18. package/dist_ts/capture/index.d.ts +4 -0
  19. package/dist_ts/capture/index.js +5 -0
  20. package/dist_ts/capture/interaction.d.ts +11 -0
  21. package/dist_ts/capture/interaction.js +138 -0
  22. package/dist_ts/capture/navigationguard.d.ts +44 -0
  23. package/dist_ts/capture/navigationguard.js +129 -0
  24. package/dist_ts/capture/previewpage.d.ts +29 -0
  25. package/dist_ts/capture/previewpage.js +96 -0
  26. package/dist_ts/capture/request.d.ts +69 -0
  27. package/dist_ts/capture/request.js +239 -0
  28. package/dist_ts/classes.bundlestatus.d.ts +36 -0
  29. package/dist_ts/classes.bundlestatus.js +110 -0
  30. package/dist_ts/classes.devapi.d.ts +3 -0
  31. package/dist_ts/classes.devapi.js +8 -1
  32. package/dist_ts/classes.devserver.d.ts +65 -14
  33. package/dist_ts/classes.devserver.js +157 -52
  34. package/dist_ts/classes.hostpolicy.d.ts +63 -0
  35. package/dist_ts/classes.hostpolicy.js +171 -0
  36. package/dist_ts/cli.js +28 -3
  37. package/dist_ts/cli.screenshot.d.ts +6 -0
  38. package/dist_ts/cli.screenshot.js +89 -0
  39. package/dist_ts/index.d.ts +0 -2
  40. package/dist_ts/index.js +1 -3
  41. package/dist_ts/plugins.d.ts +6 -2
  42. package/dist_ts/plugins.js +8 -3
  43. package/dist_ts_interfaces/capture.d.ts +151 -0
  44. package/dist_ts_interfaces/capture.js +2 -0
  45. package/dist_ts_interfaces/index.d.ts +1 -0
  46. package/dist_ts_interfaces/index.js +2 -1
  47. package/dist_ts_interfaces/requests.d.ts +59 -0
  48. package/dist_ts_shell/bundlestatus.d.ts +15 -0
  49. package/dist_ts_shell/bundlestatus.js +64 -0
  50. package/dist_ts_shell/elements/wcc-contextmenu.d.ts +3 -0
  51. package/dist_ts_shell/elements/wcc-contextmenu.js +18 -3
  52. package/dist_ts_shell/elements/wcc-preview-frame.d.ts +6 -0
  53. package/dist_ts_shell/elements/wcc-preview-frame.js +50 -2
  54. package/dist_ts_shell/elements/wcc-recording-panel.d.ts +9 -0
  55. package/dist_ts_shell/elements/wcc-recording-panel.js +12 -1
  56. package/dist_ts_shell/elements/wcc-shell.d.ts +15 -0
  57. package/dist_ts_shell/elements/wcc-shell.js +45 -3
  58. package/dist_ts_shell/plugins.d.ts +2 -1
  59. package/dist_ts_shell/plugins.js +3 -2
  60. package/dist_ts_shell/services/framesampler.service.d.ts +23 -0
  61. package/dist_ts_shell/services/framesampler.service.js +101 -0
  62. package/dist_ts_web/00_commitinfo_data.js +1 -1
  63. package/package.json +6 -2
  64. package/readme.md +108 -6
  65. package/ts/00_commitinfo_data.ts +1 -1
  66. package/ts/capture/classes.capturebrowser.ts +290 -0
  67. package/ts/capture/classes.captureservice.ts +152 -0
  68. package/ts/capture/classes.screencast.ts +152 -0
  69. package/ts/capture/errors.ts +25 -0
  70. package/ts/capture/images.ts +61 -0
  71. package/ts/capture/index.ts +4 -0
  72. package/ts/capture/interaction.ts +146 -0
  73. package/ts/capture/navigationguard.ts +147 -0
  74. package/ts/capture/previewpage.ts +130 -0
  75. package/ts/capture/request.ts +304 -0
  76. package/ts/classes.bundlestatus.ts +123 -0
  77. package/ts/classes.devapi.ts +9 -0
  78. package/ts/classes.devserver.ts +172 -50
  79. package/ts/classes.hostpolicy.ts +201 -0
  80. package/ts/cli.screenshot.ts +95 -0
  81. package/ts/cli.ts +28 -2
  82. package/ts/index.ts +0 -2
  83. package/ts/plugins.ts +9 -2
  84. package/ts_interfaces/bridge.ts +95 -0
  85. package/ts_interfaces/capture.ts +144 -0
  86. package/ts_interfaces/catalog.ts +37 -0
  87. package/ts_interfaces/index.ts +5 -0
  88. package/ts_interfaces/plugins.ts +3 -0
  89. package/ts_interfaces/requests.ts +104 -0
  90. package/ts_interfaces/standard.ts +103 -0
  91. package/ts_shared/index.ts +1 -0
  92. package/ts_shared/plugins.ts +3 -0
  93. package/ts_shared/previewroute.ts +58 -0
  94. package/ts_shell/bundlestatus.ts +68 -0
  95. package/ts_shell/elements/wcc-contextmenu.ts +306 -0
  96. package/ts_shell/elements/wcc-preview-frame.ts +167 -0
  97. package/ts_shell/elements/wcc-properties.ts +998 -0
  98. package/ts_shell/elements/wcc-record-button.ts +108 -0
  99. package/ts_shell/elements/wcc-recording-panel.ts +1017 -0
  100. package/ts_shell/elements/wcc-shell.ts +485 -0
  101. package/ts_shell/elements/wcc-sidebar.ts +1419 -0
  102. package/ts_shell/index.html +21 -0
  103. package/ts_shell/index.ts +4 -0
  104. package/ts_shell/plugins.ts +12 -0
  105. package/ts_shell/previewconnection.ts +103 -0
  106. package/ts_shell/services/framesampler.service.ts +112 -0
  107. package/ts_shell/services/recorder.service.ts +451 -0
  108. package/ts_shell/types/dom-mediacapture-stub/index.d.ts +12 -0
  109. package/ts_shell/types/dom-mediacapture-stub/package.json +6 -0
  110. package/ts_shell/types/dom-webcodecs-stub/index.d.ts +2 -0
  111. package/ts_shell/types/dom-webcodecs-stub/package.json +6 -0
  112. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -2,17 +2,35 @@ 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 {
8
10
  /** Port to listen on; overrides the tswatch configuration. 0 picks a free port. */
9
11
  port?: number;
12
+ /**
13
+ * The project directory: its tswatch configuration, package.json and catalog are served, and
14
+ * tswatch bundles and watches in it (default: `process.cwd()` when the server is constructed).
15
+ */
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[];
10
24
  }
11
25
 
12
26
  export interface IWccDevServerAddress {
13
27
  port: number;
14
28
  /** The shell's address. */
15
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[];
16
34
  }
17
35
 
18
36
  /** tswatch's default port and serve directory, used when the configuration names none. */
@@ -25,87 +43,187 @@ const securityHeaders: plugins.typedserver.ISecurityHeaders = {
25
43
  crossOriginEmbedderPolicy: 'require-corp',
26
44
  };
27
45
 
46
+ /** Every surface takes every hostname: the server-wide Host admission refuses the ones it does not serve. */
28
47
  const acceptAnyHostname = () => true;
29
48
 
30
49
  /**
31
- * `wcctools dev`: bundles the catalog of the current project with tswatch, as its
32
- * `@git.zone/tswatch` configuration (or the `element` preset) describes, and serves on one port:
50
+ * `wcctools dev`: bundles the catalog of a project with tswatch, as its `@git.zone/tswatch`
51
+ * configuration (or the `element` preset) describes, and serves on one port:
33
52
  *
34
53
  * - the shell on `/wcctools/` (`/` redirects there) and its routes `/wcctools-route/...`,
35
- * - the typed API on `/wcctools/typedrequest`,
54
+ * - the typed API on `/wcctools/typedrequest`, including the bundles' status,
36
55
  * - the catalog's preview document and bundle from the tswatch serve directory on every other
37
- * 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.
38
62
  *
39
- * tswatch resolves its paths against the process's working directory, so the server serves the
40
- * project the process runs in.
63
+ * The server owns no process lifecycle: while it runs, a process shutdown through smartexit's
64
+ * `ProcessLifecycle` stops it. The entry point installs that lifecycle with
65
+ * `getShutdownTimeoutMs()`.
41
66
  */
42
67
  export class WccDevServer {
43
68
  /** The typed API on `/wcctools/typedrequest`; register further handlers before `start()`. */
44
69
  public readonly api = new WccDevApi();
45
- private tsWatch: plugins.tswatch.TsWatch | null = null;
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;
74
+ /** The project directory everything resolves against. */
75
+ public readonly cwd: string;
76
+ /** The project's tswatch configuration, or the `element` preset; read once, on construction. */
77
+ private readonly config: plugins.tswatch.ITswatchConfig;
78
+ /** Bundling and watching stay tswatch's; its own server stays off. */
79
+ private readonly tsWatch: plugins.tswatch.TsWatch;
46
80
  private typedServer: plugins.typedserver.TypedServer | null = null;
47
- private readonly smartExit = new plugins.smartexit.SmartExit({ silent: true });
81
+ /** Stops the server on a process shutdown; registered by start(), released by stop(). */
82
+ private smartExit: plugins.smartexit.SmartExit | null = null;
83
+ private unsubscribeBundleEvents: (() => void) | null = null;
84
+ private running = false;
85
+ private starting: Promise<IWccDevServerAddress> | null = null;
86
+ private stopping: Promise<void> | null = null;
48
87
 
49
88
  constructor(private readonly options: IWccDevServerOptions = {}) {
50
- this.smartExit.addCleanupFunction(() => this.stop());
89
+ this.cwd = plugins.path.resolve(options.cwd ?? process.cwd());
90
+ const configHandler = new plugins.tswatch.ConfigHandler(this.cwd);
91
+ this.config = configHandler.loadConfig() ?? this.getElementPreset(configHandler);
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);
51
96
  }
52
97
 
98
+ /**
99
+ * The shutdown deadline the server needs, tswatch's: the entry point installs smartexit's
100
+ * `ProcessLifecycle` with it, so every watcher command keeps its full grace period.
101
+ */
102
+ public getShutdownTimeoutMs(): number {
103
+ return this.tsWatch.getShutdownTimeoutMs();
104
+ }
105
+
106
+ /**
107
+ * Bundles the catalog, starts the watchers and serves. Refused while the server runs or stops;
108
+ * the server starts again after stop().
109
+ */
53
110
  public async start(): Promise<IWccDevServerAddress> {
54
- if (this.typedServer) {
55
- throw new Error('wcctools: the dev server is already started.');
111
+ if (this.running || this.stopping) {
112
+ throw new Error('wcctools: the dev server is already started or still stopping.');
113
+ }
114
+ this.running = true;
115
+ // A process shutdown stops the server from here on, also while it is still starting
116
+ const smartExit = new plugins.smartexit.SmartExit({ silent: true });
117
+ smartExit.addCleanupFunction(() => this.stop());
118
+ this.smartExit = smartExit;
119
+ const starting = this.startServing();
120
+ this.starting = starting;
121
+ try {
122
+ const address = await starting;
123
+ if (this.stopping) {
124
+ throw new Error('wcctools: the dev server was stopped while starting.');
125
+ }
126
+ return address;
127
+ } catch (error) {
128
+ // A start that failed halfway releases what it opened
129
+ await this.stop();
130
+ throw error;
131
+ } finally {
132
+ if (this.starting === starting) {
133
+ this.starting = null;
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Stops the server and tswatch's bundling and watching, and releases everything start()
140
+ * registered. A stop during start() waits for the start to settle first; concurrent and
141
+ * repeated calls share one stop.
142
+ */
143
+ public stop(): Promise<void> {
144
+ this.stopping ??= this.release().finally(() => {
145
+ this.stopping = null;
146
+ });
147
+ return this.stopping;
148
+ }
149
+
150
+ private async release(): Promise<void> {
151
+ await this.starting?.catch(() => undefined);
152
+ const smartExit = this.smartExit;
153
+ const typedServer = this.typedServer;
154
+ const unsubscribeBundleEvents = this.unsubscribeBundleEvents;
155
+ this.smartExit = null;
156
+ this.typedServer = null;
157
+ this.unsubscribeBundleEvents = null;
158
+ smartExit?.deregister();
159
+ // Status requests waiting for a change are refused now, so none holds the server's stop, and
160
+ // requests until the server stops listening are refused too: the shell backs off
161
+ this.api.bundleStatus.close();
162
+ try {
163
+ // Captures end and the capture browser closes before the server stops answering its pages
164
+ await this.capture.close();
165
+ await typedServer?.stop();
166
+ } finally {
167
+ this.hostPolicy.setPort(null);
168
+ try {
169
+ await this.tsWatch.stop();
170
+ } finally {
171
+ unsubscribeBundleEvents?.();
172
+ this.running = false;
173
+ }
56
174
  }
57
- const cwd = process.cwd();
58
- const configHandler = new plugins.tswatch.ConfigHandler(cwd);
59
- const config = configHandler.loadConfig() ?? configHandler.getPreset('element');
60
- const serveDir = plugins.path.resolve(cwd, config.server?.serveDir ?? defaultServeDir);
61
- const port = this.options.port ?? config.server?.port ?? defaultPort;
175
+ }
176
+
177
+ private async startServing(): Promise<IWccDevServerAddress> {
178
+ const serveDir = plugins.path.resolve(this.cwd, this.config.server?.serveDir ?? defaultServeDir);
179
+ const port = this.options.port ?? this.config.server?.port ?? defaultPort;
62
180
 
63
181
  // The preview surface watches the serve directory, which must exist before the first bundle
64
182
  await plugins.fs.mkdir(serveDir, { recursive: true });
65
183
  this.api.setServerInfo({
66
184
  wcctoolsVersion: commitinfo.version,
67
- projectName: await this.readProjectName(cwd),
185
+ projectName: await this.readProjectName(),
68
186
  });
187
+ const surfaces = [await this.createShellSurface(), this.createPreviewSurface(serveDir)];
69
188
  const typedServer = new plugins.typedserver.TypedServer({
70
189
  cors: false,
71
190
  port,
72
- surfaces: [
73
- await this.createShellSurface(),
74
- this.createPreviewSurface(serveDir),
75
- ],
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,
76
196
  });
77
197
  this.typedServer = typedServer;
78
198
 
79
- // Bundling and watching stay tswatch's; its own server stays off
80
- this.tsWatch = new plugins.tswatch.TsWatch({ ...config, server: { enabled: false } });
81
- try {
82
- await this.tsWatch.start();
83
- await typedServer.start();
84
- } catch (error) {
85
- await this.stop();
86
- throw error;
87
- }
199
+ this.api.bundleStatus.open();
200
+ const bundleStatus = this.api.bundleStatus;
201
+ this.unsubscribeBundleEvents = this.tsWatch.onBundleEvent((eventArg) => bundleStatus.record(eventArg));
202
+ // The initial bundles run here; a failed one is reported in the bundle status
203
+ await this.tsWatch.start();
204
+ await typedServer.start();
88
205
  const boundPort = typedServer.listeningPort ?? port;
89
- 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
+ };
90
214
  }
91
215
 
92
- /** Stops the server, then tswatch's bundling and watching. */
93
- public async stop(): Promise<void> {
94
- const typedServer = this.typedServer;
95
- const tsWatch = this.tsWatch;
96
- this.typedServer = null;
97
- this.tsWatch = null;
98
- this.smartExit.deregister();
99
- await typedServer?.stop();
100
- await tsWatch?.stop();
216
+ private getElementPreset(configHandlerArg: plugins.tswatch.ConfigHandler): plugins.tswatch.ITswatchConfig {
217
+ const preset = configHandlerArg.getPreset('element');
218
+ if (!preset) {
219
+ throw new Error('wcctools: tswatch provides no `element` preset.');
220
+ }
221
+ return preset;
101
222
  }
102
223
 
103
224
  /**
104
- * The shell: the prebuilt shell bundle and the typed API.
105
- *
106
- * Seam for side-effecting APIs (assistant, capture, standards fixes): before any handler with
107
- * side effects is registered here, add a `websocketAdmission`/`requestAdmission` that accepts
108
- * only the shell's own origin, and keep surface mode's strict authority validation.
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.
109
227
  */
110
228
  private async createShellSurface(): Promise<plugins.typedserver.ITypedServerSurface> {
111
229
  return {
@@ -118,6 +236,8 @@ export class WccDevServer {
118
236
  spaFallback: true,
119
237
  httpTypedRouter: this.api.typedrouter,
120
238
  typedRequestPath: `${shellPathPrefix}/typedrequest`,
239
+ requestAdmission: this.hostPolicy.admitRequestOrigin,
240
+ websocketAdmission: this.hostPolicy.admitWebsocketOrigin,
121
241
  noCache: true,
122
242
  securityHeaders,
123
243
  };
@@ -141,8 +261,10 @@ export class WccDevServer {
141
261
  watch: true,
142
262
  // Lets the reload client get pushed changes instead of polling
143
263
  websocketTypedRouter: new plugins.typedrequest.TypedRouter(),
144
- // As tswatch serves catalogs
145
- 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,
146
268
  httpHandler: async (contextArg) => {
147
269
  if (contextArg.path === '/' && (contextArg.method === 'GET' || contextArg.method === 'HEAD')) {
148
270
  return new Response(null, { status: 302, headers: { Location: `${shellPathPrefix}/` } });
@@ -154,8 +276,8 @@ export class WccDevServer {
154
276
  };
155
277
  }
156
278
 
157
- private async readProjectName(cwdArg: string): Promise<string> {
158
- const packageJson = JSON.parse(await plugins.fs.readFile(plugins.path.join(cwdArg, 'package.json'), 'utf8')) as { name?: string };
159
- return packageJson.name ?? plugins.path.basename(cwdArg);
279
+ private async readProjectName(): Promise<string> {
280
+ const packageJson = JSON.parse(await plugins.fs.readFile(plugins.path.join(this.cwd, 'package.json'), 'utf8')) as { name?: string };
281
+ return packageJson.name ?? plugins.path.basename(this.cwd);
160
282
  }
161
283
  }
@@ -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
+ }
@@ -0,0 +1,95 @@
1
+ import * as plugins from './plugins.js';
2
+ import { WccDevServer } from './classes.devserver.js';
3
+ import { WccCaptureRequestError, captureViewportWidths, resolveScreenshotRequest } from './capture/index.js';
4
+
5
+ /** Reads `<item>` or `<section>/<item>`. */
6
+ const parseSubject = (argsArg: readonly string[], demoArg: number | undefined): plugins.interfaces.IWccCaptureSubject => {
7
+ if (argsArg.length !== 1 || argsArg[0].trim() === '') {
8
+ throw new WccCaptureRequestError('wcctools screenshot: name one catalog entry, as <item> or <section>/<item>.');
9
+ }
10
+ const [subject] = argsArg;
11
+ const slash = subject.indexOf('/');
12
+ const named: plugins.interfaces.IWccCaptureSubject = slash === -1
13
+ ? { itemName: subject }
14
+ : { sectionName: subject.slice(0, slash), itemName: subject.slice(slash + 1) };
15
+ return demoArg === undefined ? named : { ...named, demoIndex: demoArg };
16
+ };
17
+
18
+ /** Reads a named viewport or a width in pixels. */
19
+ const parseViewport = (valueArg: string | undefined): plugins.interfaces.TWccCaptureViewport | undefined => {
20
+ if (valueArg === undefined) {
21
+ return undefined;
22
+ }
23
+ if (Object.hasOwn(captureViewportWidths, valueArg)) {
24
+ return valueArg as plugins.interfaces.TWccCaptureViewportPreset;
25
+ }
26
+ if (/^\d+$/.test(valueArg)) {
27
+ return Number(valueArg);
28
+ }
29
+ throw new WccCaptureRequestError(
30
+ `wcctools screenshot: --viewport must be one of ${Object.keys(captureViewportWidths).join(', ')} or a width in pixels.`,
31
+ );
32
+ };
33
+
34
+ /** The image format the output file's extension names. */
35
+ const formatForFile = (fileArg: string): 'png' | 'jpeg' => {
36
+ const extension = plugins.path.extname(fileArg).toLowerCase();
37
+ if (extension === '.png') {
38
+ return 'png';
39
+ }
40
+ if (extension === '.jpg' || extension === '.jpeg') {
41
+ return 'jpeg';
42
+ }
43
+ throw new WccCaptureRequestError('wcctools screenshot: --out must name a .png, .jpg or .jpeg file.');
44
+ };
45
+
46
+ /**
47
+ * `wcctools screenshot`: bundles the catalog of the working directory, serves it on a free
48
+ * loopback port, screenshots one demo in a headless browser, writes the image and stops.
49
+ */
50
+ export const registerScreenshotCommand = (out: plugins.smartconsole.SmartConsole): void => {
51
+ out.cli.command({
52
+ name: 'screenshot',
53
+ description: 'Screenshot one demo of the catalog in a headless browser: wcctools screenshot <item | section/item> --out <file.png>',
54
+ options: {
55
+ out: { type: 'string', required: true, description: 'The image file to write; .png, .jpg or .jpeg' },
56
+ demo: { type: 'number', description: 'The demo to render, counted from 0 as in the shell URL (default 0)' },
57
+ viewport: { type: 'string', description: `The width: ${Object.entries(captureViewportWidths).map(([nameArg, widthArg]) => `${nameArg} (${widthArg})`).join(', ')} or pixels (default desktop)` },
58
+ height: { type: 'number', description: 'The height of the preview window in pixels (default 800)' },
59
+ theme: { type: 'string', description: 'dark or bright (default dark)' },
60
+ framing: { type: 'string', description: 'element, viewport or fullpage (default element; fullpage for pages)' },
61
+ scale: { type: 'number', description: 'Device pixel ratio, 1 or 2 (default 1)' },
62
+ quality: { type: 'number', description: 'JPEG quality from 1 to 100 (default 80)' },
63
+ },
64
+ }, async ({ args, options }) => {
65
+ const format = formatForFile(options.out);
66
+ const request: plugins.interfaces.IWccScreenshotRequest = {
67
+ subject: parseSubject(args, options.demo),
68
+ viewport: parseViewport(options.viewport),
69
+ height: options.height,
70
+ theme: options.theme as plugins.interfaces.TWccTheme | undefined,
71
+ framing: options.framing as plugins.interfaces.TWccCaptureFraming | undefined,
72
+ scale: options.scale as 1 | 2 | undefined,
73
+ format,
74
+ quality: options.quality,
75
+ };
76
+ // Invalid options are refused before anything is bundled
77
+ resolveScreenshotRequest(request);
78
+ const outFile = plugins.path.resolve(options.out);
79
+ const devServer = new WccDevServer({ port: 0, cwd: process.cwd() });
80
+ // As for `wcctools dev`: SIGINT and SIGTERM stop the server and the capture browser once
81
+ plugins.smartexit.ProcessLifecycle.install({ shutdownTimeoutMs: devServer.getShutdownTimeoutMs() });
82
+ let result: plugins.interfaces.IWccScreenshotResult;
83
+ try {
84
+ await devServer.start();
85
+ result = await devServer.capture.captureScreenshot(request);
86
+ } finally {
87
+ await devServer.stop();
88
+ }
89
+ await plugins.fs.writeFile(outFile, Buffer.from(result.image.dataBase64, 'base64'));
90
+ const { selection, image } = result;
91
+ await out.log(
92
+ `wcctools screenshot: wrote ${outFile} (${selection.sectionName}/${selection.itemName} demo ${selection.demoIndex}, ${result.framing}, ${image.width}×${image.height}${result.truncated ? ', cut at the maximum height' : ''})`,
93
+ );
94
+ });
95
+ };
package/ts/cli.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { commitinfo } from './00_commitinfo_data.js';
3
3
  import { WccDevServer } from './classes.devserver.js';
4
+ import { WccHostPolicyError } from './classes.hostpolicy.js';
5
+ import { WccCaptureRequestError } from './capture/index.js';
6
+ import { registerScreenshotCommand } from './cli.screenshot.js';
4
7
  import { StandardConfigError, StandardUsageError, registerStandardCommands } from './standards/index.js';
5
8
 
6
9
  /**
@@ -21,13 +24,34 @@ export const registerCommands = (out: plugins.smartconsole.SmartConsole): void =
21
24
  type: 'number',
22
25
  description: 'Port to listen on (0 picks a free port); overrides the tswatch configuration',
23
26
  },
27
+ host: {
28
+ type: 'string',
29
+ description: 'Interface address to listen on (default 127.0.0.1, this machine only; 0.0.0.0 for every interface)',
30
+ },
31
+ allowedHost: {
32
+ type: 'strings',
33
+ description: 'A further hostname or address the server answers, besides localhost and the listen address (repeatable)',
34
+ },
24
35
  },
25
36
  }, async ({ options }) => {
26
- const devServer = new WccDevServer({ port: options.port });
37
+ const devServer = new WccDevServer({
38
+ port: options.port,
39
+ cwd: process.cwd(),
40
+ host: options.host,
41
+ allowedHosts: options.allowedHost,
42
+ });
43
+ // The entry point owns process signals: SIGINT and SIGTERM run every registered cleanup, the
44
+ // dev server's stop among them, once within tswatch's shutdown deadline, then exit.
45
+ plugins.smartexit.ProcessLifecycle.install({ shutdownTimeoutMs: devServer.getShutdownTimeoutMs() });
27
46
  const address = await devServer.start();
28
47
  await out.log(`wcctools dev: ${address.url}`);
48
+ if (address.host !== '127.0.0.1' && address.host !== '::1' && address.host !== 'localhost') {
49
+ await out.log(`wcctools dev: listening on ${address.host}, answering the hosts ${address.allowedHosts.join(', ')}`);
50
+ }
29
51
  });
30
52
 
53
+ registerScreenshotCommand(out);
54
+
31
55
  registerStandardCommands(out);
32
56
 
33
57
  out.cli.default({}, async () => {
@@ -45,7 +69,9 @@ export const runCli = async () => {
45
69
  // Configuration and usage errors exit with 2, every other failure with 1.
46
70
  const usageError = error instanceof plugins.smartconsole.CliUsageError
47
71
  || error instanceof StandardConfigError
48
- || error instanceof StandardUsageError;
72
+ || error instanceof StandardUsageError
73
+ || error instanceof WccCaptureRequestError
74
+ || error instanceof WccHostPolicyError;
49
75
  process.exitCode = usageError ? 2 : 1;
50
76
  } finally {
51
77
  await out.dispose();
package/ts/index.ts CHANGED
@@ -1,3 +1 @@
1
1
  export { runCli } from './cli.js';
2
- export { WccDevServer, type IWccDevServerOptions, type IWccDevServerAddress } from './classes.devserver.js';
3
- export { WccDevApi } from './classes.devapi.js';