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 +21 -0
- package/README.md +218 -0
- package/bin/fastly-spa-yolo.js +9 -0
- package/package.json +33 -0
- package/src/cli.js +789 -0
- package/templates/assets.rs.tmpl +8 -0
- package/templates/compute-main.rs +196 -0
- package/templates/yolo_config.rs.tmpl +3 -0
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,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
|
+
}
|