@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.
- package/dist-types/exports/adapters/cloudflare.d.ts +2 -0
- package/dist-types/exports/adapters/cloudflare.d.ts.map +1 -0
- package/dist-types/exports/types/rocket.d.ts +11 -0
- package/dist-types/exports/types/rocket.d.ts.map +1 -1
- package/dist-types/exports/types.d.ts +1 -1
- package/dist-types/exports/types.d.ts.map +1 -1
- package/dist-types/src/PageData.d.ts +9 -0
- package/dist-types/src/PageData.d.ts.map +1 -1
- package/dist-types/src/RocketCodeBlock.d.ts.map +1 -1
- package/dist-types/src/RocketJsDemo.d.ts.map +1 -1
- package/dist-types/src/adapters/cloudflare.d.ts +27 -0
- package/dist-types/src/adapters/cloudflare.d.ts.map +1 -0
- package/dist-types/src/adapters/netlify.d.ts.map +1 -1
- package/dist-types/src/adapters/redirects.d.ts +14 -0
- package/dist-types/src/adapters/redirects.d.ts.map +1 -0
- package/dist-types/src/icons.d.ts +27 -0
- package/dist-types/src/icons.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasHeroLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasNotFoundLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/layout-helper.d.ts.map +1 -1
- package/dist-types/src/markdownHook.d.ts.map +1 -1
- package/dist-types/src/menus/MainMenu.d.ts.map +1 -1
- package/dist-types/src/menus/RocketMenu.d.ts.map +1 -1
- package/dist-types/src/menus/RocketToc.d.ts.map +1 -1
- package/dist-types/src/menus/pageNavigation.d.ts.map +1 -1
- package/dist-types/src/publicAssets.d.ts.map +1 -1
- package/exports/adapters/cloudflare.js +1 -0
- package/exports/types/rocket.ts +17 -4
- package/exports/types.ts +1 -0
- package/package.json +40 -26
- package/src/PageData.js +15 -0
- package/src/RocketCodeBlock.js +11 -9
- package/src/RocketJsDemo.js +7 -5
- package/src/adapters/cloudflare.js +522 -0
- package/src/adapters/netlify.js +2 -24
- package/src/adapters/redirects.js +31 -0
- package/src/cli/RocketBuild.js +5 -2
- package/src/config.js +1 -1
- package/src/icons.js +138 -0
- package/src/layouts/atlas/atlasBlogLayout.js +45 -31
- package/src/layouts/atlas/atlasDocLayout.js +22 -16
- package/src/layouts/atlas/atlasHeroLayout.js +39 -31
- package/src/layouts/atlas/atlasNotFoundLayout.js +5 -3
- package/src/layouts/layout-helper.js +38 -26
- package/src/main.js +15 -0
- package/src/markdownCompiler.js +5 -1
- package/src/menus/MainMenu.js +17 -13
- package/src/menus/RocketMenu.js +25 -19
- package/src/menus/RocketToc.js +7 -5
- package/src/menus/pageNavigation.js +24 -10
- package/src/publicAssets.js +9 -2
- package/src/transform.js +7 -1
- 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
|
+
}
|
package/src/adapters/netlify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/cli/RocketBuild.js
CHANGED
|
@@ -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(
|