@webstir-io/webstir 0.1.0 → 0.1.2

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 (77) hide show
  1. package/README.md +13 -0
  2. package/assets/deployment/docker/.dockerignore +7 -0
  3. package/assets/deployment/docker/Dockerfile +17 -0
  4. package/assets/deployment/docker/README.md +44 -0
  5. package/assets/deployment/docker/example.env +3 -0
  6. package/assets/features/client_nav/client_nav.ts +369 -264
  7. package/assets/features/client_nav/document_navigation.ts +344 -0
  8. package/assets/features/client_nav/form_enhancement.ts +275 -0
  9. package/assets/templates/api/src/backend/index.ts +71 -10
  10. package/assets/templates/api/src/backend/tsconfig.json +6 -1
  11. package/assets/templates/full/src/backend/index.ts +71 -10
  12. package/assets/templates/full/src/backend/module.ts +515 -0
  13. package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
  14. package/assets/templates/full/src/backend/tsconfig.json +6 -1
  15. package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
  16. package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
  17. package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
  18. package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
  19. package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
  20. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
  21. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
  22. package/package.json +31 -13
  23. package/scripts/check-feature-projections.mjs +87 -0
  24. package/scripts/check-full-demo-sync.mjs +89 -0
  25. package/scripts/check-package-install.mjs +537 -0
  26. package/scripts/check-standalone-install.mjs +221 -0
  27. package/scripts/pack-standalone.mjs +52 -28
  28. package/scripts/publish.sh +9 -0
  29. package/scripts/run-tests.mjs +99 -0
  30. package/scripts/sync-assets.mjs +175 -17
  31. package/src/add-backend-compat.ts +628 -0
  32. package/src/add-backend.ts +155 -27
  33. package/src/add.ts +111 -4
  34. package/src/agent.ts +393 -0
  35. package/src/api-watch.ts +7 -4
  36. package/src/backend-inspect.ts +70 -2
  37. package/src/backend-runtime.ts +22 -14
  38. package/src/build.ts +1 -3
  39. package/src/bun-generated-frontend-watch.ts +209 -0
  40. package/src/bun-globals.d.ts +23 -0
  41. package/src/bun-spa-document.ts +310 -0
  42. package/src/bun-spa-routes.ts +159 -0
  43. package/src/bun-spa-watch.ts +29 -0
  44. package/src/bun-ssg-watch.ts +304 -0
  45. package/src/cli.ts +381 -50
  46. package/src/compile-tests.ts +37 -29
  47. package/src/dev-server.ts +214 -143
  48. package/src/doctor.ts +164 -0
  49. package/src/enable-assets.ts +18 -1
  50. package/src/enable.ts +133 -41
  51. package/src/execute.ts +28 -4
  52. package/src/external-workspace.ts +178 -0
  53. package/src/format.ts +296 -17
  54. package/src/frontend-inspect.ts +32 -0
  55. package/src/frontend-watch.ts +27 -102
  56. package/src/full-watch.ts +13 -18
  57. package/src/index.ts +7 -0
  58. package/src/init-assets.ts +41 -11
  59. package/src/init.ts +85 -71
  60. package/src/inspect.ts +112 -0
  61. package/src/mcp/run-cli-json.ts +46 -0
  62. package/src/mcp/server.ts +307 -0
  63. package/src/operations.ts +176 -0
  64. package/src/providers.ts +20 -18
  65. package/src/refresh.ts +29 -3
  66. package/src/repair.ts +110 -43
  67. package/src/runtime-filter.ts +41 -0
  68. package/src/runtime.ts +1 -1
  69. package/src/smoke.ts +48 -16
  70. package/src/test.ts +54 -16
  71. package/src/testing-runtime.ts +273 -0
  72. package/src/types.ts +1 -4
  73. package/src/watch-events.ts +46 -17
  74. package/src/watch.ts +5 -1
  75. package/src/workspace-watcher.ts +10 -6
  76. package/src/workspace.ts +4 -2
  77. package/src/watch-daemon-client.ts +0 -171
@@ -6,41 +6,46 @@ import ts from 'typescript';
6
6
  import type { TestModule } from '@webstir-io/webstir-testing';
7
7
 
8
8
  const TESTING_PACKAGE_SPECIFIER = '@webstir-io/webstir-testing';
