@selvajs/cli 4.9.0 → 4.9.1
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/package.json +1 -1
- package/src/caddyfile.js +4 -1
- package/src/checks/config.js +67 -0
- package/src/commands/doctor.js +13 -1
- package/src/env.js +40 -2
package/package.json
CHANGED
package/src/caddyfile.js
CHANGED
|
@@ -45,8 +45,11 @@ export function renderCaddyfile({ domain, acmeEmail, port = 3000 }) {
|
|
|
45
45
|
@api path /api/*
|
|
46
46
|
header @api Cache-Control "no-cache, no-store, must-revalidate"
|
|
47
47
|
|
|
48
|
+
# Must clear the app's own caps (COMPUTE_REQUEST_MAX_BYTES / BODY_SIZE_LIMIT,
|
|
49
|
+
# both 256 MB) — Caddy rejects first, so a lower value here silently caps
|
|
50
|
+
# every upload no matter what .env says.
|
|
48
51
|
request_body {
|
|
49
|
-
max_size
|
|
52
|
+
max_size 256mb
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
log {
|
package/src/checks/config.js
CHANGED
|
@@ -165,6 +165,73 @@ export function checkEnvDocumentation(commentLines, fix) {
|
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Parse a `BODY_SIZE_LIMIT` the way adapter-node does, so this check agrees with
|
|
170
|
+
* the thing that actually rejects the request.
|
|
171
|
+
*
|
|
172
|
+
* The quirk is load-bearing: adapter-node reads only the LAST character as the
|
|
173
|
+
* suffix, so `60mb` parses as the digits `60m` and happens to mean 60 MB, while
|
|
174
|
+
* `60xy` silently becomes NaN. Returns bytes, or `null` when the value is one
|
|
175
|
+
* adapter-node can't use.
|
|
176
|
+
*/
|
|
177
|
+
export function parseBodySizeLimit(raw) {
|
|
178
|
+
if (raw == null || raw === '') return null;
|
|
179
|
+
const value = String(raw).trim();
|
|
180
|
+
if (value === '') return null;
|
|
181
|
+
|
|
182
|
+
// Mirrors adapter-node's parse_as_bytes (files/utils.js): only K/M/G count as
|
|
183
|
+
// units, and the rest goes through `Number`, which is strict — "256mb" leaves
|
|
184
|
+
// a trailing "m" and yields NaN, so adapter-node throws on boot.
|
|
185
|
+
const units = { K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 };
|
|
186
|
+
const multiplier = units[value.at(-1).toUpperCase()] ?? 1;
|
|
187
|
+
const bytes = Number(multiplier !== 1 ? value.slice(0, -1) : value) * multiplier;
|
|
188
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
|
189
|
+
return bytes;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The app's own default for COMPUTE_REQUEST_MAX_BYTES (packages/server
|
|
193
|
+
// compute/limits.ts). Duplicated rather than imported: the CLI installs the
|
|
194
|
+
// runtime and cannot import from it.
|
|
195
|
+
const COMPUTE_REQUEST_DEFAULT_BYTES = 256 * 1024 * 1024;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* BODY_SIZE_LIMIT has to clear the compute request cap, or adapter-node's global
|
|
199
|
+
* backstop rejects the upload first — with a non-JSON body the app never sees
|
|
200
|
+
* and cannot log. Unset is the dangerous case, not a neutral one: adapter-node
|
|
201
|
+
* falls back to 512 KB, so every upload 413s on a deployment that looks fine.
|
|
202
|
+
*/
|
|
203
|
+
export function checkBodySizeLimit(env) {
|
|
204
|
+
const raw = env.BODY_SIZE_LIMIT;
|
|
205
|
+
const requestCap = Number(env.COMPUTE_REQUEST_MAX_BYTES) || COMPUTE_REQUEST_DEFAULT_BYTES;
|
|
206
|
+
const asMb = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
207
|
+
|
|
208
|
+
if (raw == null || raw === '') {
|
|
209
|
+
return red(
|
|
210
|
+
`BODY_SIZE_LIMIT unset — adapter-node falls back to 512 KB, so uploads over ` +
|
|
211
|
+
`that fail with an opaque 413 no app log records. Set it to at least ` +
|
|
212
|
+
`${asMb(requestCap)} (the compute request cap).`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const bytes = parseBodySizeLimit(raw);
|
|
217
|
+
if (bytes === null) {
|
|
218
|
+
return red(
|
|
219
|
+
`BODY_SIZE_LIMIT="${raw}" — adapter-node cannot parse this and throws on boot. ` +
|
|
220
|
+
`Use a byte count or a single K/M/G suffix, e.g. "256M".`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (bytes < requestCap) {
|
|
225
|
+
return yellow(
|
|
226
|
+
`BODY_SIZE_LIMIT=${raw} (${asMb(bytes)}) is below COMPUTE_REQUEST_MAX_BYTES ` +
|
|
227
|
+
`(${asMb(requestCap)}) — the global cap rejects first, making the compute cap ` +
|
|
228
|
+
`dead config. Raise it to at least ${asMb(requestCap)}.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return green(`BODY_SIZE_LIMIT=${raw}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
168
235
|
// A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
|
|
169
236
|
// encodes a value in the new name, so migrate leaves it alone rather than
|
|
170
237
|
// guessing — it's reported here and nowhere else.
|
package/src/commands/doctor.js
CHANGED
|
@@ -27,6 +27,7 @@ import { green, yellow, red, fixable } from '../checks/result.js';
|
|
|
27
27
|
import { checkBootPersistence } from '../checks/boot.js';
|
|
28
28
|
import { checkRuntimeEnvironment } from '../checks/runtime.js';
|
|
29
29
|
import {
|
|
30
|
+
checkBodySizeLimit,
|
|
30
31
|
checkDeprecatedEnv,
|
|
31
32
|
checkEnvDocumentation,
|
|
32
33
|
checkHeaderNames,
|
|
@@ -82,6 +83,7 @@ export async function runDoctor(argv = []) {
|
|
|
82
83
|
checks.push(...checkRuntimeEnvironment(dir));
|
|
83
84
|
checks.push(...checkBootPersistence(dir));
|
|
84
85
|
checks.push(checkOrigin(env));
|
|
86
|
+
checks.push(checkBodySizeLimit(env));
|
|
85
87
|
checks.push(...checkDeprecatedEnv(env));
|
|
86
88
|
checks.push(checkEnvDocs(dir));
|
|
87
89
|
|
|
@@ -174,7 +176,7 @@ function checkDataPath(dir, dataPath) {
|
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
async function checkSupabase(env) {
|
|
179
|
+
export async function checkSupabase(env) {
|
|
178
180
|
if (!env.SUPABASE_URL) return red('SUPABASE_URL unset');
|
|
179
181
|
if (!env.SUPABASE_ANON_KEY) return red('SUPABASE_ANON_KEY unset');
|
|
180
182
|
if (!env.SUPABASE_SERVICE_ROLE_KEY) return red('SUPABASE_SERVICE_ROLE_KEY unset');
|
|
@@ -189,11 +191,21 @@ async function checkSupabase(env) {
|
|
|
189
191
|
try {
|
|
190
192
|
const controller = new AbortController();
|
|
191
193
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
194
|
+
// GoTrue rejects an unauthenticated request to its own health endpoint, so
|
|
195
|
+
// without the key a perfectly healthy project answers 401 and this reads as
|
|
196
|
+
// a broken deployment. Sending the anon key makes 200 mean what it says.
|
|
192
197
|
const res = await fetch(env.SUPABASE_URL + '/auth/v1/health', {
|
|
198
|
+
headers: { apikey: env.SUPABASE_ANON_KEY },
|
|
193
199
|
signal: controller.signal
|
|
194
200
|
});
|
|
195
201
|
clearTimeout(timer);
|
|
196
202
|
if (res.ok) return green(`SUPABASE_URL reachable (${res.status})`);
|
|
203
|
+
if (res.status === 401) {
|
|
204
|
+
return red(
|
|
205
|
+
`SUPABASE_URL rejected SUPABASE_ANON_KEY (401) — the key does not belong ` +
|
|
206
|
+
`to this project, or it has been rotated.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
197
209
|
return yellow(`SUPABASE_URL responded ${res.status} — check project status`);
|
|
198
210
|
} catch (err) {
|
|
199
211
|
return yellow(`SUPABASE_URL unreachable (${err.message ?? err}) — skipping`);
|
package/src/env.js
CHANGED
|
@@ -180,6 +180,31 @@ export function readEnvFile(path) {
|
|
|
180
180
|
return parseEnv(readFileSync(path, 'utf8'));
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Template vars whose shipped value is a real default a deployment should run
|
|
185
|
+
* with, rather than a placeholder or a prompt answer.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately an allowlist, not "every live line in the template". The template
|
|
188
|
+
* also ships `SELVA_HMAC_KEY=replace-this-…` (seeding it would give every
|
|
189
|
+
* deployment a publicly known key) and a monorepo-relative `DATA_PATH` that is
|
|
190
|
+
* wrong anywhere but this repo.
|
|
191
|
+
*
|
|
192
|
+
* `BODY_SIZE_LIMIT` has to be here: unset, adapter-node falls back to 512 KB and
|
|
193
|
+
* every upload 413s with an opaque non-JSON body, which reads as a broken
|
|
194
|
+
* install rather than a missing setting.
|
|
195
|
+
*/
|
|
196
|
+
const SEEDED_TEMPLATE_DEFAULTS = ['BODY_SIZE_LIMIT'];
|
|
197
|
+
|
|
198
|
+
/** The subset of the template's live `KEY=value` lines that are safe to seed. */
|
|
199
|
+
function seededTemplateDefaults(template) {
|
|
200
|
+
const parsed = parseEnv(template);
|
|
201
|
+
const out = {};
|
|
202
|
+
for (const key of SEEDED_TEMPLATE_DEFAULTS) {
|
|
203
|
+
if (parsed[key] !== undefined) out[key] = parsed[key];
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
183
208
|
/**
|
|
184
209
|
* Render a values-only `.env` — the scaffold's default shape.
|
|
185
210
|
*
|
|
@@ -192,6 +217,7 @@ export function readEnvFile(path) {
|
|
|
192
217
|
export function renderEnvValues(template, values, { header = ENV_HEADER } = {}) {
|
|
193
218
|
const ordered = [];
|
|
194
219
|
const seen = new Set();
|
|
220
|
+
const defaults = seededTemplateDefaults(template);
|
|
195
221
|
|
|
196
222
|
// The template names a var three ways — live (`KEY=v`), commented-out
|
|
197
223
|
// (`# KEY=v`), and as a bare mention in prose (`# KEY single | multi`).
|
|
@@ -202,7 +228,17 @@ export function renderEnvValues(template, values, { header = ENV_HEADER } = {})
|
|
|
202
228
|
const key = stripped.split(/[\s=]/)[0]?.trim();
|
|
203
229
|
if (!key || !/^[A-Z][A-Z0-9_]{2,}$/.test(key)) continue;
|
|
204
230
|
if (seen.has(key)) continue;
|
|
205
|
-
|
|
231
|
+
// A live `KEY=value` in the template is a shipped default, not just an
|
|
232
|
+
// ordering hint: the prompts cover a fraction of the vars, so without this
|
|
233
|
+
// a scaffolded deployment silently runs on the code's fallback instead.
|
|
234
|
+
// BODY_SIZE_LIMIT is the case that bites — adapter-node's fallback is
|
|
235
|
+
// 512 KB, so every upload 413s until an operator sets it by hand.
|
|
236
|
+
if (
|
|
237
|
+
!Object.prototype.hasOwnProperty.call(values, key) &&
|
|
238
|
+
!Object.prototype.hasOwnProperty.call(defaults, key)
|
|
239
|
+
) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
206
242
|
seen.add(key);
|
|
207
243
|
ordered.push(key);
|
|
208
244
|
}
|
|
@@ -213,7 +249,9 @@ export function renderEnvValues(template, values, { header = ENV_HEADER } = {})
|
|
|
213
249
|
|
|
214
250
|
const lines = [];
|
|
215
251
|
for (const key of ordered) {
|
|
216
|
-
|
|
252
|
+
// Operator values win over the template's defaults, including when the
|
|
253
|
+
// operator has deliberately blanked one out.
|
|
254
|
+
const value = Object.prototype.hasOwnProperty.call(values, key) ? values[key] : defaults[key];
|
|
217
255
|
if (value === undefined || value === null || value === '') continue;
|
|
218
256
|
lines.push(`${key}=${quoteIfNeeded(value)}`);
|
|
219
257
|
}
|