fastly-spa-yolo 0.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fastly-spa-yolo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # fastly-spa-yolo
2
+
3
+ Deploy a tiny built SPA to Fastly Compute by embedding the whole output folder into the Rust/Wasm package.
4
+
5
+ Bad idea. Works great.
6
+
7
+ This is for prototypes, demos, hackathon apps, vibe-coded experiments, internal previews, and the moment where you already have a `dist/` folder and just need a URL.
8
+
9
+ ## What it does
10
+
11
+ From an existing SPA project, this creates an isolated Fastly Compute Rust project in `.fastly-spa-yolo/`.
12
+
13
+ ```txt
14
+ my-spa/
15
+ package.json
16
+ src/
17
+ dist/
18
+ index.html
19
+ assets/app.abcdef12.js
20
+ assets/app.css
21
+
22
+ .fastly-spa-yolo/
23
+ Cargo.toml
24
+ fastly.toml
25
+ rust-toolchain.toml
26
+ src/
27
+ main.rs
28
+ assets.rs
29
+ yolo_config.rs
30
+ config.json
31
+ ```
32
+
33
+ The generated Rust keeps the Fastly handler in `src/main.rs` and puts the variable generated bits in tiny side files. `src/assets.rs` owns the `rust-embed` folder path:
34
+
35
+ ```rust
36
+ #[derive(Embed)]
37
+ #[folder = "../dist/"]
38
+ pub struct Asset;
39
+ ```
40
+
41
+ Then requests are served directly from the embedded asset map.
42
+
43
+ ```txt
44
+ / -> index.html
45
+ /index.html -> index.html
46
+ /assets/app.abcdef12.js -> embedded JS asset
47
+ /dashboard -> index.html, SPA fallback
48
+ /assets/missing.js -> 404
49
+ /__yolo -> build metadata JSON
50
+ ```
51
+
52
+ ## Install / initialize
53
+
54
+ From an existing SPA project root:
55
+
56
+ ```bash
57
+ npx fastly-spa-yolo init
58
+ ```
59
+
60
+ For no prompts:
61
+
62
+ ```bash
63
+ npx fastly-spa-yolo init --yes
64
+ ```
65
+
66
+ With explicit output folder:
67
+
68
+ ```bash
69
+ npx fastly-spa-yolo init --yes --dist build
70
+ ```
71
+
72
+ ## Generated npm scripts
73
+
74
+ By default, `init` patches your root `package.json` with:
75
+
76
+ ```json
77
+ {
78
+ "scripts": {
79
+ "yolo:init": "npx --yes fastly-spa-yolo init",
80
+ "yolo:build": "npx --yes fastly-spa-yolo build",
81
+ "yolo:serve": "npx --yes fastly-spa-yolo serve",
82
+ "yolo:deploy": "npx --yes fastly-spa-yolo deploy"
83
+ }
84
+ }
85
+ ```
86
+
87
+ Skip that with:
88
+
89
+ ```bash
90
+ npx fastly-spa-yolo init --no-scripts
91
+ ```
92
+
93
+ ## Commands
94
+
95
+ ### `init`
96
+
97
+ Generates `.fastly-spa-yolo/`.
98
+
99
+ ```bash
100
+ npx fastly-spa-yolo init
101
+ ```
102
+
103
+ Useful flags:
104
+
105
+ ```txt
106
+ --yes, -y accept inferred defaults
107
+ --force, -f overwrite generated files
108
+ --dist <dir> SPA build output folder
109
+ --build-command <cmd> app build command
110
+ --name <name> Fastly/Cargo package name
111
+ --compute-dir <dir> generated project dir
112
+ --no-scripts do not patch package.json
113
+ --no-spa-fallback disable unknown route fallback to index.html
114
+ ```
115
+
116
+ ### `build`
117
+
118
+ Runs the app build, checks the dist folder, hashes it, then runs `fastly compute build` inside `.fastly-spa-yolo/`.
119
+
120
+ ```bash
121
+ npm run yolo:build
122
+ ```
123
+
124
+ Pass Fastly CLI args after `--`:
125
+
126
+ ```bash
127
+ npm run yolo:build -- --verbose
128
+ ```
129
+
130
+ ### `serve`
131
+
132
+ Runs the app build, then serves locally through Fastly Compute:
133
+
134
+ ```bash
135
+ npm run yolo:serve
136
+ ```
137
+
138
+ Pass Fastly CLI args after `--`:
139
+
140
+ ```bash
141
+ npm run yolo:serve -- --watch
142
+ ```
143
+
144
+ ### `deploy`
145
+
146
+ Runs the app build, then runs `fastly compute publish` inside `.fastly-spa-yolo/`.
147
+
148
+ ```bash
149
+ npm run yolo:deploy
150
+ ```
151
+
152
+ Non-interactive Fastly defaults:
153
+
154
+ ```bash
155
+ npm run yolo:deploy -- --accept-defaults
156
+ ```
157
+
158
+ ### `hash-dist`
159
+
160
+ Prints the SHA-256 hash of the configured dist folder.
161
+
162
+ ```bash
163
+ npx fastly-spa-yolo hash-dist
164
+ ```
165
+
166
+ ### `doctor`
167
+
168
+ Prints basic environment/project checks.
169
+
170
+ ```bash
171
+ npx fastly-spa-yolo doctor
172
+ ```
173
+
174
+ ## Cache behavior
175
+
176
+ The generated Compute app uses this deliberately simple policy:
177
+
178
+ ```txt
179
+ index.html Cache-Control: no-cache
180
+ assets with hex hashes Cache-Control: public, max-age=31536000, immutable
181
+ other static assets Cache-Control: public, max-age=60
182
+ ```
183
+
184
+ Every response gets:
185
+
186
+ ```txt
187
+ X-Yolo-Build: <dist hash prefix>
188
+ ETag: "yolo-<build>-<path>"
189
+ ```
190
+
191
+ `/__yolo` returns something like:
192
+
193
+ ```json
194
+ {
195
+ "name": "my-app-yolo",
196
+ "build": "a1b2c3d4e5f6",
197
+ "assets": 3,
198
+ "mode": "embedded-spa-yolo"
199
+ }
200
+ ```
201
+
202
+ ## Requirements
203
+
204
+ You need:
205
+
206
+ - Node.js 18+
207
+ - Fastly CLI
208
+ - Rust / Cargo / rustup
209
+
210
+ The generated Compute project pins `wasm32-wasip1` and uses a build script that copies the compiled Wasm to `bin/main.wasm`.
211
+
212
+ ## Caveats
213
+
214
+ This embeds static files into the Compute package. Every asset change means a new Wasm build and Fastly deploy.
215
+
216
+ That is the point.
217
+
218
+ For large sites, serious static hosting, or content-only deploys, use a real static pipeline or Fastly's KV-based static publishing tooling instead.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2)).catch((err) => {
5
+ const message = err && err.message ? err.message : String(err);
6
+ console.error(`fastly-spa-yolo: ${message}`);
7
+ if (err && err.hint) console.error(`hint: ${err.hint}`);
8
+ process.exitCode = err && Number.isInteger(err.exitCode) ? err.exitCode : 1;
9
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "fastly-spa-yolo",
3
+ "version": "0.0.2",
4
+ "description": "YOLO deploy tiny SPA builds to Fastly Compute by embedding dist into Rust/Wasm.",
5
+ "type": "module",
6
+ "bin": {
7
+ "fastly-spa-yolo": "bin/fastly-spa-yolo.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "templates/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test",
18
+ "lint:syntax": "node --check bin/fastly-spa-yolo.js && node --check src/cli.js"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "fastly",
25
+ "compute",
26
+ "spa",
27
+ "static",
28
+ "edge",
29
+ "yolo"
30
+ ],
31
+ "author": "",
32
+ "license": "MIT"
33
+ }
package/src/cli.js ADDED
@@ -0,0 +1,789 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import { createReadStream } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import readline from 'node:readline/promises';
8
+ import process from 'node:process';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
13
+ const TEMPLATE_DIR = path.join(PACKAGE_ROOT, 'templates');
14
+
15
+ const CLI_VERSION = '0.0.2';
16
+ const DEFAULT_COMPUTE_DIR = '.fastly-spa-yolo';
17
+ const CONFIG_FILE = 'config.json';
18
+ const GENERATED_HEADER = 'Generated by fastly-spa-yolo. Hack responsibly.';
19
+
20
+ export async function main(argv = process.argv.slice(2)) {
21
+ const parsed = parseArgv(argv);
22
+
23
+ if (parsed.flags.version || parsed.command === 'version') {
24
+ console.log(CLI_VERSION);
25
+ return;
26
+ }
27
+
28
+ if (parsed.flags.help || parsed.command === 'help' || !parsed.command) {
29
+ printHelp();
30
+ return;
31
+ }
32
+
33
+ switch (parsed.command) {
34
+ case 'init':
35
+ await initCommand(parsed.flags);
36
+ return;
37
+ case 'build':
38
+ await buildCommand(parsed.flags, parsed.passthrough);
39
+ return;
40
+ case 'serve':
41
+ await serveCommand(parsed.flags, parsed.passthrough);
42
+ return;
43
+ case 'deploy':
44
+ case 'publish':
45
+ await deployCommand(parsed.flags, parsed.passthrough);
46
+ return;
47
+ case 'hash-dist':
48
+ case 'hash':
49
+ await hashDistCommand(parsed.flags);
50
+ return;
51
+ case 'doctor':
52
+ await doctorCommand(parsed.flags);
53
+ return;
54
+ default:
55
+ throw usageError(`unknown command: ${parsed.command}`, 'Run: fastly-spa-yolo --help');
56
+ }
57
+ }
58
+
59
+ function printHelp() {
60
+ console.log(`fastly-spa-yolo ${CLI_VERSION}
61
+
62
+ YOLO deploy a tiny SPA to Fastly Compute by embedding the build output into Rust/Wasm.
63
+
64
+ Usage:
65
+ fastly-spa-yolo init [options]
66
+ fastly-spa-yolo build [options] [-- fastly compute build args]
67
+ fastly-spa-yolo serve [options] [-- fastly compute serve args]
68
+ fastly-spa-yolo deploy [options] [-- fastly compute publish args]
69
+ fastly-spa-yolo hash-dist [options]
70
+ fastly-spa-yolo doctor
71
+
72
+ Common init options:
73
+ --yes, -y Accept inferred defaults
74
+ --force, -f Overwrite existing .fastly-spa-yolo files
75
+ --dist <dir> SPA build output folder, default inferred or dist
76
+ --build-command <cmd> App build command, default inferred from package.json
77
+ --name <name> Fastly/Cargo package name, default from package.json
78
+ --compute-dir <dir> Generated Compute project dir, default .fastly-spa-yolo
79
+ --no-scripts Do not patch package.json scripts
80
+ --no-spa-fallback Disable SPA fallback to index.html
81
+
82
+ Common run options:
83
+ --skip-app-build Do not run the app build before Fastly build/serve/deploy
84
+ --compute-dir <dir> Generated Compute project dir
85
+
86
+ Examples:
87
+ npx fastly-spa-yolo init
88
+ npm run yolo:serve
89
+ npm run yolo:deploy -- --accept-defaults
90
+ `);
91
+ }
92
+
93
+ async function initCommand(flags) {
94
+ const root = path.resolve(String(flags.cwd || process.cwd()));
95
+ const pkgPath = path.join(root, 'package.json');
96
+ const pkg = await readJsonIfExists(pkgPath);
97
+
98
+ if (!pkg) {
99
+ throw usageError(
100
+ 'no package.json found in the current directory',
101
+ 'Run fastly-spa-yolo from the root of an existing SPA project.'
102
+ );
103
+ }
104
+
105
+ const packageManager = await inferPackageManager(root, pkg);
106
+ const defaultBuildCommand = inferBuildCommand(pkg, packageManager);
107
+ const defaultDistDir = await inferDistDir(root, pkg);
108
+ const defaultName = sanitizePackageBase(String(pkg.name || path.basename(root) || 'spa')) + '-yolo';
109
+ const defaultVersion = normalizeVersion(pkg.version || '0.0.1');
110
+ const defaultComputeDir = String(flags.computeDir || DEFAULT_COMPUTE_DIR);
111
+
112
+ const yes = Boolean(flags.yes || flags.y);
113
+ const force = Boolean(flags.force || flags.f);
114
+
115
+ let distDir = String(flags.dist || defaultDistDir);
116
+ let buildCommand = String(flags.buildCommand || defaultBuildCommand || 'npm run build');
117
+ let fastlyName = sanitizePackageName(String(flags.name || defaultName));
118
+ let computeDirRel = normalizeRelativeDir(defaultComputeDir);
119
+ let spaFallback = flags.spaFallback !== false;
120
+
121
+ if (!yes) {
122
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
123
+ try {
124
+ distDir = await askText(rl, 'Static output folder', distDir);
125
+ buildCommand = await askText(rl, 'Build command', buildCommand);
126
+ fastlyName = sanitizePackageName(await askText(rl, 'Fastly package name', fastlyName));
127
+ computeDirRel = normalizeRelativeDir(await askText(rl, 'Generated Compute directory', computeDirRel));
128
+ spaFallback = await askYesNo(rl, 'Fallback unknown extensionless routes to index.html', spaFallback);
129
+ } finally {
130
+ rl.close();
131
+ }
132
+ }
133
+
134
+ distDir = normalizeRelativeDir(distDir);
135
+ const computeDir = path.resolve(root, computeDirRel);
136
+
137
+ if (await exists(computeDir)) {
138
+ if (!force) {
139
+ throw usageError(
140
+ `${computeDirRel} already exists`,
141
+ 'Pass --force to overwrite generated files.'
142
+ );
143
+ }
144
+ }
145
+
146
+ const distAbs = path.resolve(root, distDir);
147
+ const embedFolder = toPosixPath(path.relative(computeDir, distAbs));
148
+ const cargoPackageName = sanitizePackageName(fastlyName);
149
+ const cargoVersion = defaultVersion;
150
+
151
+ const config = {
152
+ version: 1,
153
+ generatedBy: `fastly-spa-yolo@${CLI_VERSION}`,
154
+ computeDir: computeDirRel,
155
+ distDir,
156
+ buildCommand,
157
+ fastlyName,
158
+ cargoPackageName,
159
+ cargoVersion,
160
+ spaFallback,
161
+ createdAt: new Date().toISOString()
162
+ };
163
+
164
+ await fs.mkdir(path.join(computeDir, 'src'), { recursive: true });
165
+ await fs.mkdir(path.join(computeDir, 'scripts'), { recursive: true });
166
+ await fs.mkdir(path.join(computeDir, '.cargo'), { recursive: true });
167
+
168
+ await writeGenerated(path.join(computeDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, force);
169
+ await writeGenerated(path.join(computeDir, 'Cargo.toml'), renderCargoToml(config), force);
170
+ await writeGenerated(path.join(computeDir, 'fastly.toml'), renderFastlyToml(config), force);
171
+ await writeGenerated(path.join(computeDir, 'rust-toolchain.toml'), renderRustToolchainToml(), force);
172
+ await writeGenerated(path.join(computeDir, '.cargo', 'config.toml'), renderCargoConfigToml(), force);
173
+ await writeGenerated(path.join(computeDir, 'src', 'main.rs'), await renderMainRs(), force);
174
+ await writeGenerated(path.join(computeDir, 'src', 'assets.rs'), await renderAssetsRs({ embedFolder }), force);
175
+ await writeGenerated(path.join(computeDir, 'src', 'yolo_config.rs'), await renderYoloConfigRs({ spaFallback }), force);
176
+ await writeGenerated(path.join(computeDir, 'scripts', 'copy-wasm.cjs'), renderCopyWasmScript(), force);
177
+ await writeGenerated(path.join(computeDir, '.gitignore'), renderComputeGitignore(), force);
178
+ await writeGenerated(path.join(computeDir, 'README.md'), renderComputeReadme(config), force);
179
+
180
+ if (flags.scripts !== false) {
181
+ await patchPackageJsonScripts(pkgPath, pkg);
182
+ }
183
+
184
+ console.log(`\nfastly-spa-yolo initialized in ${computeDirRel}`);
185
+ console.log(`dist: ${distDir}`);
186
+ console.log(`build: ${buildCommand}`);
187
+ console.log('\nNext:');
188
+ console.log(' npm run yolo:serve');
189
+ console.log(' npm run yolo:deploy');
190
+ }
191
+
192
+ async function buildCommand(flags, passthrough) {
193
+ const ctx = await loadContext(flags);
194
+ await maybeRunAppBuild(ctx, flags);
195
+ const env = await computeBuildEnv(ctx);
196
+ await runFastly(['compute', 'build', ...passthrough], { cwd: ctx.computeDir, env });
197
+ }
198
+
199
+ async function serveCommand(flags, passthrough) {
200
+ const ctx = await loadContext(flags);
201
+ await maybeRunAppBuild(ctx, flags);
202
+ const env = await computeBuildEnv(ctx);
203
+ await runFastly(['compute', 'serve', ...passthrough], { cwd: ctx.computeDir, env });
204
+ }
205
+
206
+ async function deployCommand(flags, passthrough) {
207
+ const ctx = await loadContext(flags);
208
+ await maybeRunAppBuild(ctx, flags);
209
+ const env = await computeBuildEnv(ctx);
210
+ await runFastly(['compute', 'publish', ...passthrough], { cwd: ctx.computeDir, env });
211
+ }
212
+
213
+ async function hashDistCommand(flags) {
214
+ let distAbs;
215
+
216
+ if (flags.dist) {
217
+ distAbs = path.resolve(String(flags.cwd || process.cwd()), String(flags.dist));
218
+ } else {
219
+ const ctx = await loadContext(flags);
220
+ distAbs = path.resolve(ctx.root, ctx.config.distDir);
221
+ }
222
+
223
+ await assertDistReady(distAbs);
224
+ console.log(await hashDirectory(distAbs));
225
+ }
226
+
227
+ async function doctorCommand(flags) {
228
+ const root = path.resolve(String(flags.cwd || process.cwd()));
229
+ const pkg = await readJsonIfExists(path.join(root, 'package.json'));
230
+
231
+ console.log('fastly-spa-yolo doctor');
232
+ console.log(`root: ${root}`);
233
+ console.log(`package.json: ${pkg ? 'ok' : 'missing'}`);
234
+
235
+ const fastly = commandVersion('fastly', ['--version']);
236
+ const cargo = commandVersion('cargo', ['--version']);
237
+ const rustup = commandVersion('rustup', ['--version']);
238
+
239
+ console.log(`fastly: ${fastly.ok ? fastly.version : 'missing'}`);
240
+ console.log(`cargo: ${cargo.ok ? cargo.version : 'missing'}`);
241
+ console.log(`rustup: ${rustup.ok ? rustup.version : 'missing'}`);
242
+
243
+ try {
244
+ const ctx = await loadContext(flags);
245
+ const distAbs = path.resolve(ctx.root, ctx.config.distDir);
246
+ const distOk = await exists(distAbs);
247
+ const indexOk = await exists(path.join(distAbs, 'index.html'));
248
+ console.log(`compute dir: ${ctx.config.computeDir}`);
249
+ console.log(`dist dir: ${ctx.config.distDir} ${distOk ? 'ok' : 'missing'}`);
250
+ console.log(`index.html: ${indexOk ? 'ok' : 'missing'}`);
251
+ } catch (err) {
252
+ console.log('config: missing; run fastly-spa-yolo init');
253
+ }
254
+ }
255
+
256
+ async function loadContext(flags) {
257
+ const root = path.resolve(String(flags.cwd || process.cwd()));
258
+ const computeDirRel = normalizeRelativeDir(String(flags.computeDir || DEFAULT_COMPUTE_DIR));
259
+ const computeDir = path.resolve(root, computeDirRel);
260
+ const configPath = path.join(computeDir, CONFIG_FILE);
261
+ const config = await readJsonIfExists(configPath);
262
+
263
+ if (!config) {
264
+ throw usageError(
265
+ `no ${CONFIG_FILE} found in ${computeDirRel}`,
266
+ 'Run fastly-spa-yolo init first.'
267
+ );
268
+ }
269
+
270
+ return { root, computeDir, configPath, config };
271
+ }
272
+
273
+ async function maybeRunAppBuild(ctx, flags) {
274
+ if (flags.skipAppBuild) {
275
+ const distAbs = path.resolve(ctx.root, ctx.config.distDir);
276
+ await assertDistReady(distAbs);
277
+ return;
278
+ }
279
+
280
+ const command = String(ctx.config.buildCommand || '').trim();
281
+ if (!command) {
282
+ throw usageError('no build command configured', `Edit ${ctx.config.computeDir}/${CONFIG_FILE}.`);
283
+ }
284
+
285
+ console.log(`\n$ ${command}`);
286
+ await runShell(command, { cwd: ctx.root, env: process.env });
287
+
288
+ const distAbs = path.resolve(ctx.root, ctx.config.distDir);
289
+ await assertDistReady(distAbs);
290
+ }
291
+
292
+ async function computeBuildEnv(ctx) {
293
+ const distAbs = path.resolve(ctx.root, ctx.config.distDir);
294
+ await assertDistReady(distAbs);
295
+
296
+ const hash = await hashDirectory(distAbs);
297
+ const shortHash = hash.slice(0, 12);
298
+
299
+ return {
300
+ ...process.env,
301
+ FASTLY_SPA_YOLO_BUILD_ID: shortHash,
302
+ FASTLY_SPA_YOLO_DIST_HASH: hash,
303
+ FASTLY_SPA_YOLO_APP_NAME: ctx.config.fastlyName || 'fastly-spa-yolo'
304
+ };
305
+ }
306
+
307
+ async function assertDistReady(distAbs) {
308
+ if (!(await exists(distAbs))) {
309
+ throw usageError(`dist folder not found: ${distAbs}`, 'Run your SPA build first, or update .fastly-spa-yolo/config.json.');
310
+ }
311
+
312
+ const indexPath = path.join(distAbs, 'index.html');
313
+ if (!(await exists(indexPath))) {
314
+ throw usageError(`index.html not found in ${distAbs}`, 'fastly-spa-yolo expects an SPA build folder with index.html.');
315
+ }
316
+ }
317
+
318
+ async function patchPackageJsonScripts(pkgPath, pkg) {
319
+ pkg.scripts ||= {};
320
+
321
+ const additions = {
322
+ 'yolo:init': 'npx --yes fastly-spa-yolo init',
323
+ 'yolo:build': 'npx --yes fastly-spa-yolo build',
324
+ 'yolo:serve': 'npx --yes fastly-spa-yolo serve',
325
+ 'yolo:deploy': 'npx --yes fastly-spa-yolo deploy'
326
+ };
327
+
328
+ let changed = false;
329
+ for (const [name, value] of Object.entries(additions)) {
330
+ if (!pkg.scripts[name]) {
331
+ pkg.scripts[name] = value;
332
+ changed = true;
333
+ }
334
+ }
335
+
336
+ if (changed) {
337
+ await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
338
+ console.log('patched package.json scripts');
339
+ }
340
+ }
341
+
342
+ async function inferPackageManager(root, pkg) {
343
+ const declared = typeof pkg.packageManager === 'string' ? pkg.packageManager : '';
344
+ if (declared.startsWith('pnpm@')) return 'pnpm';
345
+ if (declared.startsWith('yarn@')) return 'yarn';
346
+ if (declared.startsWith('bun@')) return 'bun';
347
+ if (declared.startsWith('npm@')) return 'npm';
348
+
349
+ if (await exists(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
350
+ if (await exists(path.join(root, 'yarn.lock'))) return 'yarn';
351
+ if (await exists(path.join(root, 'bun.lockb')) || await exists(path.join(root, 'bun.lock'))) return 'bun';
352
+ return 'npm';
353
+ }
354
+
355
+ function inferBuildCommand(pkg, packageManager) {
356
+ if (pkg.scripts && typeof pkg.scripts.build === 'string') {
357
+ switch (packageManager) {
358
+ case 'pnpm': return 'pnpm run build';
359
+ case 'yarn': return 'yarn build';
360
+ case 'bun': return 'bun run build';
361
+ default: return 'npm run build';
362
+ }
363
+ }
364
+
365
+ return '';
366
+ }
367
+
368
+ async function inferDistDir(root, pkg) {
369
+ const candidatesWithIndex = ['dist', 'build', 'out', 'public', 'docs'];
370
+ for (const candidate of candidatesWithIndex) {
371
+ if (await exists(path.join(root, candidate, 'index.html'))) return candidate;
372
+ }
373
+
374
+ const candidatesExisting = ['dist', 'build', 'out'];
375
+ for (const candidate of candidatesExisting) {
376
+ if (await exists(path.join(root, candidate))) return candidate;
377
+ }
378
+
379
+ const deps = {
380
+ ...pkg.dependencies,
381
+ ...pkg.devDependencies
382
+ };
383
+
384
+ const buildScript = String(pkg.scripts?.build || '').toLowerCase();
385
+
386
+ if (deps['react-scripts'] || buildScript.includes('react-scripts build')) return 'build';
387
+ if (deps.next || buildScript.includes('next export')) return 'out';
388
+
389
+ return 'dist';
390
+ }
391
+
392
+ function renderCargoToml(config) {
393
+ return `# ${GENERATED_HEADER}
394
+
395
+ [package]
396
+ name = ${tomlString(config.cargoPackageName)}
397
+ version = ${tomlString(config.cargoVersion)}
398
+ edition = "2021"
399
+ publish = false
400
+
401
+ [dependencies]
402
+ fastly = "0.12"
403
+ mime_guess = "2"
404
+ rust-embed = { version = "8", features = ["debug-embed"] }
405
+ `;
406
+ }
407
+
408
+ function renderFastlyToml(config) {
409
+ const wasmSource = `target/wasm32-wasip1/release/${config.cargoPackageName}.wasm`;
410
+ const buildCommand = `cargo build --bin ${config.cargoPackageName} --release --target wasm32-wasip1 --color always && node scripts/copy-wasm.cjs ${wasmSource} bin/main.wasm`;
411
+
412
+ return `# ${GENERATED_HEADER}
413
+
414
+ manifest_version = 3
415
+ name = ${tomlString(config.fastlyName)}
416
+ description = "YOLO embedded SPA on Fastly Compute"
417
+ language = "rust"
418
+ authors = []
419
+
420
+ [scripts]
421
+ build = ${tomlString(buildCommand)}
422
+ `;
423
+ }
424
+
425
+ function renderRustToolchainToml() {
426
+ return `# ${GENERATED_HEADER}
427
+ # Pinned away from known-bad future/current Fastly compatibility bumps.
428
+
429
+ [toolchain]
430
+ channel = "1.90.0"
431
+ targets = ["wasm32-wasip1"]
432
+ `;
433
+ }
434
+
435
+ function renderCargoConfigToml() {
436
+ return `# ${GENERATED_HEADER}
437
+
438
+ [build]
439
+ target = "wasm32-wasip1"
440
+ `;
441
+ }
442
+
443
+ function renderCopyWasmScript() {
444
+ return `#!/usr/bin/env node
445
+ const fs = require('node:fs');
446
+ const path = require('node:path');
447
+
448
+ const [source, dest] = process.argv.slice(2);
449
+ if (!source || !dest) {
450
+ console.error('usage: node scripts/copy-wasm.cjs <source.wasm> <dest.wasm>');
451
+ process.exit(2);
452
+ }
453
+
454
+ if (!fs.existsSync(source)) {
455
+ console.error(` + '`wasm source not found: ${source}`' + `);
456
+ process.exit(1);
457
+ }
458
+
459
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
460
+ fs.copyFileSync(source, dest);
461
+ console.log(` + '`copied ${source} -> ${dest}`' + `);
462
+ `;
463
+ }
464
+
465
+ function renderComputeGitignore() {
466
+ return `target/
467
+ bin/
468
+ pkg/
469
+ `;
470
+ }
471
+
472
+ function renderComputeReadme(config) {
473
+ return `# ${config.fastlyName}
474
+
475
+ Generated by fastly-spa-yolo.
476
+
477
+ This Compute project embeds \`${config.distDir}/\` from the parent SPA project into the Rust/Wasm binary.
478
+
479
+ Run from the project root:
480
+
481
+ \`\`\`bash
482
+ npm run yolo:serve
483
+ npm run yolo:deploy
484
+ \`\`\`
485
+
486
+ This is intentionally a YOLO prototype path, not a general static hosting platform.
487
+ `;
488
+ }
489
+
490
+ async function renderMainRs() {
491
+ return readTemplate('compute-main.rs');
492
+ }
493
+
494
+ async function renderAssetsRs({ embedFolder }) {
495
+ const folder = ensureTrailingSlash(toPosixPath(embedFolder));
496
+ return renderTemplate('assets.rs.tmpl', {
497
+ FASTLY_SPA_YOLO_EMBED_FOLDER: folder
498
+ });
499
+ }
500
+
501
+ async function renderYoloConfigRs({ spaFallback }) {
502
+ return renderTemplate('yolo_config.rs.tmpl', {
503
+ FASTLY_SPA_YOLO_SPA_FALLBACK: spaFallback ? 'true' : 'false'
504
+ });
505
+ }
506
+
507
+ async function renderTemplate(name, replacements = {}) {
508
+ let content = await readTemplate(name);
509
+
510
+ for (const [key, value] of Object.entries(replacements)) {
511
+ content = content.replaceAll(`__${key}__`, String(value));
512
+ }
513
+
514
+ return content;
515
+ }
516
+
517
+ async function readTemplate(name) {
518
+ return fs.readFile(path.join(TEMPLATE_DIR, name), 'utf8');
519
+ }
520
+
521
+ async function hashDirectory(dir) {
522
+ const files = await listFilesRecursive(dir);
523
+ const hash = createHash('sha256');
524
+
525
+ for (const file of files.sort()) {
526
+ const abs = path.join(dir, file);
527
+ hash.update(file.replaceAll('\\\\', '/'));
528
+ hash.update('\0');
529
+ await hashFileInto(abs, hash);
530
+ hash.update('\0');
531
+ }
532
+
533
+ return hash.digest('hex');
534
+ }
535
+
536
+ async function listFilesRecursive(root, dir = root, out = []) {
537
+ const entries = await fs.readdir(dir, { withFileTypes: true });
538
+
539
+ for (const entry of entries) {
540
+ const abs = path.join(dir, entry.name);
541
+ if (entry.isDirectory()) {
542
+ await listFilesRecursive(root, abs, out);
543
+ } else if (entry.isFile()) {
544
+ out.push(toPosixPath(path.relative(root, abs)));
545
+ }
546
+ }
547
+
548
+ return out;
549
+ }
550
+
551
+ function hashFileInto(file, hash) {
552
+ return new Promise((resolve, reject) => {
553
+ const stream = createReadStream(file);
554
+ stream.on('data', (chunk) => hash.update(chunk));
555
+ stream.on('error', reject);
556
+ stream.on('end', resolve);
557
+ });
558
+ }
559
+
560
+ function runShell(command, { cwd, env }) {
561
+ return new Promise((resolve, reject) => {
562
+ const child = spawn(command, {
563
+ cwd,
564
+ env,
565
+ stdio: 'inherit',
566
+ shell: true
567
+ });
568
+
569
+ child.on('error', reject);
570
+ child.on('exit', (code, signal) => {
571
+ if (code === 0) resolve();
572
+ else reject(commandError(`command failed: ${command}${signal ? ` (${signal})` : ''}`, code ?? 1));
573
+ });
574
+ });
575
+ }
576
+
577
+ function runFastly(args, { cwd, env }) {
578
+ console.log(`\n$ fastly ${args.join(' ')}`);
579
+
580
+ return new Promise((resolve, reject) => {
581
+ const child = spawn('fastly', args, {
582
+ cwd,
583
+ env,
584
+ stdio: 'inherit',
585
+ shell: process.platform === 'win32'
586
+ });
587
+
588
+ child.on('error', (err) => {
589
+ if (err.code === 'ENOENT') {
590
+ reject(usageError('Fastly CLI not found on PATH', 'Install the Fastly CLI, then run this command again.'));
591
+ } else {
592
+ reject(err);
593
+ }
594
+ });
595
+
596
+ child.on('exit', (code, signal) => {
597
+ if (code === 0) resolve();
598
+ else reject(commandError(`fastly ${args.join(' ')} failed${signal ? ` (${signal})` : ''}`, code ?? 1));
599
+ });
600
+ });
601
+ }
602
+
603
+ async function writeGenerated(file, content, force) {
604
+ if (!force && await exists(file)) {
605
+ throw usageError(`refusing to overwrite ${file}`, 'Pass --force to regenerate.');
606
+ }
607
+ await fs.writeFile(file, content);
608
+ if (file.endsWith('copy-wasm.cjs')) {
609
+ await fs.chmod(file, 0o755);
610
+ }
611
+ }
612
+
613
+ async function readJsonIfExists(file) {
614
+ try {
615
+ const raw = await fs.readFile(file, 'utf8');
616
+ return JSON.parse(raw);
617
+ } catch (err) {
618
+ if (err.code === 'ENOENT') return null;
619
+ throw err;
620
+ }
621
+ }
622
+
623
+ async function exists(file) {
624
+ try {
625
+ await fs.access(file);
626
+ return true;
627
+ } catch {
628
+ return false;
629
+ }
630
+ }
631
+
632
+ async function askText(rl, question, defaultValue) {
633
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
634
+ const answer = await rl.question(`${question}${suffix}: `);
635
+ return answer.trim() || defaultValue || '';
636
+ }
637
+
638
+ async function askYesNo(rl, question, defaultValue) {
639
+ const suffix = defaultValue ? ' [Y/n]' : ' [y/N]';
640
+ const answer = (await rl.question(`${question}${suffix}: `)).trim().toLowerCase();
641
+ if (!answer) return defaultValue;
642
+ return ['y', 'yes', 'true', '1'].includes(answer);
643
+ }
644
+
645
+ function parseArgv(argv) {
646
+ const dashDash = argv.indexOf('--');
647
+ const ownArgs = dashDash === -1 ? argv : argv.slice(0, dashDash);
648
+ const passthrough = dashDash === -1 ? [] : argv.slice(dashDash + 1);
649
+ const positionals = [];
650
+ const flags = {};
651
+
652
+ for (let i = 0; i < ownArgs.length; i += 1) {
653
+ const arg = ownArgs[i];
654
+
655
+ if (arg === '-y') {
656
+ flags.yes = true;
657
+ continue;
658
+ }
659
+ if (arg === '-f') {
660
+ flags.force = true;
661
+ continue;
662
+ }
663
+ if (arg === '-h') {
664
+ flags.help = true;
665
+ continue;
666
+ }
667
+
668
+ if (arg.startsWith('--')) {
669
+ const raw = arg.slice(2);
670
+
671
+ if (raw.startsWith('no-')) {
672
+ flags[toCamel(raw.slice(3))] = false;
673
+ continue;
674
+ }
675
+
676
+ const eq = raw.indexOf('=');
677
+ if (eq !== -1) {
678
+ flags[toCamel(raw.slice(0, eq))] = raw.slice(eq + 1);
679
+ continue;
680
+ }
681
+
682
+ const name = toCamel(raw);
683
+ const next = ownArgs[i + 1];
684
+ if (next && !next.startsWith('-')) {
685
+ flags[name] = next;
686
+ i += 1;
687
+ } else {
688
+ flags[name] = true;
689
+ }
690
+ continue;
691
+ }
692
+
693
+ positionals.push(arg);
694
+ }
695
+
696
+ return {
697
+ command: positionals[0] || '',
698
+ args: positionals.slice(1),
699
+ flags,
700
+ passthrough
701
+ };
702
+ }
703
+
704
+ function toCamel(flag) {
705
+ return flag.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
706
+ }
707
+
708
+ function normalizeRelativeDir(value) {
709
+ const clean = String(value || '').trim();
710
+ if (!clean) return '.';
711
+ if (path.isAbsolute(clean)) return toPosixPath(path.relative(process.cwd(), clean));
712
+ return toPosixPath(clean.replace(/^\.\//, ''));
713
+ }
714
+
715
+ function sanitizePackageBase(name) {
716
+ let base = String(name || '')
717
+ .replace(/^@[^/]+\//, '')
718
+ .toLowerCase()
719
+ .replace(/[^a-z0-9_-]+/g, '-')
720
+ .replace(/_+/g, '-')
721
+ .replace(/-+/g, '-')
722
+ .replace(/^-|-$/g, '');
723
+
724
+ if (!base) base = 'spa';
725
+ return base.slice(0, 54).replace(/-$/g, '') || 'spa';
726
+ }
727
+
728
+ function sanitizePackageName(name) {
729
+ let out = sanitizePackageBase(name);
730
+ if (/^[0-9]/.test(out)) out = `spa-${out}`;
731
+ return out;
732
+ }
733
+
734
+ function normalizeVersion(version) {
735
+ const raw = String(version || '').replace(/^v/, '').trim();
736
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(raw) ? raw : '0.0.1';
737
+ }
738
+
739
+ function tomlString(value) {
740
+ return JSON.stringify(String(value));
741
+ }
742
+
743
+ function rustString(value) {
744
+ return JSON.stringify(String(value));
745
+ }
746
+
747
+ function ensureTrailingSlash(value) {
748
+ return value.endsWith('/') ? value : `${value}/`;
749
+ }
750
+
751
+ function toPosixPath(value) {
752
+ return String(value).replaceAll(path.sep, '/').replaceAll('\\\\', '/');
753
+ }
754
+
755
+ function usageError(message, hint) {
756
+ const err = new Error(message);
757
+ err.exitCode = 2;
758
+ err.hint = hint;
759
+ return err;
760
+ }
761
+
762
+ function commandError(message, exitCode) {
763
+ const err = new Error(message);
764
+ err.exitCode = exitCode;
765
+ return err;
766
+ }
767
+
768
+ function commandVersion(command, args) {
769
+ const result = spawnSync(command, args, {
770
+ encoding: 'utf8',
771
+ shell: process.platform === 'win32'
772
+ });
773
+
774
+ if (result.error || result.status !== 0) return { ok: false, version: '' };
775
+ return { ok: true, version: String(result.stdout || result.stderr).trim().split('\n')[0] };
776
+ }
777
+
778
+ // Exported for the tiny node:test smoke tests.
779
+ export const internals = {
780
+ parseArgv,
781
+ sanitizePackageBase,
782
+ sanitizePackageName,
783
+ normalizeVersion,
784
+ inferBuildCommand,
785
+ hashDirectory,
786
+ renderMainRs,
787
+ renderAssetsRs,
788
+ renderYoloConfigRs
789
+ };
@@ -0,0 +1,8 @@
1
+ // Generated by fastly-spa-yolo. Hack responsibly.
2
+ // This is the only Rust file with the generated #[folder = "..."] path.
3
+
4
+ use rust_embed::Embed;
5
+
6
+ #[derive(Embed)]
7
+ #[folder = "__FASTLY_SPA_YOLO_EMBED_FOLDER__"]
8
+ pub struct Asset;
@@ -0,0 +1,196 @@
1
+ // Generated by fastly-spa-yolo. Hack responsibly.
2
+ // This file is intentionally boring. The entire SPA is embedded by rust-embed.
3
+
4
+ mod assets;
5
+ mod yolo_config;
6
+
7
+ use assets::Asset;
8
+ use fastly::http::{header, Method, StatusCode};
9
+ use fastly::{Error, Request, Response};
10
+ use mime_guess::from_path;
11
+ use rust_embed::{EmbeddedFile, RustEmbed};
12
+
13
+ #[fastly::main]
14
+ fn main(req: Request) -> Result<Response, Error> {
15
+ let method = req.get_method().clone();
16
+
17
+ if method != Method::GET && method != Method::HEAD {
18
+ return Ok(Response::from_status(StatusCode::METHOD_NOT_ALLOWED)
19
+ .with_header(header::ALLOW, "GET, HEAD")
20
+ .with_body("method not allowed"));
21
+ }
22
+
23
+ if req.get_path() == "/__yolo" {
24
+ return Ok(yolo_response(&method));
25
+ }
26
+
27
+ let Some(path) = normalize_path(req.get_path()) else {
28
+ return Ok(Response::from_status(StatusCode::BAD_REQUEST).with_body("bad path"));
29
+ };
30
+
31
+ let Some((served_path, file)) = resolve_asset(&path) else {
32
+ return Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("not found"));
33
+ };
34
+
35
+ Ok(asset_response(&method, &served_path, file))
36
+ }
37
+
38
+ fn asset_response(method: &Method, served_path: &str, file: EmbeddedFile) -> Response {
39
+ let content_len = file.data.len();
40
+ let content_type = content_type_for(served_path);
41
+ let cache_control = cache_control_for(served_path);
42
+ let etag = etag_for(served_path);
43
+
44
+ let mut resp = Response::from_status(StatusCode::OK)
45
+ .with_header(header::CONTENT_TYPE, content_type)
46
+ .with_header(header::CACHE_CONTROL, cache_control)
47
+ .with_header(header::CONTENT_LENGTH, content_len.to_string())
48
+ .with_header(header::ETAG, etag)
49
+ .with_header("X-Yolo-Build", build_id());
50
+
51
+ if method == &Method::GET {
52
+ resp = resp.with_body(file.data.into_owned());
53
+ }
54
+
55
+ resp
56
+ }
57
+
58
+ fn yolo_response(method: &Method) -> Response {
59
+ let body = format!(
60
+ "{{\"name\":\"{}\",\"build\":\"{}\",\"assets\":{},\"mode\":\"embedded-spa-yolo\"}}",
61
+ json_escape(app_name()),
62
+ json_escape(build_id()),
63
+ Asset::iter().count()
64
+ );
65
+
66
+ let mut resp = Response::from_status(StatusCode::OK)
67
+ .with_header(header::CONTENT_TYPE, "application/json; charset=utf-8")
68
+ .with_header(header::CACHE_CONTROL, "no-cache")
69
+ .with_header(header::CONTENT_LENGTH, body.len().to_string())
70
+ .with_header("X-Yolo-Build", build_id());
71
+
72
+ if method == &Method::GET {
73
+ resp = resp.with_body(body);
74
+ }
75
+
76
+ resp
77
+ }
78
+
79
+ fn normalize_path(raw: &str) -> Option<String> {
80
+ if raw.is_empty() || raw == "/" {
81
+ return Some("index.html".to_string());
82
+ }
83
+
84
+ if raw.contains('\\') {
85
+ return None;
86
+ }
87
+
88
+ let raw = raw.trim_start_matches('/');
89
+ let mut parts = Vec::new();
90
+
91
+ for part in raw.split('/') {
92
+ match part {
93
+ "" | "." => {}
94
+ ".." => return None,
95
+ other => parts.push(other),
96
+ }
97
+ }
98
+
99
+ if parts.is_empty() {
100
+ Some("index.html".to_string())
101
+ } else {
102
+ Some(parts.join("/"))
103
+ }
104
+ }
105
+
106
+ fn resolve_asset(path: &str) -> Option<(String, EmbeddedFile)> {
107
+ if let Some(file) = Asset::get(path) {
108
+ return Some((path.to_string(), file));
109
+ }
110
+
111
+ let dir_index = format!("{}/index.html", path.trim_end_matches('/'));
112
+ if let Some(file) = Asset::get(&dir_index) {
113
+ return Some((dir_index, file));
114
+ }
115
+
116
+ if yolo_config::SPA_FALLBACK && looks_like_spa_route(path) {
117
+ return Asset::get("index.html").map(|file| ("index.html".to_string(), file));
118
+ }
119
+
120
+ None
121
+ }
122
+
123
+ fn looks_like_spa_route(path: &str) -> bool {
124
+ path.rsplit('/')
125
+ .next()
126
+ .map(|last| !last.contains('.'))
127
+ .unwrap_or(true)
128
+ }
129
+
130
+ fn content_type_for(path: &str) -> String {
131
+ let mime = from_path(path).first_or_octet_stream();
132
+
133
+ match mime.essence_str() {
134
+ "text/html" => "text/html; charset=utf-8".to_string(),
135
+ "text/css" => "text/css; charset=utf-8".to_string(),
136
+ "text/javascript" | "application/javascript" => "application/javascript; charset=utf-8".to_string(),
137
+ "application/json" => "application/json; charset=utf-8".to_string(),
138
+ "image/svg+xml" => "image/svg+xml; charset=utf-8".to_string(),
139
+ other => other.to_string(),
140
+ }
141
+ }
142
+
143
+ fn cache_control_for(path: &str) -> &'static str {
144
+ if path == "index.html" || path.ends_with("/index.html") {
145
+ "no-cache"
146
+ } else if looks_hashed_asset(path) {
147
+ "public, max-age=31536000, immutable"
148
+ } else {
149
+ "public, max-age=60"
150
+ }
151
+ }
152
+
153
+ fn looks_hashed_asset(path: &str) -> bool {
154
+ let filename = path.rsplit('/').next().unwrap_or(path);
155
+ filename
156
+ .split('.')
157
+ .any(|part| part.len() >= 8 && part.chars().all(|ch| ch.is_ascii_hexdigit()))
158
+ }
159
+
160
+ fn etag_for(path: &str) -> String {
161
+ let safe_path: String = path
162
+ .chars()
163
+ .map(|ch| {
164
+ if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
165
+ ch
166
+ } else {
167
+ '_'
168
+ }
169
+ })
170
+ .collect();
171
+
172
+ format!("\"yolo-{}-{}\"", build_id(), safe_path)
173
+ }
174
+
175
+ fn app_name() -> &'static str {
176
+ option_env!("FASTLY_SPA_YOLO_APP_NAME").unwrap_or("fastly-spa-yolo")
177
+ }
178
+
179
+ fn build_id() -> &'static str {
180
+ option_env!("FASTLY_SPA_YOLO_BUILD_ID").unwrap_or("dev")
181
+ }
182
+
183
+ fn json_escape(value: &str) -> String {
184
+ let mut out = String::new();
185
+ for ch in value.chars() {
186
+ match ch {
187
+ '"' => out.push_str("\\\""),
188
+ '\\' => out.push_str("\\\\"),
189
+ '\n' => out.push_str("\\n"),
190
+ '\r' => out.push_str("\\r"),
191
+ '\t' => out.push_str("\\t"),
192
+ other => out.push(other),
193
+ }
194
+ }
195
+ out
196
+ }
@@ -0,0 +1,3 @@
1
+ // Generated by fastly-spa-yolo. Hack responsibly.
2
+
3
+ pub const SPA_FALLBACK: bool = __FASTLY_SPA_YOLO_SPA_FALLBACK__;