@rocket/js 0.2.1 → 0.3.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 (55) hide show
  1. package/dist-types/exports/adapters/cloudflare.d.ts +2 -0
  2. package/dist-types/exports/adapters/cloudflare.d.ts.map +1 -0
  3. package/dist-types/exports/types/rocket.d.ts +11 -0
  4. package/dist-types/exports/types/rocket.d.ts.map +1 -1
  5. package/dist-types/exports/types.d.ts +1 -1
  6. package/dist-types/exports/types.d.ts.map +1 -1
  7. package/dist-types/src/PageData.d.ts +9 -0
  8. package/dist-types/src/PageData.d.ts.map +1 -1
  9. package/dist-types/src/RocketCodeBlock.d.ts.map +1 -1
  10. package/dist-types/src/RocketJsDemo.d.ts.map +1 -1
  11. package/dist-types/src/adapters/cloudflare.d.ts +27 -0
  12. package/dist-types/src/adapters/cloudflare.d.ts.map +1 -0
  13. package/dist-types/src/adapters/netlify.d.ts.map +1 -1
  14. package/dist-types/src/adapters/redirects.d.ts +14 -0
  15. package/dist-types/src/adapters/redirects.d.ts.map +1 -0
  16. package/dist-types/src/icons.d.ts +27 -0
  17. package/dist-types/src/icons.d.ts.map +1 -1
  18. package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts.map +1 -1
  19. package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts.map +1 -1
  20. package/dist-types/src/layouts/atlas/atlasHeroLayout.d.ts.map +1 -1
  21. package/dist-types/src/layouts/atlas/atlasNotFoundLayout.d.ts.map +1 -1
  22. package/dist-types/src/layouts/layout-helper.d.ts.map +1 -1
  23. package/dist-types/src/markdownHook.d.ts.map +1 -1
  24. package/dist-types/src/menus/MainMenu.d.ts.map +1 -1
  25. package/dist-types/src/menus/RocketMenu.d.ts.map +1 -1
  26. package/dist-types/src/menus/RocketToc.d.ts.map +1 -1
  27. package/dist-types/src/menus/pageNavigation.d.ts.map +1 -1
  28. package/dist-types/src/publicAssets.d.ts.map +1 -1
  29. package/exports/adapters/cloudflare.js +1 -0
  30. package/exports/types/rocket.ts +17 -4
  31. package/exports/types.ts +1 -0
  32. package/package.json +40 -26
  33. package/src/PageData.js +15 -0
  34. package/src/RocketCodeBlock.js +11 -9
  35. package/src/RocketJsDemo.js +7 -5
  36. package/src/adapters/cloudflare.js +522 -0
  37. package/src/adapters/netlify.js +2 -24
  38. package/src/adapters/redirects.js +31 -0
  39. package/src/cli/RocketBuild.js +5 -2
  40. package/src/config.js +1 -1
  41. package/src/icons.js +138 -0
  42. package/src/layouts/atlas/atlasBlogLayout.js +45 -31
  43. package/src/layouts/atlas/atlasDocLayout.js +22 -16
  44. package/src/layouts/atlas/atlasHeroLayout.js +39 -31
  45. package/src/layouts/atlas/atlasNotFoundLayout.js +5 -3
  46. package/src/layouts/layout-helper.js +38 -26
  47. package/src/main.js +15 -0
  48. package/src/markdownCompiler.js +5 -1
  49. package/src/menus/MainMenu.js +17 -13
  50. package/src/menus/RocketMenu.js +25 -19
  51. package/src/menus/RocketToc.js +7 -5
  52. package/src/menus/pageNavigation.js +24 -10
  53. package/src/publicAssets.js +9 -2
  54. package/src/transform.js +7 -1
  55. package/src/wds-plugin.js +2 -1