9
- const TESTING_RUNTIME_SPECIFIER = import.meta.resolve(TESTING_PACKAGE_SPECIFIER);
9
+ const TESTING_RUNTIME_SPECIFIER = import.meta.resolve('./testing-runtime.ts');
10
10
 
11
- export async function compileTestModules(workspaceRoot: string, modules: readonly TestModule[]): Promise<void> {
11
+ export async function compileTestModules(
12
+ workspaceRoot: string,
13
+ modules: readonly TestModule[],
14
+ ): Promise<void> {
12
15
  const shimPath = await ensureTestingRuntimeShim(workspaceRoot);
13
16
 
14
- await Promise.all(modules.map(async (module) => {
15
- if (!module.compiledPath) {
16
- return;
17
- }
17
+ await Promise.all(
18
+ modules.map(async (module) => {
19
+ if (!module.compiledPath) {
20
+ return;
21
+ }
18
22
 
19
- await mkdir(path.dirname(module.compiledPath), { recursive: true });
23
+ await mkdir(path.dirname(module.compiledPath), { recursive: true });
24
+
25
+ if (module.sourcePath.endsWith('.js')) {
26
+ const source = await readFile(module.sourcePath, 'utf8');
27
+ const rewritten = rewriteTestingImports(source, module.compiledPath, shimPath);
28
+ await writeFile(module.compiledPath, rewritten, 'utf8');
29
+ return;
30
+ }
20
31
 
21
- if (module.sourcePath.endsWith('.js')) {
22
32
  const source = await readFile(module.sourcePath, 'utf8');
23
- const rewritten = rewriteTestingImports(source, module.compiledPath, shimPath);
33
+ const output = ts.transpileModule(source, {
34
+ fileName: module.sourcePath,
35
+ compilerOptions: {
36
+ target: ts.ScriptTarget.ES2022,
37
+ module: ts.ModuleKind.ESNext,
38
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
39
+ verbatimModuleSyntax: true,
40
+ rewriteRelativeImportExtensions: true,
41
+ },
42
+ reportDiagnostics: false,
43
+ });
44
+
45
+ const rewritten = rewriteTestingImports(output.outputText, module.compiledPath, shimPath);
24
46
  await writeFile(module.compiledPath, rewritten, 'utf8');
25
- return;
26
- }
27
-
28
- const source = await readFile(module.sourcePath, 'utf8');
29
- const output = ts.transpileModule(source, {
30
- fileName: module.sourcePath,
31
- compilerOptions: {
32
- target: ts.ScriptTarget.ES2022,
33
- module: ts.ModuleKind.ESNext,
34
- moduleResolution: ts.ModuleResolutionKind.Bundler,
35
- verbatimModuleSyntax: true,
36
- rewriteRelativeImportExtensions: true,
37
- },
38
- reportDiagnostics: false,
39
- });
40
-
41
- const rewritten = rewriteTestingImports(output.outputText, module.compiledPath, shimPath);
42
- await writeFile(module.compiledPath, rewritten, 'utf8');
43
- }));
47
+ }),
48
+ );
44
49
  }
45
50
 
46
51
  async function ensureTestingRuntimeShim(workspaceRoot: string): Promise<string> {
@@ -52,7 +57,10 @@ async function ensureTestingRuntimeShim(workspaceRoot: string): Promise<string>
52
57
  }
53
58
 
54
59
  function rewriteTestingImports(source: string, compiledPath: string, shimPath: string): string {
55
- const relativeShim = path.relative(path.dirname(compiledPath), shimPath).split(path.sep).join('/');
60
+ const relativeShim = path
61
+ .relative(path.dirname(compiledPath), shimPath)
62
+ .split(path.sep)
63
+ .join('/');
56
64
  const specifier = relativeShim.startsWith('.') ? relativeShim : `./${relativeShim}`;
57
65
 
58
66
  return source
package/src/dev-server.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  import path from 'node:path';
2
- import http, { type IncomingMessage, type ServerResponse } from 'node:http';
3
- import { createReadStream } from 'node:fs';
4
2
  import { access } from 'node:fs/promises';
5
3
 
6
4
  import type { HotUpdatePayload, WatchStatus } from './watch-events.ts';
@@ -41,14 +39,39 @@ const MIME_TYPES: Record<string, string> = {
41
39
 
42
40
  const RESERVED_PREFIXES = ['__webstir', 'api', 'fonts', 'images', 'media', 'pages', 'sse'];
43
41
  const STATIC_EXTENSIONS = new Set([
44
- '.css', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
45
- '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp3', '.m4a', '.wav', '.ogg', '.mp4',
46
- '.webm', '.mov', '.json', '.txt', '.xml', '.map',
42
+ '.css',
43
+ '.js',
44
+ '.mjs',
45
+ '.png',
46
+ '.jpg',
47
+ '.jpeg',
48
+ '.gif',
49
+ '.svg',
50
+ '.webp',
51
+ '.ico',
52
+ '.woff',
53
+ '.woff2',
54
+ '.ttf',
55
+ '.otf',
56
+ '.eot',
57
+ '.mp3',
58
+ '.m4a',
59
+ '.wav',
60
+ '.ogg',
61
+ '.mp4',
62
+ '.webm',
63
+ '.mov',
64
+ '.json',
65
+ '.txt',
66
+ '.xml',
67
+ '.map',
47
68
  ]);
48
- const CONTENT_HASH_PATTERN = /\.[a-f0-9]{8,64}\.(css|js|png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp3|m4a|wav|ogg|mp4|webm|mov)$/i;
69
+ const CONTENT_HASH_PATTERN =
70
+ /\.[a-f0-9]{8,64}\.(css|js|png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp3|m4a|wav|ogg|mp4|webm|mov)$/i;
49
71
 
50
72
  interface SseClient {
51
- readonly response: ServerResponse<IncomingMessage>;
73
+ send(message: string): void;
74
+ close(): void;
52
75
  }
53
76
 
54
77
  export class DevServer {
@@ -57,7 +80,7 @@ export class DevServer {
57
80
  private readonly port: number;
58
81
  private readonly apiProxyOrigin?: string;
59
82
  private readonly clients = new Set<SseClient>();
60
- private server?: http.Server;
83
+ private server?: ReturnType<typeof Bun.serve>;
61
84
 
62
85
  public constructor(options: DevServerOptions) {
63
86
  this.buildRoot = path.resolve(options.buildRoot);
@@ -71,16 +94,13 @@ export class DevServer {
71
94
  return this.getAddress();
72
95
  }
73
96
 
74
- this.server = http.createServer((request, response) => {
75
- void this.handleRequest(request, response);
76
- });
77
-
78
- await new Promise<void>((resolve, reject) => {
79
- this.server!.once('error', reject);
80
- this.server!.listen(this.port, this.host, () => {
81
- this.server!.off('error', reject);
82
- resolve();
83
- });
97
+ this.server = Bun.serve({
98
+ hostname: this.host,
99
+ idleTimeout: 0,
100
+ port: this.port,
101
+ fetch: (request) => {
102
+ return this.handleRequest(request);
103
+ },
84
104
  });
85
105
 
86
106
  return this.getAddress();
@@ -90,7 +110,7 @@ export class DevServer {
90
110
  await this.broadcastRaw('data: shutdown\n\n');
91
111
 
92
112
  for (const client of this.clients) {
93
- client.response.end();
113
+ client.close();
94
114
  }
95
115
  this.clients.clear();
96
116
 
@@ -100,16 +120,7 @@ export class DevServer {
100
120
 
101
121
  const server = this.server;
102
122
  this.server = undefined;
103
- await new Promise<void>((resolve, reject) => {
104
- server.close((error) => {
105
- if (error) {
106
- reject(error);
107
- return;
108
- }
109
-
110
- resolve();
111
- });
112
- });
123
+ server.stop();
113
124
  }
114
125
 
115
126
  public async publishStatus(status: WatchStatus): Promise<void> {
@@ -129,139 +140,127 @@ export class DevServer {
129
140
  throw new Error('Dev server has not started.');
130
141
  }
131
142
 
132
- const address = this.server.address();
133
- if (!address || typeof address === 'string') {
134
- throw new Error('Dev server did not expose a TCP address.');
135
- }
136
-
137
143
  const originHost = this.host === '0.0.0.0' ? '127.0.0.1' : this.host;
138
144
  return {
139
145
  host: originHost,
140
- port: address.port,
141
- origin: `http://${originHost}:${address.port}`,
146
+ port: this.server.port,
147
+ origin: `http://${originHost}:${this.server.port}`,
142
148
  };
143
149
  }
144
150
 
145
- private async handleRequest(
146
- request: IncomingMessage,
147
- response: ServerResponse<IncomingMessage>
148
- ): Promise<void> {
149
- if (!request.url) {
150
- this.writeText(response, 400, 'Bad request.');
151
- return;
152
- }
153
-
154
- const method = request.method ?? 'GET';
155
-
156
- const requestUrl = new URL(request.url, 'http://localhost');
151
+ private async handleRequest(request: Request): Promise<Response> {
152
+ const method = request.method || 'GET';
153
+ const requestUrl = new URL(request.url);
157
154
  const { pathname } = requestUrl;
155
+
158
156
  if (pathname === '/sse') {
159
- this.handleSse(response);
160
- return;
157
+ return this.handleSse(request);
161
158
  }
162
159
 
163
160
  const apiProxyPath = getApiProxyPath(pathname);
164
161
  if (apiProxyPath !== null && this.apiProxyOrigin) {
165
- await this.handleApiProxy(request, response, requestUrl, apiProxyPath);
166
- return;
162
+ return await this.handleApiProxy(request, requestUrl, apiProxyPath);
167
163
  }
168
164
 
169
165
  if (method !== 'GET' && method !== 'HEAD') {
170
- this.writeText(response, 405, 'Method not allowed.');
171
- return;
166
+ return textResponse(405, 'Method not allowed.');
172
167
  }
173
168
 
174
169
  const candidates = getStaticCandidatePaths(pathname);
175
170
  const resolved = await resolveStaticFile(this.buildRoot, candidates);
176
171
  if (!resolved) {
177
- this.writeText(response, 404, 'Not found.');
178
- return;
172
+ return textResponse(404, 'Not found.');
179
173
  }
180
174
 
181
175
  const lowerRelativePath = resolved.relativePath.toLowerCase();
182
176
  const extension = path.extname(lowerRelativePath).toLowerCase();
183
- response.setHeader('Content-Type', MIME_TYPES[extension] ?? 'application/octet-stream');
184
- setCacheHeaders(response, lowerRelativePath);
177
+ const headers = new Headers({
178
+ 'Content-Type': MIME_TYPES[extension] ?? 'application/octet-stream',
179
+ });
180
+ setCacheHeaders(headers, lowerRelativePath);
185
181
 
186
182
  if (method === 'HEAD') {
187
- response.statusCode = 200;
188
- response.end();
189
- return;
183
+ return new Response(null, {
184
+ status: 200,
185
+ headers,
186
+ });
190
187
  }
191
188
 
192
- const stream = createReadStream(resolved.absolutePath);
193
- stream.once('error', () => {
194
- if (!response.headersSent) {
195
- this.writeText(response, 500, 'Failed to read file.');
196
- } else {
197
- response.destroy();
198
- }
189
+ return new Response(Bun.file(resolved.absolutePath), {
190
+ status: 200,
191
+ headers,
199
192
  });
200
- stream.pipe(response);
201
193
  }
202
194
 
203
195
  private async handleApiProxy(
204
- request: IncomingMessage,
205
- response: ServerResponse<IncomingMessage>,
196
+ request: Request,
206
197
  requestUrl: URL,
207
- apiProxyPath: string
208
- ): Promise<void> {
198
+ apiProxyPath: string,
199
+ ): Promise<Response> {
209
200
  const targetUrl = new URL(apiProxyPath + requestUrl.search, this.apiProxyOrigin);
210
201
 
211
- await new Promise<void>((resolve) => {
212
- const proxyRequest = http.request(targetUrl, {
213
- agent: false,
214
- method: request.method,
215
- headers: {
216
- ...request.headers,
217
- host: targetUrl.host,
218
- connection: 'close',
219
- },
220
- }, (proxyResponse) => {
221
- response.writeHead(proxyResponse.statusCode ?? 502, proxyResponse.headers);
222
- proxyResponse.pipe(response);
223
- proxyResponse.once('end', resolve);
224
- proxyResponse.once('error', () => {
225
- if (!response.headersSent) {
226
- this.writeText(response, 502, 'Backend proxy read failed.');
227
- } else {
228
- response.destroy();
229
- }
230
- resolve();
231
- });
232
- });
202
+ try {
203
+ const requestInit = createProxyRequestInit(request, targetUrl);
204
+ const proxyResponse = await fetch(targetUrl, requestInit);
205
+ const headers = rewriteProxyResponseHeaders(proxyResponse.headers, targetUrl);
233
206
 
234
- proxyRequest.once('error', () => {
235
- if (!response.headersSent) {
236
- this.writeText(response, 502, 'Backend proxy failed.');
237
- } else {
238
- response.destroy();
239
- }
240
- resolve();
207
+ return new Response(request.method !== 'HEAD' ? proxyResponse.body : null, {
208
+ status: proxyResponse.status || 502,
209
+ headers,
241
210
  });
211
+ } catch {
212
+ return textResponse(502, 'Backend proxy failed.');
213
+ }
214
+ }
242
215
 
243
- if (request.method === 'GET' || request.method === 'HEAD') {
244
- proxyRequest.end();
245
- return;
246
- }
216
+ private handleSse(request: Request): Response {
217
+ const encoder = new TextEncoder();
218
+ let client: SseClient | undefined;
247
219
 
248
- request.pipe(proxyRequest);
249
- });
250
- }
220
+ const stream = new ReadableStream<Uint8Array>({
221
+ start: (controller) => {
222
+ const cleanup = () => {
223
+ if (!client) {
224
+ return;
225
+ }
251
226
 
252
- private handleSse(response: ServerResponse<IncomingMessage>): void {
253
- response.writeHead(200, {
254
- 'Content-Type': 'text/event-stream',
255
- 'Cache-Control': 'no-cache',
256
- Connection: 'keep-alive',
227
+ this.clients.delete(client);
228
+ try {
229
+ controller.close();
230
+ } catch {
231
+ // The stream is already closed.
232
+ }
233
+ request.signal.removeEventListener('abort', cleanup);
234
+ client = undefined;
235
+ };
236
+
237
+ client = {
238
+ send: (message) => {
239
+ try {
240
+ controller.enqueue(encoder.encode(message));
241
+ } catch {
242
+ cleanup();
243
+ }
244
+ },
245
+ close: cleanup,
246
+ };
247
+
248
+ this.clients.add(client);
249
+ controller.enqueue(encoder.encode('\n'));
250
+ request.signal.addEventListener('abort', cleanup, { once: true });
251
+ },
252
+ cancel: () => {
253
+ client?.close();
254
+ },
257
255
  });
258
256
 
259
- const client = { response };
260
- this.clients.add(client);
261
- response.write('\n');
262
-
263
- response.once('close', () => {
264
- this.clients.delete(client);
257
+ return new Response(stream, {
258
+ status: 200,
259
+ headers: {
260
+ 'Content-Type': 'text/event-stream',
261
+ 'Cache-Control': 'no-cache',
262
+ Connection: 'keep-alive',
263
+ },
265
264
  });
266
265
  }
267
266
 
@@ -272,22 +271,13 @@ export class DevServer {
272
271
  private async broadcastRaw(message: string): Promise<void> {
273
272
  for (const client of Array.from(this.clients)) {
274
273
  try {
275
- client.response.write(message);
274
+ client.send(message);
276
275
  } catch {
276
+ client.close();
277
277
  this.clients.delete(client);
278
278
  }
279
279
  }
280
280
  }
281
-
282
- private writeText(
283
- response: ServerResponse<IncomingMessage>,
284
- statusCode: number,
285
- body: string
286
- ): void {
287
- response.statusCode = statusCode;
288
- response.setHeader('Content-Type', 'text/plain; charset=utf-8');
289
- response.end(body);
290
- }
291
281
  }
292
282
 
293
283
  export function getStaticCandidatePaths(pathname: string): readonly string[] {
@@ -309,7 +299,7 @@ export function getStaticCandidatePaths(pathname: string): readonly string[] {
309
299
  candidates.push(path.posix.join('pages', relativePath, 'index.html'));
310
300
  }
311
301
 
312
- return Array.from(new Set(candidates.map(candidate => candidate.replace(/^\/+/, ''))));
302
+ return Array.from(new Set(candidates.map((candidate) => candidate.replace(/^\/+/, ''))));
313
303
  }
314
304
 
315
305
  export function getApiProxyPath(pathname: string): string | null {
@@ -325,9 +315,73 @@ export function getApiProxyPath(pathname: string): string | null {
325
315
  return null;
326
316
  }
327
317
 
318
+ function rewriteProxyResponseHeaders(headers: Headers, targetUrl: URL): Headers {
319
+ const nextHeaders = new Headers(headers);
320
+ const location = headers.get('location');
321
+ if (location) {
322
+ nextHeaders.set('location', rewriteProxyLocation(location, targetUrl));
323
+ }
324
+
325
+ return nextHeaders;
326
+ }
327
+
328
+ function createProxyRequestInit(
329
+ request: Request,
330
+ targetUrl: URL,
331
+ ): RequestInit & { duplex?: 'half' } {
332
+ const headers = new Headers(request.headers);
333
+ headers.set('host', targetUrl.host);
334
+ headers.set('connection', 'close');
335
+
336
+ const requestInit: RequestInit & { duplex?: 'half' } = {
337
+ method: request.method,
338
+ headers,
339
+ redirect: 'manual',
340
+ signal: request.signal,
341
+ };
342
+
343
+ if (methodAllowsBody(request.method)) {
344
+ requestInit.body = request.body;
345
+ if (request.body) {
346
+ requestInit.duplex = 'half';
347
+ }
348
+ }
349
+
350
+ return requestInit;
351
+ }
352
+
353
+ function rewriteProxyLocation(value: string, targetUrl: URL): string {
354
+ const trimmed = value.trim();
355
+ if (!trimmed) {
356
+ return value;
357
+ }
358
+
359
+ if (trimmed.startsWith('/')) {
360
+ return prefixApiMount(trimmed);
361
+ }
362
+
363
+ try {
364
+ const resolved = new URL(trimmed, targetUrl.origin);
365
+ if (resolved.origin !== targetUrl.origin) {
366
+ return value;
367
+ }
368
+ return prefixApiMount(`${resolved.pathname}${resolved.search}${resolved.hash}`);
369
+ } catch {
370
+ return value;
371
+ }
372
+ }
373
+
374
+ function prefixApiMount(pathname: string): string {
375
+ if (pathname === '/api' || pathname.startsWith('/api/')) {
376
+ return pathname;
377
+ }
378
+
379
+ return pathname === '/' ? '/api' : `/api${pathname}`;
380
+ }
381
+
328
382
  async function resolveStaticFile(
329
383
  buildRoot: string,
330
- relativePaths: readonly string[]
384
+ relativePaths: readonly string[],
331
385
  ): Promise<{ absolutePath: string; relativePath: string } | null> {
332
386
  for (const relativePath of relativePaths) {
333
387
  const absolutePath = path.resolve(buildRoot, relativePath);
@@ -363,31 +417,48 @@ function getGenericFileCandidates(relativePath: string): readonly string[] {
363
417
  }
364
418
 
365
419
  function hasReservedPrefix(relativePath: string): boolean {
366
- return RESERVED_PREFIXES.some((prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`));
420
+ return RESERVED_PREFIXES.some(
421
+ (prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`),
422
+ );
367
423
  }
368
424
 
369
- function setCacheHeaders(response: ServerResponse<IncomingMessage>, relativePath: string): void {
425
+ function setCacheHeaders(headers: Headers, relativePath: string): void {
370
426
  if (CONTENT_HASH_PATTERN.test(relativePath)) {
371
- response.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
427
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable');
372
428
  return;
373
429
  }
374
430
 
375
431
  if (relativePath.endsWith('refresh.js') || relativePath.endsWith('hmr.js')) {
376
- response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
377
- response.setHeader('Pragma', 'no-cache');
378
- response.setHeader('Expires', '0');
432
+ setNoCacheHeaders(headers);
379
433
  return;
380
434
  }
381
435
 
382
436
  const extension = path.extname(relativePath).toLowerCase();
383
437
  if (extension === '.html' || extension === '') {
384
- response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
385
- response.setHeader('Pragma', 'no-cache');
386
- response.setHeader('Expires', '0');
438
+ setNoCacheHeaders(headers);
387
439
  return;
388
440
  }
389
441
 
390
442
  if (STATIC_EXTENSIONS.has(extension)) {
391
- response.setHeader('Cache-Control', 'no-cache, must-revalidate');
443
+ headers.set('Cache-Control', 'no-cache, must-revalidate');
392
444
  }
393
445
  }
446
+
447
+ function setNoCacheHeaders(headers: Headers): void {
448
+ headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
449
+ headers.set('Pragma', 'no-cache');
450
+ headers.set('Expires', '0');
451
+ }
452
+
453
+ function textResponse(statusCode: number, body: string): Response {
454
+ return new Response(body, {
455
+ status: statusCode,
456
+ headers: {
457
+ 'Content-Type': 'text/plain; charset=utf-8',
458
+ },
459
+ });
460
+ }
461
+
462
+ function methodAllowsBody(method: string): boolean {
463
+ return method !== 'GET' && method !== 'HEAD';
464
+ }