@@ -0,0 +1,522 @@
1
+ /** Runs on: server */
2
+ /* eslint-disable no-console */
3
+ import { mkdirSync, watch, writeFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import { Readable } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { debounce } from '../debounce.js';
9
+ import { makeRedirectsFileSource, writeRedirectsFile } from './redirects.js';
10
+
11
+ /**
12
+ * @typedef {typeof import('wrangler')} Wrangler
13
+ * @typedef {Awaited<ReturnType<Wrangler['unstable_startWorker']>>} Worker
14
+ * @typedef {import('koa').Context} KoaContext
15
+ */
16
+
17
+ /**
18
+ * Deploys Rocket's static output to Cloudflare Workers Static Assets and runs the site's Worker
19
+ * inside `rocket start`, on the same origin as the Rocket Pages.
20
+ *
21
+ * @param {{ config?: string }} [options]
22
+ * @returns {import('@rocket/js/types.js').RocketAdapter}
23
+ */
24
+ export function cloudflare({ config = './wrangler.jsonc' } = {}) {
25
+ return {
26
+ name: 'cloudflare',
27
+ async adjustDevServerConfig(devServerConfig, context) {
28
+ const configPath = path.resolve(context.projectRoot, config);
29
+ const wrangler = await loadWrangler();
30
+ const workerPaths = workerPathPrefixes(
31
+ wrangler.unstable_readConfig({ config: configPath }).assets?.run_worker_first,
32
+ configPath,
33
+ );
34
+ assertNotShadowed(context, workerPaths);
35
+ /** @param {string} requestPath */
36
+ const isWorkerPath = requestPath =>
37
+ workerPaths.some(prefix => requestPath.startsWith(prefix));
38
+ const devWorker = new DevWorker({
39
+ wrangler,
40
+ configPath,
41
+ projectRoot: context.projectRoot,
42
+ watch: devServerConfig.watch !== false,
43
+ });
44
+
45
+ return {
46
+ ...devServerConfig,
47
+ middleware: [
48
+ async (ctx, next) => {
49
+ if (!isWorkerPath(ctx.path)) {
50
+ return next();
51
+ }
52
+ const worker = await devWorker.ready();
53
+ if (!worker) {
54
+ respondWithStartupError(ctx, devWorker.startupError);
55
+ return;
56
+ }
57
+ try {
58
+ await forwardToWorker(ctx, worker);
59
+ } catch (error) {
60
+ if (devWorker.startupError) {
61
+ // The Worker broke while this request waited for it.
62
+ respondWithStartupError(ctx, devWorker.startupError);
63
+ } else if (!ctx.res.headersSent) {
64
+ respondWithWorkerError(ctx, error);
65
+ }
66
+ }
67
+ },
68
+ ...(devServerConfig.middleware ?? []),
69
+ ],
70
+ plugins: [
71
+ ...(devServerConfig.plugins ?? []),
72
+ {
73
+ name: 'rocket-cloudflare-worker',
74
+ serverStart() {
75
+ devWorker.ready();
76
+ },
77
+ async serverStop() {
78
+ await devWorker.dispose();
79
+ },
80
+ },
81
+ ],
82
+ };
83
+ },
84
+ async build(context) {
85
+ assertNoServerPages(context);
86
+ const configPath = path.resolve(context.projectRoot, config);
87
+ const wrangler = await loadWrangler();
88
+ const { assets } = wrangler.unstable_readConfig({ config: configPath });
89
+ assertNotShadowed(context, workerPathPrefixes(assets?.run_worker_first, configPath));
90
+ assertCloudflareRedirects(context.config.urlLifecycle?.redirects ?? []);
91
+ const assetsDirectory =
92
+ assets?.directory && path.resolve(path.dirname(configPath), assets.directory);
93
+ if (assetsDirectory !== path.resolve(context.outDir)) {
94
+ throw new Error(
95
+ `assets.directory ${JSON.stringify(assets?.directory)} in ${configPath} does not match ` +
96
+ `Rocket's build output ${context.outDir}. Set "assets": { "directory": ` +
97
+ `${JSON.stringify(`./${path.relative(path.dirname(configPath), context.outDir)}`)} }.`,
98
+ );
99
+ }
100
+ // Rocket skips dotfiles in public/, so the ignore file is written here. It keeps Vite's
101
+ // manifest out of the upload.
102
+ mkdirSync(context.outDir, { recursive: true });
103
+ writeFileSync(path.join(context.outDir, '.assetsignore'), '.vite\n');
104
+ // Wrangler reads `_redirects` itself and leaves it out of the upload.
105
+ writeRedirectsFile(context);
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Server-rendered Pages need a Rocket request handler inside the Worker, which this adapter does
112
+ * not generate yet.
113
+ *
114
+ * @param {import('@rocket/js/types.js').RocketAdapterBuildContext} context
115
+ */
116
+ function assertNoServerPages(context) {
117
+ if (context.serverPages.size) {
118
+ throw new Error(
119
+ `The cloudflare() adapter does not support Pages with render: 'server' yet: ` +
120
+ `${Array.from(context.serverPages.keys()).join(', ')}`,
121
+ );
122
+ }
123
+ }
124
+
125
+ // Wrangler's `_redirects` limits. It skips rules past them with a warning instead of failing.
126
+ const MAX_STATIC_REDIRECTS = 2000;
127
+ const MAX_REDIRECT_LINE_LENGTH = 2000;
128
+
129
+ /**
130
+ * Rocket Redirects match their source exactly. Cloudflare reads `*` and `:name` in a source as a
131
+ * splat or placeholder, so such a source would match other paths too.
132
+ *
133
+ * @param {import('@rocket/js/types.js').RedirectConfig[]} redirects
134
+ */
135
+ function assertCloudflareRedirects(redirects) {
136
+ if (redirects.length > MAX_STATIC_REDIRECTS) {
137
+ throw new Error(
138
+ `${redirects.length} Redirects are configured, but Cloudflare's _redirects file supports ` +
139
+ `at most ${MAX_STATIC_REDIRECTS}. Handle the rest in the Worker.`,
140
+ );
141
+ }
142
+ for (const redirect of redirects) {
143
+ if (/\*|:[A-Za-z]/.test(redirect.source)) {
144
+ throw new Error(
145
+ `The Redirect source ${JSON.stringify(redirect.source)} contains "*" or ":name", ` +
146
+ `which Cloudflare's _redirects file reads as a pattern rather than an exact path.`,
147
+ );
148
+ }
149
+ const line = makeRedirectsFileSource([redirect]).trimEnd();
150
+ if (line.length > MAX_REDIRECT_LINE_LENGTH) {
151
+ throw new Error(
152
+ `The Redirect from ${JSON.stringify(redirect.source)} is longer than the ` +
153
+ `${MAX_REDIRECT_LINE_LENGTH} characters Cloudflare's _redirects file allows per rule.`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+
159
+ const SUPPORTED_WRANGLER = '^4.162.0';
160
+
161
+ /**
162
+ * Wrangler is an optional peer dependency, so it is only loaded once a site uses this adapter.
163
+ *
164
+ * @returns {Promise<Wrangler>}
165
+ */
166
+ async function loadWrangler() {
167
+ /** @type {Wrangler} */
168
+ let wrangler;
169
+ try {
170
+ wrangler = await import('wrangler');
171
+ } catch (error) {
172
+ if (/** @type {{ code?: string }} */ (error).code === 'ERR_MODULE_NOT_FOUND') {
173
+ throw new Error(
174
+ `The cloudflare() adapter needs Wrangler. Install it with: npm install --save-dev wrangler@${SUPPORTED_WRANGLER}`,
175
+ { cause: error },
176
+ );
177
+ }
178
+ throw error;
179
+ }
180
+ const { version } = createRequire(import.meta.url)('wrangler/package.json');
181
+ assertSupportedWrangler(wrangler, version);
182
+ return wrangler;
183
+ }
184
+
185
+ /**
186
+ * `unstable_startWorker` and `unstable_readConfig` are unstable Wrangler APIs, so a Wrangler that
187
+ * renamed them stops startup with a clear message instead of a TypeError later.
188
+ *
189
+ * @param {object} wrangler
190
+ * @param {string} version
191
+ */
192
+ export function assertSupportedWrangler(wrangler, version) {
193
+ const required = ['unstable_startWorker', 'unstable_readConfig'];
194
+ const missing = required.filter(
195
+ name => typeof (/** @type {Record<string, unknown>} */ (wrangler)[name]) !== 'function',
196
+ );
197
+ if (missing.length) {
198
+ throw new Error(
199
+ `Wrangler ${version} is not supported by this Rocket version: it has no ${missing.join(' or ')}. ` +
200
+ `Install a Wrangler matching ${SUPPORTED_WRANGLER}.`,
201
+ );
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Keeps the site's Worker running for `rocket start` and tracks whether it currently fails to
207
+ * start. Wrangler recovers from a broken save by itself once the Worker has started, but not from
208
+ * a Worker that never started, so that case is restarted on the next file change.
209
+ */
210
+ class DevWorker {
211
+ /** @type {Promise<Worker> | undefined} */
212
+ #starting;
213
+ /** @type {Worker | undefined} */
214
+ #worker;
215
+ /** @type {import('node:fs').FSWatcher | undefined} */
216
+ #retryWatcher;
217
+ #disposed = false;
218
+
219
+ /** @type {string | undefined} */
220
+ startupError;
221
+
222
+ /**
223
+ * @param {{ wrangler: Wrangler; configPath: string; projectRoot: string; watch: boolean }} options
224
+ */
225
+ constructor(options) {
226
+ this.options = options;
227
+ // Wrangler refuses to start without an assets directory. Rocket serves the frontend in dev, so
228
+ // the Worker gets an empty one instead of the build output (which may not exist yet).
229
+ this.devAssetsDir = path.join(options.projectRoot, '.wrangler', 'rocket-dev-assets');
230
+ }
231
+
232
+ /**
233
+ * The running Worker, or `undefined` while it has a startup error.
234
+ *
235
+ * @returns {Promise<Worker | undefined>}
236
+ */
237
+ async ready() {
238
+ if (this.startupError) {
239
+ return undefined;
240
+ }
241
+ this.#starting ??= this.#start();
242
+ try {
243
+ return await this.#starting;
244
+ } catch {
245
+ return undefined;
246
+ }
247
+ }
248
+
249
+ async dispose() {
250
+ this.#disposed = true;
251
+ this.#retryWatcher?.close();
252
+ await this.#starting?.catch(() => undefined);
253
+ await this.#teardown();
254
+ }
255
+
256
+ async #start() {
257
+ mkdirSync(this.devAssetsDir, { recursive: true });
258
+ let started = false;
259
+ /** @type {Worker} */
260
+ let worker;
261
+ try {
262
+ worker = await this.options.wrangler.unstable_startWorker({
263
+ config: this.options.configPath,
264
+ assets: this.devAssetsDir,
265
+ dev: { watch: this.options.watch, inspector: false, server: { port: 0 } },
266
+ });
267
+ } catch (error) {
268
+ this.#failedFirstStart(error);
269
+ throw error;
270
+ }
271
+ this.#worker = worker;
272
+ worker.raw.on('reloadComplete', () => {
273
+ started = true;
274
+ this.startupError = undefined;
275
+ });
276
+ worker.raw.on('buildFailed', (/** @type {{ cause: unknown }} */ event) => {
277
+ this.startupError = errorMessage(event.cause);
278
+ });
279
+ worker.raw.on('error', (/** @type {unknown} */ event) => {
280
+ if (!isWorkerStartupError(event)) {
281
+ return;
282
+ }
283
+ this.startupError = errorMessage(event instanceof Error ? event : event.cause);
284
+ if (!started) {
285
+ this.#failedFirstStart(event);
286
+ }
287
+ });
288
+ return worker;
289
+ }
290
+
291
+ /**
292
+ * @param {unknown} error
293
+ */
294
+ #failedFirstStart(error) {
295
+ this.startupError = errorMessage(error);
296
+ this.#starting = undefined;
297
+ this.#teardown();
298
+ if (!this.options.watch || this.#disposed || this.#retryWatcher) {
299
+ return;
300
+ }
301
+ console.error('[rocket] The Worker failed to start. Rocket starts it again on the next save.');
302
+ // Saves often touch several files at once; restart once they are written.
303
+ const restart = debounce(() => {
304
+ if (this.#disposed) {
305
+ return;
306
+ }
307
+ this.#retryWatcher?.close();
308
+ this.#retryWatcher = undefined;
309
+ this.startupError = undefined;
310
+ this.ready();
311
+ }, 100);
312
+ this.#retryWatcher = watch(this.options.projectRoot, { recursive: true }, (_, filename) => {
313
+ if (filename && !isIgnoredForRetry(filename)) {
314
+ restart();
315
+ }
316
+ });
317
+ }
318
+
319
+ async #teardown() {
320
+ const worker = this.#worker;
321
+ this.#worker = undefined;
322
+ // `dispose()` waits for the Worker to become ready, which never happens after a failed start.
323
+ await worker?.raw.teardown().catch(() => undefined);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * @param {unknown} event
329
+ * @returns {event is Error | { cause: unknown }}
330
+ */
331
+ function isWorkerStartupError(event) {
332
+ if (event instanceof Error) {
333
+ return true;
334
+ }
335
+ const source = /** @type {{ source?: string }} */ (event)?.source;
336
+ return source === 'LocalRuntimeController' || source === 'ConfigController';
337
+ }
338
+
339
+ /**
340
+ * @param {string} filename
341
+ */
342
+ function isIgnoredForRetry(filename) {
343
+ const [first] = filename.split(path.sep);
344
+ return ['node_modules', '.wrangler', '.git', 'dist'].includes(first);
345
+ }
346
+
347
+ /**
348
+ * @param {unknown} error
349
+ * @returns {string}
350
+ */
351
+ function errorMessage(error) {
352
+ if (error instanceof Error) {
353
+ const cause = error.cause instanceof Error ? `\n${error.cause.message}` : '';
354
+ return `${error.message}${cause}`;
355
+ }
356
+ const cause = /** @type {{ cause?: unknown }} */ (error)?.cause;
357
+ return cause ? errorMessage(cause) : String(error);
358
+ }
359
+
360
+ /**
361
+ * @param {KoaContext} ctx
362
+ * @param {string | undefined} startupError
363
+ */
364
+ function respondWithStartupError(ctx, startupError) {
365
+ ctx.respond = true;
366
+ ctx.status = 503;
367
+ ctx.type = 'text/plain; charset=utf-8';
368
+ ctx.body = [
369
+ 'Worker failed to start. This is a development-only response from rocket start.',
370
+ '',
371
+ startupError ?? 'Unknown error',
372
+ '',
373
+ 'See the terminal running rocket start for details. The Worker starts again when you save a fix.',
374
+ '',
375
+ ].join('\n');
376
+ }
377
+
378
+ /**
379
+ * Wrangler rethrows an exception the Worker did not catch, so it is shown the way `wrangler dev`
380
+ * shows it: in the terminal and in the response.
381
+ *
382
+ * @param {KoaContext} ctx
383
+ * @param {unknown} error
384
+ */
385
+ function respondWithWorkerError(ctx, error) {
386
+ console.error('[rocket] The Worker threw while handling', ctx.method, ctx.originalUrl, error);
387
+ ctx.respond = true;
388
+ ctx.status = 500;
389
+ ctx.type = 'text/plain; charset=utf-8';
390
+ ctx.body = error instanceof Error ? (error.stack ?? error.message) : String(error);
391
+ }
392
+
393
+ /**
394
+ * Returns the path prefixes the Worker answers in dev, from Wrangler's `assets.run_worker_first`.
395
+ * Only plain `"/prefix/*"` patterns are supported, so dev routing matches production exactly.
396
+ *
397
+ * @param {string[] | boolean | undefined} runWorkerFirst
398
+ * @param {string} configPath
399
+ * @returns {string[]}
400
+ */
401
+ function workerPathPrefixes(runWorkerFirst, configPath) {
402
+ if (runWorkerFirst === undefined || runWorkerFirst === false) {
403
+ return [];
404
+ }
405
+ const patterns = Array.isArray(runWorkerFirst) ? runWorkerFirst : [runWorkerFirst];
406
+ return patterns.map(pattern => {
407
+ if (typeof pattern !== 'string' || !/^\/(?:[^*!]+\/)?\*$/.test(pattern) || pattern === '/*') {
408
+ throw new Error(
409
+ `Unsupported assets.run_worker_first entry ${JSON.stringify(pattern)} in ${configPath}. ` +
410
+ `The cloudflare() adapter's dev server only supports plain "/prefix/*" patterns, ` +
411
+ `such as "/api/*".`,
412
+ );
413
+ }
414
+ return pattern.slice(0, -1);
415
+ });
416
+ }
417
+
418
+ /**
419
+ * The Worker answers `run_worker_first` paths before static assets and `_redirects`, so a Page or
420
+ * Redirect under one would never be reached in production.
421
+ *
422
+ * @param {{ pages: import('@rocket/js/types.js').PageRegistry; config: import('@rocket/js/types.js').ResolvedRocketConfig }} context
423
+ * @param {string[]} workerPaths
424
+ */
425
+ function assertNotShadowed({ pages, config }, workerPaths) {
426
+ /** @param {string} urlPath */
427
+ const shadowingPrefix = urlPath => workerPaths.find(workerPath => urlPath.startsWith(workerPath));
428
+ for (const [pagePath, page] of pages) {
429
+ const prefix = shadowingPrefix(pagePath);
430
+ if (prefix) {
431
+ throw new Error(
432
+ `The Page ${pagePath} (${page.file}) is shadowed by "${prefix}*" in assets.run_worker_first: ` +
433
+ `the Worker answers that path, so the Page would never be served. ` +
434
+ `Move the Page or narrow run_worker_first.`,
435
+ );
436
+ }
437
+ }
438
+ for (const redirect of config.urlLifecycle?.redirects ?? []) {
439
+ const prefix = shadowingPrefix(redirect.source);
440
+ if (prefix) {
441
+ throw new Error(
442
+ `The Redirect source ${redirect.source} is shadowed by "${prefix}*" in ` +
443
+ `assets.run_worker_first: the Worker answers that path, so the Redirect would never apply. ` +
444
+ `Handle it in the Worker or narrow run_worker_first.`,
445
+ );
446
+ }
447
+ }
448
+ }
449
+
450
+ const HOP_BY_HOP_HEADERS = new Set([
451
+ 'connection',
452
+ 'content-length',
453
+ 'expect',
454
+ 'keep-alive',
455
+ 'proxy-connection',
456
+ 'te',
457
+ 'trailer',
458
+ 'transfer-encoding',
459
+ 'upgrade',
460
+ ]);
461
+
462
+ /**
463
+ * Hands one request to the Worker and writes its response back unchanged, so Worker paths share
464
+ * the Rocket dev server's origin and port.
465
+ *
466
+ * @param {KoaContext} ctx
467
+ * @param {Worker} worker
468
+ */
469
+ async function forwardToWorker(ctx, worker) {
470
+ const headers = new Headers();
471
+ for (const [name, value] of Object.entries(ctx.req.headers)) {
472
+ if (value === undefined || HOP_BY_HOP_HEADERS.has(name)) {
473
+ continue;
474
+ }
475
+ for (const item of Array.isArray(value) ? value : [value]) {
476
+ headers.append(name, item);
477
+ }
478
+ }
479
+ const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : await readBody(ctx.req);
480
+
481
+ const response = await worker.fetch(new URL(ctx.originalUrl, ctx.request.origin).href, {
482
+ method: ctx.method,
483
+ headers,
484
+ body,
485
+ redirect: 'manual',
486
+ });
487
+
488
+ ctx.respond = false;
489
+ const res = ctx.res;
490
+ res.statusCode = response.status;
491
+ res.statusMessage = response.statusText;
492
+ for (const [name, value] of response.headers) {
493
+ if (name !== 'set-cookie' && !HOP_BY_HOP_HEADERS.has(name)) {
494
+ res.setHeader(name, value);
495
+ }
496
+ }
497
+ const cookies = response.headers.getSetCookie();
498
+ if (cookies.length) {
499
+ res.setHeader('set-cookie', cookies);
500
+ }
501
+ if (!response.body) {
502
+ res.end();
503
+ return;
504
+ }
505
+ await pipeline(
506
+ Readable.fromWeb(/** @type {import('node:stream/web').ReadableStream} */ (response.body)),
507
+ res,
508
+ );
509
+ }
510
+
511
+ /**
512
+ * @param {import('node:http').IncomingMessage} req
513
+ * @returns {Promise<Buffer | undefined>}
514
+ */
515
+ async function readBody(req) {
516
+ /** @type {Buffer[]} */
517
+ const chunks = [];
518
+ for await (const chunk of req) {
519
+ chunks.push(chunk);
520
+ }
521
+ return chunks.length ? Buffer.concat(chunks) : undefined;
522
+ }
@@ -7,6 +7,7 @@ import { build } from 'vite';
7
7
  import { normalizeIconLibrariesConfig, rocketIconRuntimeOutputs } from '../icons.js';
8
8
  import { rocketMarkdownPlugin } from '../markdownCompiler.js';
9
9
  import { standaloneDemoRoutePatterns } from '../standalone-demo-url.js';
10
+ import { writeRedirectsFile } from './redirects.js';
10
11
 
11
12
  const ROCKET_ICON_FUNCTION_PATHS = [
12
13
  '/_rocket/icons/:library/:iconFile',
@@ -28,35 +29,12 @@ export function netlify() {
28
29
  return {
29
30
  name: 'netlify',
30
31
  async build(context) {
31
- writeNetlifyRedirectOutput(context);
32
+ writeRedirectsFile(context);
32
33
  await buildNetlifyFunction(context);
33
34
  },
34
35
  };
35
36
  }
36
37
 
37
- /**
38
- * @param {import('@rocket/js/types.js').RocketAdapterBuildContext} context
39
- */
40
- function writeNetlifyRedirectOutput(context) {
41
- const redirects = context.config.urlLifecycle?.redirects || [];
42
- if (!redirects.length) {
43
- return;
44
- }
45
-
46
- mkdirSync(context.outDir, { recursive: true });
47
- writeFileSync(path.join(context.outDir, '_redirects'), makeNetlifyRedirectsSource(redirects));
48
- }
49
-
50
- /**
51
- * @param {import('@rocket/js/types.js').RedirectConfig[]} redirects
52
- */
53
- function makeNetlifyRedirectsSource(redirects) {
54
- return redirects
55
- .map(redirect => `${redirect.source} ${redirect.target} ${redirect.status || 308}`)
56
- .join('\n')
57
- .concat('\n');
58
- }
59
-
60
38
  /**
61
39
  * @param {import('@rocket/js/types.js').RocketAdapterBuildContext} context
62
40
  */
@@ -0,0 +1,31 @@
1
+ /** Runs on: server */
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Netlify and Cloudflare Workers Static Assets both read a `_redirects` file from the published
7
+ * directory, with one `source target status` rule per line.
8
+ *
9
+ * @param {import('@rocket/js/types.js').RedirectConfig[]} redirects
10
+ */
11
+ export function makeRedirectsFileSource(redirects) {
12
+ return redirects
13
+ .map(redirect => `${redirect.source} ${redirect.target} ${redirect.status || 308}`)
14
+ .join('\n')
15
+ .concat('\n');
16
+ }
17
+
18
+ /**
19
+ * Writes `<outDir>/_redirects` for the configured URL Lifecycle Redirects, if there are any.
20
+ *
21
+ * @param {import('@rocket/js/types.js').RocketAdapterBuildContext} context
22
+ */
23
+ export function writeRedirectsFile(context) {
24
+ const redirects = context.config.urlLifecycle?.redirects || [];
25
+ if (!redirects.length) {
26
+ return;
27
+ }
28
+
29
+ mkdirSync(context.outDir, { recursive: true });
30
+ writeFileSync(path.join(context.outDir, '_redirects'), makeRedirectsFileSource(redirects));
31
+ }
@@ -300,7 +300,7 @@ export async function renderStaticPages({
300
300
  });
301
301
  }
302
302
  }
303
- writeIconAssetOutputs(iconAssetStore);
303
+ await writeIconAssetOutputs(iconAssetStore);
304
304
  if (emitRedirectFallbacks) {
305
305
  writeRedirectFallbackFiles(urlLifecycle?.redirects);
306
306
  }
@@ -504,10 +504,13 @@ function copyStaticFileOutputs(outDir) {
504
504
  /**
505
505
  * @param {import('../icons.js').IconAssetStore} iconAssetStore
506
506
  */
507
- function writeIconAssetOutputs(iconAssetStore) {
507
+ async function writeIconAssetOutputs(iconAssetStore) {
508
508
  for (const output of iconAssetStore.outputs()) {
509
509
  writeFileOutput(output.url, Buffer.from(output.svg, 'utf8'));
510
510
  }
511
+ for (const output of await iconAssetStore.webAwesomeIconOutputs()) {
512
+ writeFileOutput(output.url, Buffer.from(output.svg, 'utf8'));
513
+ }
511
514
  if (!iconAssetStore.needsRuntime) {
512
515
  return;
513
516
  }
package/src/config.js CHANGED
@@ -262,7 +262,7 @@ function invalidSiteOriginError(siteOrigin) {
262
262
  function validateRedirectConfig(redirects = []) {
263
263
  const sources = new Set();
264
264
  for (const redirect of redirects) {
265
- // whitespace would corrupt whitespace-delimited outputs like Netlify _redirects
265
+ // whitespace would corrupt whitespace-delimited outputs like Netlify and Cloudflare _redirects
266
266
  if (containsWhitespace(redirect.source) || containsWhitespace(redirect.target)) {
267
267
  throw new Error(
268
268
  `Invalid Redirect from ${JSON.stringify(redirect.source)} to ${JSON.stringify(