wawesome 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -5
- package/dist/index.mjs +230 -71
- package/dist/vite.d.mts +23 -0
- package/dist/vite.mjs +247 -0
- package/package.json +24 -2
- package/vite-env.d.ts +37 -0
package/README.md
CHANGED
|
@@ -231,10 +231,110 @@ Add `"assets"` to deploy static files beside your code:
|
|
|
231
231
|
}
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
-
Everything under that directory is deployed with the version
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
234
|
+
Everything under that directory is deployed with the version and served at its path beneath your
|
|
235
|
+
Function's URL — `dist/client/assets/index-a1.js` answers at `https://<app-host>/<function>/assets/index-a1.js`.
|
|
236
|
+
The CLI hashes each file and asks the platform which of them it does not already hold, so a redeploy
|
|
237
|
+
that changed one chunk uploads one chunk — and a deploy that changed nothing at all is refused
|
|
238
|
+
before a byte moves.
|
|
239
|
+
|
|
240
|
+
Files are served straight from object storage; your Function is never invoked for one, and no
|
|
241
|
+
invocation is recorded. They answer on your App's own hostname and nowhere else — on the
|
|
242
|
+
development path form (`/x/<tenant>/<app>/<function>/...`) the same address reaches your handler
|
|
243
|
+
as it always has, because a file on an origin every workspace shares would be same-origin with
|
|
244
|
+
all of them. Each carries `Cache-Control: public, max-age=31536000, immutable` and an
|
|
245
|
+
`ETag`, so name your build output by content hash — a file's bytes must never change under a name a
|
|
246
|
+
browser has already cached for a year. The content type comes from the extension against a fixed
|
|
247
|
+
allowlist and is never sniffed; anything off it is served as a download.
|
|
248
|
+
|
|
249
|
+
Two rules to know about:
|
|
250
|
+
|
|
251
|
+
- **Everything beneath `assets/` is static**, whatever the deploy carries. A request there never
|
|
252
|
+
reaches your handler — an unknown path under it is a 404, not a route for you to answer.
|
|
253
|
+
- **At most 100 files may sit outside `assets/`.** Those paths travel on the version record so a
|
|
254
|
+
request can be routed without a lookup per file. Put bulk output under `assets/`, where a file
|
|
255
|
+
costs nothing; `favicon.ico`, `robots.txt` and a `.well-known/` directory are what the rest is
|
|
256
|
+
for.
|
|
257
|
+
|
|
258
|
+
**HTML is refused at deploy time.** Your Function renders its own markup, and a document served from
|
|
259
|
+
your App's own origin is the sharpest same-origin vector a static file has.
|
|
260
|
+
|
|
261
|
+
**An SVG is served with script denied.** The rule behind the refusal above is that nothing you deploy
|
|
262
|
+
as a file runs script on your App's own origin, and an SVG opened directly in a browser would. It is
|
|
263
|
+
served rather than refused because denying it costs the file nothing: an `<img src="logo.svg">` never
|
|
264
|
+
ran that script, so your drawings render as they always did — every SVG and XML file carries
|
|
265
|
+
`Content-Security-Policy: script-src 'none'`, and only navigating straight to one loses anything.
|
|
266
|
+
|
|
267
|
+
### Schedules
|
|
268
|
+
|
|
269
|
+
Add `"schedules"` to run a Function on a recurring timer, with no caller:
|
|
270
|
+
|
|
271
|
+
```json
|
|
272
|
+
{
|
|
273
|
+
"app": "my-app",
|
|
274
|
+
"function": "nightly-reconcile",
|
|
275
|
+
"entry": "src/index.ts",
|
|
276
|
+
"schedules": [{ "name": "overnight", "expression": "0 3 * * *" }]
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
An expression is **five fields, read in UTC** — minute, hour, day of month, month, day of week. There
|
|
281
|
+
is no seconds field, and no timezone: a local zone would make one night a year fire a job twice and
|
|
282
|
+
another night not at all.
|
|
283
|
+
|
|
284
|
+
The name is yours to choose and is what the platform keys the schedule by, so editing an expression
|
|
285
|
+
is a change to the same schedule rather than the deletion of one and the creation of another — its
|
|
286
|
+
history and its paused state stay attached to it.
|
|
287
|
+
|
|
288
|
+
Your deploy applies them and prints each one with the time it will next run. It is refused, before
|
|
289
|
+
anything is built or uploaded, if an expression cannot be read, if it would run more often than every
|
|
290
|
+
five minutes, or if one Function declares more than five schedules.
|
|
291
|
+
|
|
292
|
+
Two rules worth knowing before you edit the file:
|
|
293
|
+
|
|
294
|
+
- **A schedule you delete from the file is disabled, not deleted.** Its history stays, and declaring
|
|
295
|
+
it again is what turns it back on — so a typo costs you a deploy rather than a job's record.
|
|
296
|
+
- **A deploy never resumes a schedule a person paused.** When it runs is code; whether it is running
|
|
297
|
+
is not, and an unrelated commit the next morning must not restart what you stopped at 3am.
|
|
298
|
+
|
|
299
|
+
Leaving `schedules` out of the file entirely says nothing about them and changes nothing. Writing
|
|
300
|
+
`"schedules": []` says this Function declares none, which disables the ones it used to have.
|
|
301
|
+
|
|
302
|
+
### Keeping a Function off the web
|
|
303
|
+
|
|
304
|
+
A job on a timer should not also be sitting at a guessable URL where a stranger can fire it. Add
|
|
305
|
+
`"visibility"` to make a Function unreachable from the internet:
|
|
306
|
+
|
|
307
|
+
```json
|
|
308
|
+
{
|
|
309
|
+
"app": "my-app",
|
|
310
|
+
"function": "nightly-reconcile",
|
|
311
|
+
"entry": "src/index.ts",
|
|
312
|
+
"visibility": "private"
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
A private Function has **no address at all** — not a hidden one, not one behind a credential. A
|
|
317
|
+
request for it is answered with the same 404 as a Function that was never deployed, on your App's
|
|
318
|
+
own hostname and on the development path form alike. This is how you deploy a job with side effects
|
|
319
|
+
without leaving it where a stranger who guesses the slug can fire it.
|
|
320
|
+
|
|
321
|
+
Leave the line out and your Function is public, which is what every Function without it has always
|
|
322
|
+
been.
|
|
323
|
+
|
|
324
|
+
Today that is all a private Function is: there is no way *in* to one yet. Schedules do not fire yet,
|
|
325
|
+
and the authenticated trigger that runs a Function by hand is not built — so make one private when
|
|
326
|
+
you want it off the web, and expect to publish it again to call it until those land.
|
|
327
|
+
|
|
328
|
+
Making a Function private takes nothing but the deploy. Making it public again does not: deleting
|
|
329
|
+
the line is refused, and the deploy tells you so having written nothing.
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
npx wawesome deploy --publish
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
That is deliberate — the line that keeps a job off the internet is one line, and a deploy that
|
|
336
|
+
quietly honoured its deletion would put the job back on the open internet with nothing said. Every
|
|
337
|
+
deploy prints the visibility it landed, beside the URL or in place of it.
|
|
238
338
|
|
|
239
339
|
### Reserved headers
|
|
240
340
|
|
|
@@ -243,11 +343,12 @@ handler sees it, and off your response before the caller does — so **do not na
|
|
|
243
343
|
on that prefix**: it is dropped silently rather than rejected, and you will not get an error telling
|
|
244
344
|
you why it vanished.
|
|
245
345
|
|
|
246
|
-
|
|
346
|
+
Four headers arrive or leave on it, and the stripping is what makes them worth trusting:
|
|
247
347
|
|
|
248
348
|
| Header | Direction | What it means |
|
|
249
349
|
| --- | --- | --- |
|
|
250
350
|
| `x-wawesome-forwarded-prefix` | inbound | The mount that was stripped from the path. Join it to the path you observe to rebuild the caller's URL. |
|
|
351
|
+
| `x-wawesome-trigger` | inbound | How this run started: `caller` when someone called your address. A caller cannot forge it, so you may skip your own authorization on anything else and open nothing. |
|
|
251
352
|
| `x-wawesome-invocation-id` | outbound | The id of this run — the key to fetch its logs with `npx wawesome logs --invocation <id>`. |
|
|
252
353
|
| `x-wawesome-error` | outbound | Present only when the platform failed, never when your Function did. Its *absence* means the status on the wire is yours — up to the moment your response is committed, and no further. |
|
|
253
354
|
|
package/dist/index.mjs
CHANGED
|
@@ -5,12 +5,12 @@ import path from "node:path";
|
|
|
5
5
|
import { build } from "esbuild";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import { parse } from "acorn";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
8
9
|
import http from "node:http";
|
|
9
10
|
import readline from "node:readline";
|
|
10
11
|
import { Readable } from "node:stream";
|
|
11
12
|
import crypto from "node:crypto";
|
|
12
13
|
import { confirm, select } from "@inquirer/prompts";
|
|
13
|
-
import { spawnSync } from "node:child_process";
|
|
14
14
|
import zlib from "node:zlib";
|
|
15
15
|
//#region src/config.ts
|
|
16
16
|
/**
|
|
@@ -503,8 +503,6 @@ function collectBindings(node, bound, boundNodes, scope, functionValues, pending
|
|
|
503
503
|
case "AssignmentExpression":
|
|
504
504
|
bindAssignmentTarget(node.left, bound);
|
|
505
505
|
bindBody(node.left, node.right, scope, functionValues, pending);
|
|
506
|
-
break;
|
|
507
|
-
default: break;
|
|
508
506
|
}
|
|
509
507
|
}
|
|
510
508
|
function bindBody(target, value, scope, functionValues, pending) {
|
|
@@ -608,10 +606,7 @@ function bindPattern(node, bound, boundNodes) {
|
|
|
608
606
|
case "RestElement":
|
|
609
607
|
bindPattern(node.argument, bound, boundNodes);
|
|
610
608
|
break;
|
|
611
|
-
case "AssignmentPattern":
|
|
612
|
-
bindPattern(node.left, bound, boundNodes);
|
|
613
|
-
break;
|
|
614
|
-
default: break;
|
|
609
|
+
case "AssignmentPattern": bindPattern(node.left, bound, boundNodes);
|
|
615
610
|
}
|
|
616
611
|
}
|
|
617
612
|
function bindAssignmentTarget(target, bound) {
|
|
@@ -639,6 +634,49 @@ function isNode(value) {
|
|
|
639
634
|
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
640
635
|
}
|
|
641
636
|
//#endregion
|
|
637
|
+
//#region src/project-build.ts
|
|
638
|
+
/**
|
|
639
|
+
* Run the command a project declares as the thing that produces its entry point.
|
|
640
|
+
*
|
|
641
|
+
* See **Produced entry point** in `CONTEXT.md` for why the PATH and `NODE_ENV`
|
|
642
|
+
* are what they are.
|
|
643
|
+
*/
|
|
644
|
+
function runProjectBuild(command, projectDir, verbose = false) {
|
|
645
|
+
console.log(`[wawesome] Running the project's build (${command})...`);
|
|
646
|
+
const result = spawnSync(command, {
|
|
647
|
+
cwd: projectDir,
|
|
648
|
+
shell: true,
|
|
649
|
+
stdio: verbose ? "inherit" : [
|
|
650
|
+
"ignore",
|
|
651
|
+
"pipe",
|
|
652
|
+
"pipe"
|
|
653
|
+
],
|
|
654
|
+
env: {
|
|
655
|
+
...process.env,
|
|
656
|
+
NODE_ENV: "production",
|
|
657
|
+
PATH: withBinDirectories(projectDir, process.env.PATH ?? "")
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
if (result.status === 0) return;
|
|
661
|
+
console.error(`[wawesome] Error: the project's build failed (${command}).`);
|
|
662
|
+
for (const stream of [result.stdout, result.stderr]) {
|
|
663
|
+
const text = stream?.toString().trim();
|
|
664
|
+
if (text) console.error(text);
|
|
665
|
+
}
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
668
|
+
function withBinDirectories(projectDir, existing) {
|
|
669
|
+
const dirs = [];
|
|
670
|
+
let current = path.resolve(projectDir);
|
|
671
|
+
for (;;) {
|
|
672
|
+
dirs.push(path.join(current, "node_modules", ".bin"));
|
|
673
|
+
const parent = path.dirname(current);
|
|
674
|
+
if (parent === current) break;
|
|
675
|
+
current = parent;
|
|
676
|
+
}
|
|
677
|
+
return [...dirs, existing].join(path.delimiter);
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
642
680
|
//#region src/build.ts
|
|
643
681
|
/**
|
|
644
682
|
* Bundles user TS/JS entry point into a single optimized ESM JavaScript file using esbuild.
|
|
@@ -649,6 +687,7 @@ async function buildJs(entryInput, options) {
|
|
|
649
687
|
const entry = entryInput || config?.entry || "src/index.ts";
|
|
650
688
|
const outPath = path.resolve(options.out);
|
|
651
689
|
const isVerbose = Boolean(options.verbose);
|
|
690
|
+
if (config?.build) runProjectBuild(config.build, process.cwd(), isVerbose);
|
|
652
691
|
if (!fs.existsSync(entry)) {
|
|
653
692
|
console.error(`[wawesome] Error: Entry file '${entry}' not found.`);
|
|
654
693
|
process.exit(1);
|
|
@@ -720,7 +759,7 @@ async function buildJs(entryInput, options) {
|
|
|
720
759
|
* that has to name this version — `--version`, the dependency a scaffolded
|
|
721
760
|
* project pins — reads it here, so a release bumps one file.
|
|
722
761
|
*/
|
|
723
|
-
const CLI_VERSION = "0.
|
|
762
|
+
const CLI_VERSION = "0.5.0";
|
|
724
763
|
//#endregion
|
|
725
764
|
//#region src/prompt.ts
|
|
726
765
|
/**
|
|
@@ -1116,6 +1155,42 @@ async function whoami() {
|
|
|
1116
1155
|
console.log(` Gateway: ${creds.gateway_url}\n`);
|
|
1117
1156
|
}
|
|
1118
1157
|
//#endregion
|
|
1158
|
+
//#region src/schedules.ts
|
|
1159
|
+
/**
|
|
1160
|
+
* When a schedule next runs, or why it does not run at all.
|
|
1161
|
+
*
|
|
1162
|
+
* The three ways of being off never render alike: someone who cannot tell a
|
|
1163
|
+
* pause from a schedule they deleted from their config file will press resume
|
|
1164
|
+
* and watch nothing happen.
|
|
1165
|
+
*/
|
|
1166
|
+
function whenItRuns(schedule) {
|
|
1167
|
+
switch (schedule.state) {
|
|
1168
|
+
case "active": return schedule.next_fire_at ? `next run ${asUtc(schedule.next_fire_at)}` : "next run unknown";
|
|
1169
|
+
case "paused": return "paused — resume it to run again";
|
|
1170
|
+
case "undeclared": return "not in this config file — disabled, kept";
|
|
1171
|
+
case "suspended": return "suspended — the workspace owes payment";
|
|
1172
|
+
default: return schedule.state;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
|
|
1176
|
+
function asUtc(iso) {
|
|
1177
|
+
const at = new Date(iso);
|
|
1178
|
+
if (Number.isNaN(at.getTime())) return iso;
|
|
1179
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1180
|
+
return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())} UTC`;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The block a deploy prints for the schedules it just applied. Every schedule
|
|
1184
|
+
* appears, including the ones that are off, so putting recurring compute on the
|
|
1185
|
+
* bill — or taking it off — is never silent.
|
|
1186
|
+
*/
|
|
1187
|
+
function scheduleLines(schedules) {
|
|
1188
|
+
if (schedules.length === 0) return [];
|
|
1189
|
+
const nameWidth = Math.max(...schedules.map((s) => s.name.length));
|
|
1190
|
+
const expressionWidth = Math.max(...schedules.map((s) => s.expression.length));
|
|
1191
|
+
return [" Schedules:", ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(expressionWidth)} ${whenItRuns(schedule)}`)];
|
|
1192
|
+
}
|
|
1193
|
+
//#endregion
|
|
1119
1194
|
//#region src/usage.ts
|
|
1120
1195
|
const USAGE_TIMEOUT_MS = 2e3;
|
|
1121
1196
|
/**
|
|
@@ -1192,11 +1267,10 @@ const MONTHS = [
|
|
|
1192
1267
|
];
|
|
1193
1268
|
/** Indented to sit inside the receipt the deploy already prints. */
|
|
1194
1269
|
function headroomLines(usage) {
|
|
1195
|
-
const lines = [
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
];
|
|
1270
|
+
const lines = [` Plan: ${usage.plan.name} — this deploy does not change your bill.`, ` Apps: ${usage.occupied_app_slots} / ${usage.plan.limits.app_slots} slots`];
|
|
1271
|
+
const stored = storedLine(usage);
|
|
1272
|
+
if (stored !== null) lines.push(stored);
|
|
1273
|
+
lines.push(` Usage: ${periodLabel(usage.period.start, usage.period.end)}`);
|
|
1200
1274
|
const entries = Object.entries(usage.allowances);
|
|
1201
1275
|
const labelWidth = widest(entries.map(([key]) => allowanceLabel(key)));
|
|
1202
1276
|
const amountWidth = widest(entries.map(([key, allowance]) => amount(key, allowance.used, allowance.limit)));
|
|
@@ -1209,6 +1283,17 @@ function headroomLines(usage) {
|
|
|
1209
1283
|
if (refused !== null) lines.push(` Refused: ${formatCount(refused)} request${refused === 1 ? "" : "s"} at your share`);
|
|
1210
1284
|
return lines;
|
|
1211
1285
|
}
|
|
1286
|
+
/**
|
|
1287
|
+
* What the workspace holds against what its plan grants. Beside the App slots
|
|
1288
|
+
* rather than among the allowances below, because neither is spent over the
|
|
1289
|
+
* period those are read against.
|
|
1290
|
+
*/
|
|
1291
|
+
function storedLine(usage) {
|
|
1292
|
+
const occupied = usage.occupied_stored_bytes;
|
|
1293
|
+
const granted = usage.plan.limits.stored_bytes;
|
|
1294
|
+
if (typeof occupied !== "number" || typeof granted !== "number") return null;
|
|
1295
|
+
return ` Storage: ${formatBytes(occupied)} / ${formatBytes(granted)}`;
|
|
1296
|
+
}
|
|
1212
1297
|
function refusedAtShare(usage) {
|
|
1213
1298
|
const refused = usage.refusals?.at_granted_share;
|
|
1214
1299
|
if (typeof refused !== "number" || !Number.isFinite(refused) || refused <= 0) return null;
|
|
@@ -1265,6 +1350,27 @@ function widest(values) {
|
|
|
1265
1350
|
return values.reduce((longest, value) => Math.max(longest, value.length), 0);
|
|
1266
1351
|
}
|
|
1267
1352
|
//#endregion
|
|
1353
|
+
//#region src/billing.ts
|
|
1354
|
+
function billingPageUrl() {
|
|
1355
|
+
const base = getDashboardUrl().replace(/\/+$/, "");
|
|
1356
|
+
try {
|
|
1357
|
+
return new URL("billing", `${base}/`).toString();
|
|
1358
|
+
} catch {
|
|
1359
|
+
return `${base}/billing`;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/** Refusals whose remedy is on the billing page and nowhere else. */
|
|
1363
|
+
const PLAN_LIMITS = [
|
|
1364
|
+
"app-slots-exhausted",
|
|
1365
|
+
"storage-exhausted",
|
|
1366
|
+
"paid-plan-required"
|
|
1367
|
+
];
|
|
1368
|
+
/** The rule itself is the gateway's prose, and is deliberately not restated here. */
|
|
1369
|
+
function planLimitAdvice(reason) {
|
|
1370
|
+
if (!reason || !PLAN_LIMITS.includes(reason)) return "";
|
|
1371
|
+
return `Where to resolve it: ${billingPageUrl()}`;
|
|
1372
|
+
}
|
|
1373
|
+
//#endregion
|
|
1268
1374
|
//#region src/assets.ts
|
|
1269
1375
|
/**
|
|
1270
1376
|
* The files under `dir`, hashed.
|
|
@@ -1408,6 +1514,31 @@ function trimTrailingSlashes(origin) {
|
|
|
1408
1514
|
}
|
|
1409
1515
|
//#endregion
|
|
1410
1516
|
//#region src/deploy.ts
|
|
1517
|
+
function declaredFields(declared) {
|
|
1518
|
+
return {
|
|
1519
|
+
...declared.visibility ? { visibility: declared.visibility } : {},
|
|
1520
|
+
...declared.confirmPublish ? { confirm_publish: true } : {}
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* The visibility this project declares, refused here rather than at the gateway
|
|
1525
|
+
* so a typo is a message at the keyboard.
|
|
1526
|
+
*/
|
|
1527
|
+
function declaredVisibility(value) {
|
|
1528
|
+
if (value === void 0 || value === null) return void 0;
|
|
1529
|
+
if (value === "public" || value === "private") return value;
|
|
1530
|
+
console.error(`[wawesome] Error: 'visibility' in wawesome-function.json is '${String(value)}'.`);
|
|
1531
|
+
console.error("[wawesome] It must be \"public\" or \"private\".");
|
|
1532
|
+
process.exit(1);
|
|
1533
|
+
}
|
|
1534
|
+
function reportPublishRefusal(message) {
|
|
1535
|
+
console.error(`\n[wawesome] \x1b[31mError: ${message}\x1b[0m`);
|
|
1536
|
+
console.error("[wawesome] Nothing was deployed. This Function is private, so its address");
|
|
1537
|
+
console.error("[wawesome] resolves for nobody — publishing it puts it back on the open");
|
|
1538
|
+
console.error("[wawesome] internet.");
|
|
1539
|
+
console.error("[wawesome] Re-run with \x1B[36mwawesome deploy --publish\x1B[0m to publish it, or put");
|
|
1540
|
+
console.error("[wawesome] \x1B[36m\"visibility\": \"private\"\x1B[0m back in wawesome-function.json.\n");
|
|
1541
|
+
}
|
|
1411
1542
|
/**
|
|
1412
1543
|
* Deploy a function: build → upload JS to gateway → promote.
|
|
1413
1544
|
*/
|
|
@@ -1425,6 +1556,10 @@ async function deploy(entryInput, options) {
|
|
|
1425
1556
|
process.exit(1);
|
|
1426
1557
|
}
|
|
1427
1558
|
const { app, function: funcName } = config;
|
|
1559
|
+
const declared = {
|
|
1560
|
+
visibility: declaredVisibility(config.visibility),
|
|
1561
|
+
confirmPublish: Boolean(options.publish)
|
|
1562
|
+
};
|
|
1428
1563
|
if (isVerbose) {
|
|
1429
1564
|
console.log(`[wawesome:verbose] Deploying to app=${app}, function=${funcName}`);
|
|
1430
1565
|
console.log(`[wawesome:verbose] Gateway: ${creds.gateway_url}`);
|
|
@@ -1456,12 +1591,14 @@ async function deploy(entryInput, options) {
|
|
|
1456
1591
|
for (const line of surfaceReportLines(findings)) (refused ? console.error : console.warn)(line);
|
|
1457
1592
|
if (refused) process.exit(1);
|
|
1458
1593
|
}
|
|
1594
|
+
const declaresSchedules = config.schedules !== void 0;
|
|
1459
1595
|
const assets = config.assets ? collectAssets(path.resolve(config.assets)) : [];
|
|
1460
|
-
if (assets.length > 0) await uploadAssets(creds, app, funcName, jsCode, assets, isVerbose);
|
|
1596
|
+
if (assets.length > 0) await uploadAssets(creds, app, funcName, jsCode, assets, declared, isVerbose, declaresSchedules);
|
|
1461
1597
|
console.log(`[wawesome] Uploading code for ${app}/${funcName}...`);
|
|
1462
1598
|
const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
|
|
1463
1599
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
|
|
1464
|
-
const
|
|
1600
|
+
const declaredOnTheWire = declaredFields(declared);
|
|
1601
|
+
const uploadRes = assets.length > 0 || declaresSchedules || Object.keys(declaredOnTheWire).length > 0 ? await fetch(uploadUrl, {
|
|
1465
1602
|
method: "POST",
|
|
1466
1603
|
headers: {
|
|
1467
1604
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -1469,7 +1606,9 @@ async function deploy(entryInput, options) {
|
|
|
1469
1606
|
},
|
|
1470
1607
|
body: JSON.stringify({
|
|
1471
1608
|
code: jsCode,
|
|
1472
|
-
assets: manifestOf(assets)
|
|
1609
|
+
...assets.length > 0 ? { assets: manifestOf(assets) } : {},
|
|
1610
|
+
...declaresSchedules ? { schedules: config.schedules } : {},
|
|
1611
|
+
...declaredOnTheWire
|
|
1473
1612
|
})
|
|
1474
1613
|
}) : await fetch(uploadUrl, {
|
|
1475
1614
|
method: "POST",
|
|
@@ -1481,9 +1620,16 @@ async function deploy(entryInput, options) {
|
|
|
1481
1620
|
});
|
|
1482
1621
|
if (!uploadRes.ok) {
|
|
1483
1622
|
const errorBody = await uploadRes.text();
|
|
1623
|
+
const refusal = rejectionOf(errorBody, uploadRes.status, "Version with this code bundle already exists.");
|
|
1484
1624
|
if (uploadRes.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
1485
|
-
else if (
|
|
1486
|
-
|
|
1625
|
+
else if (refusal.reason === "paid-plan-required") {
|
|
1626
|
+
console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
|
|
1627
|
+
console.error(`[wawesome] ${planLimitAdvice(refusal.reason)}\n`);
|
|
1628
|
+
} else if (uploadRes.status === 409) {
|
|
1629
|
+
if (refusal.reason === "publish-needs-confirmation") {
|
|
1630
|
+
reportPublishRefusal(refusal.message);
|
|
1631
|
+
process.exit(1);
|
|
1632
|
+
}
|
|
1487
1633
|
console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
|
|
1488
1634
|
console.error("[wawesome] Code versions are immutable and cannot be overwritten.");
|
|
1489
1635
|
console.error("[wawesome] To switch active version, run: \x1B[36mwawesome version switch\x1B[0m\n");
|
|
@@ -1493,33 +1639,40 @@ async function deploy(entryInput, options) {
|
|
|
1493
1639
|
}
|
|
1494
1640
|
process.exit(1);
|
|
1495
1641
|
}
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
const
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1642
|
+
const uploadData = await uploadRes.json();
|
|
1643
|
+
const version = uploadData.version_number;
|
|
1644
|
+
const schedules = uploadData.schedules ?? [];
|
|
1645
|
+
const visibility = uploadData.visibility ?? declared.visibility ?? "public";
|
|
1646
|
+
const nothingBuilt = uploadData.status === "declarations-updated";
|
|
1647
|
+
if (nothingBuilt) console.log(`[wawesome] ✅ Code and files unchanged (version ${version ?? "unknown"}); what the config file declares applied.`);
|
|
1648
|
+
else console.log(`[wawesome] ✅ Code uploaded (version ${version ?? "unknown"}).`);
|
|
1649
|
+
if (!nothingBuilt) {
|
|
1650
|
+
console.log(`[wawesome] Promoting to production...`);
|
|
1651
|
+
const deployUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/deploy`;
|
|
1652
|
+
if (isVerbose) console.log(`[wawesome:verbose] POST ${deployUrl}`);
|
|
1653
|
+
const deployBody = {};
|
|
1654
|
+
if (version !== void 0) deployBody.version_number = version;
|
|
1655
|
+
const deployRes = await fetch(deployUrl, {
|
|
1656
|
+
method: "POST",
|
|
1657
|
+
headers: {
|
|
1658
|
+
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
1659
|
+
"Content-Type": "application/json"
|
|
1660
|
+
},
|
|
1661
|
+
body: JSON.stringify(deployBody)
|
|
1662
|
+
});
|
|
1663
|
+
if (!deployRes.ok) {
|
|
1664
|
+
const errorBody = await deployRes.text();
|
|
1665
|
+
if (deployRes.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
1666
|
+
else {
|
|
1667
|
+
console.error(`[wawesome] Error: Deployment failed (HTTP ${deployRes.status}).`);
|
|
1668
|
+
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
1669
|
+
}
|
|
1670
|
+
process.exit(1);
|
|
1517
1671
|
}
|
|
1518
|
-
process.exit(1);
|
|
1519
1672
|
}
|
|
1520
1673
|
let address = null;
|
|
1521
1674
|
let surface = null;
|
|
1522
|
-
try {
|
|
1675
|
+
if (visibility === "public") try {
|
|
1523
1676
|
const { slug } = await resolveWorkspace(creds);
|
|
1524
1677
|
surface = await fetchInvocationSurface(creds.gateway_url);
|
|
1525
1678
|
address = publicAddress(surface, slug, app, funcName);
|
|
@@ -1533,19 +1686,27 @@ async function deploy(entryInput, options) {
|
|
|
1533
1686
|
if (isVerbose) console.log(`[wawesome:verbose] Could not read the plan's usage: ${errorText(err)}`);
|
|
1534
1687
|
}
|
|
1535
1688
|
console.log("\n======================================================");
|
|
1536
|
-
console.log("🚀 \x1B[32mDEPLOYED SUCCESSFULLY!\x1B[0m");
|
|
1689
|
+
console.log(nothingBuilt ? "🕒 \x1B[32mCONFIGURATION APPLIED\x1B[0m" : "🚀 \x1B[32mDEPLOYED SUCCESSFULLY!\x1B[0m");
|
|
1537
1690
|
console.log("======================================================");
|
|
1538
|
-
console.log(`\n App:
|
|
1539
|
-
console.log(` Function:
|
|
1540
|
-
if (version !== void 0) console.log(` Version:
|
|
1541
|
-
if (assets.length > 0) console.log(` Assets:
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
console.log(
|
|
1691
|
+
console.log(`\n App: ${app}`);
|
|
1692
|
+
console.log(` Function: ${funcName}`);
|
|
1693
|
+
if (version !== void 0) console.log(` Version: ${version}${nothingBuilt ? " (already live, nothing promoted)" : ""}`);
|
|
1694
|
+
if (assets.length > 0) console.log(` Assets: ${assets.length}`);
|
|
1695
|
+
console.log(` Visibility: ${visibility}`);
|
|
1696
|
+
if (schedules.length > 0) {
|
|
1697
|
+
console.log("");
|
|
1698
|
+
for (const line of scheduleLines(schedules)) console.log(line);
|
|
1699
|
+
}
|
|
1700
|
+
if (visibility === "private") {
|
|
1701
|
+
console.log("\n URL: none — this Function is private, so it is not reachable");
|
|
1702
|
+
console.log(" from the web at all.");
|
|
1703
|
+
} else if (address) {
|
|
1704
|
+
console.log(`\n URL: \x1b[36m${address}\x1b[0m`);
|
|
1705
|
+
console.log(` ${SUBTREE_NOTE}`);
|
|
1545
1706
|
} else if (surface) {
|
|
1546
|
-
console.log("\n URL:
|
|
1547
|
-
console.log("
|
|
1548
|
-
console.log("
|
|
1707
|
+
console.log("\n URL: none — this gateway serves no public address form.");
|
|
1708
|
+
console.log(" Set CONTENT_ORIGIN on it, or ALLOW_PATH_INVOCATION_FORM");
|
|
1709
|
+
console.log(" for local development.");
|
|
1549
1710
|
}
|
|
1550
1711
|
if (headroom) {
|
|
1551
1712
|
console.log("");
|
|
@@ -1556,7 +1717,8 @@ async function deploy(entryInput, options) {
|
|
|
1556
1717
|
app,
|
|
1557
1718
|
functionName: funcName,
|
|
1558
1719
|
version,
|
|
1559
|
-
address
|
|
1720
|
+
address,
|
|
1721
|
+
schedules
|
|
1560
1722
|
};
|
|
1561
1723
|
}
|
|
1562
1724
|
/**
|
|
@@ -1566,7 +1728,7 @@ async function deploy(entryInput, options) {
|
|
|
1566
1728
|
* The identity of the whole deploy goes with the question, so a redeploy that
|
|
1567
1729
|
* changed nothing at all is refused here — before a byte of it has moved.
|
|
1568
1730
|
*/
|
|
1569
|
-
async function uploadAssets(creds, app, funcName, bundle, assets, isVerbose) {
|
|
1731
|
+
async function uploadAssets(creds, app, funcName, bundle, assets, declared, isVerbose, continueWhenUnchanged) {
|
|
1570
1732
|
const manifestUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/assets/manifest`;
|
|
1571
1733
|
const manifestRes = await fetch(manifestUrl, {
|
|
1572
1734
|
method: "POST",
|
|
@@ -1576,14 +1738,23 @@ async function uploadAssets(creds, app, funcName, bundle, assets, isVerbose) {
|
|
|
1576
1738
|
},
|
|
1577
1739
|
body: JSON.stringify({
|
|
1578
1740
|
deploy_digest: deployDigest(Buffer.from(bundle, "utf-8"), assets),
|
|
1579
|
-
assets: manifestOf(assets)
|
|
1741
|
+
assets: manifestOf(assets),
|
|
1742
|
+
...declaredFields(declared)
|
|
1580
1743
|
})
|
|
1581
1744
|
});
|
|
1582
1745
|
if (!manifestRes.ok) {
|
|
1583
1746
|
const errorBody = await manifestRes.text();
|
|
1584
1747
|
if (manifestRes.status === 401) console.error("[wawesome] Error: Authentication expired. Run 'wawesome login' again.");
|
|
1585
1748
|
else if (manifestRes.status === 409) {
|
|
1749
|
+
if (continueWhenUnchanged) {
|
|
1750
|
+
console.log("[wawesome] Code and files unchanged since the last deploy; applying schedules.");
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1586
1753
|
const refusal = rejectionOf(errorBody, manifestRes.status, "This deploy is already deployed.");
|
|
1754
|
+
if (refusal.reason === "publish-needs-confirmation") {
|
|
1755
|
+
reportPublishRefusal(refusal.message);
|
|
1756
|
+
process.exit(1);
|
|
1757
|
+
}
|
|
1587
1758
|
console.error(`\n[wawesome] \x1b[31mError: ${refusal.message}\x1b[0m`);
|
|
1588
1759
|
console.error("[wawesome] Nothing changed since the last deploy, so nothing was uploaded.\n");
|
|
1589
1760
|
} else {
|
|
@@ -1616,6 +1787,8 @@ async function uploadAssets(creds, app, funcName, bundle, assets, isVerbose) {
|
|
|
1616
1787
|
const errorBody = await res.text();
|
|
1617
1788
|
const refusal = rejectionOf(errorBody, res.status, `Upload of '${asset.path}' failed (HTTP ${res.status}).`);
|
|
1618
1789
|
console.error(`[wawesome] Error: ${refusal.message}`);
|
|
1790
|
+
const advice = planLimitAdvice(refusal.reason);
|
|
1791
|
+
if (advice) console.error(`[wawesome] ${advice}`);
|
|
1619
1792
|
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
1620
1793
|
process.exit(1);
|
|
1621
1794
|
}
|
|
@@ -1623,20 +1796,6 @@ async function uploadAssets(creds, app, funcName, bundle, assets, isVerbose) {
|
|
|
1623
1796
|
console.log(`[wawesome] ✅ ${toUpload.length} asset(s) uploaded.`);
|
|
1624
1797
|
}
|
|
1625
1798
|
//#endregion
|
|
1626
|
-
//#region src/billing.ts
|
|
1627
|
-
function billingPageUrl() {
|
|
1628
|
-
const base = getDashboardUrl().replace(/\/+$/, "");
|
|
1629
|
-
try {
|
|
1630
|
-
return new URL("billing", `${base}/`).toString();
|
|
1631
|
-
} catch {
|
|
1632
|
-
return `${base}/billing`;
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
function appSlotAdvice(reason) {
|
|
1636
|
-
if (reason !== "app-slots-exhausted") return "";
|
|
1637
|
-
return `Where to resolve it: ${billingPageUrl()}`;
|
|
1638
|
-
}
|
|
1639
|
-
//#endregion
|
|
1640
1799
|
//#region src/env.ts
|
|
1641
1800
|
const STANDARD_SECRET_MESSAGES = [
|
|
1642
1801
|
"Encrypted at rest using AES-256",
|
|
@@ -2559,7 +2718,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
|
|
|
2559
2718
|
try {
|
|
2560
2719
|
await ensureApp(creds, appSlug);
|
|
2561
2720
|
} catch (err) {
|
|
2562
|
-
fail(errorText(err), ...[
|
|
2721
|
+
fail(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
|
|
2563
2722
|
}
|
|
2564
2723
|
for (const { declared, value } of answers) {
|
|
2565
2724
|
if (!value) {
|
|
@@ -3543,7 +3702,7 @@ async function workspaceCommand(action, target, options) {
|
|
|
3543
3702
|
//#region src/index.ts
|
|
3544
3703
|
const cli = cac("wawesome");
|
|
3545
3704
|
cli.command("build [entry]", "Bundle a serverless function to an optimized JS file").option("-o, --out <path>", "Output JS bundle path", { default: "dist/index.js" }).option("-v, --verbose", "Enable verbose debug output").action((entry, options) => buildJs(entry, options));
|
|
3546
|
-
cli.command("deploy [entry]", "Build, upload, and promote a serverless function").option("-o, --out <path>", "Output JS bundle path", { default: "dist/index.js" }).option("-v, --verbose", "Enable verbose debug output").option("--skip-build", "Skip the build step, deploy an already-built bundle").action((entry, options) => deploy(entry, options));
|
|
3705
|
+
cli.command("deploy [entry]", "Build, upload, and promote a serverless function").option("-o, --out <path>", "Output JS bundle path", { default: "dist/index.js" }).option("-v, --verbose", "Enable verbose debug output").option("--skip-build", "Skip the build step, deploy an already-built bundle").option("--publish", "Confirm putting a private function back on its public address").action((entry, options) => deploy(entry, options));
|
|
3547
3706
|
cli.command("version [action] [target]", "Manage versions (e.g. 'version list', 'version switch [version]')").option("-e, --env <environment>", "Target environment (default: production)").option("-v, --verbose", "Enable verbose debug output").action((action, target, options) => {
|
|
3548
3707
|
if (!action || action === "list" || action === "ls") return listVersions(options);
|
|
3549
3708
|
if (action === "switch" || action === "use" || action === "select") return switchVersion(target, options);
|
package/dist/vite.d.mts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
|
+
//#region src/vite.d.ts
|
|
3
|
+
interface WawesomeReactOptions {
|
|
4
|
+
/** The module the browser hydrates from. */
|
|
5
|
+
client?: string;
|
|
6
|
+
/** The module exporting the `fetch` handler the platform invokes. */
|
|
7
|
+
server?: string;
|
|
8
|
+
/** The HTML document the shell is cut from. */
|
|
9
|
+
html?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Builds a React application into the two halves the platform deploys: a client
|
|
13
|
+
* bundle uploaded as **Static assets**, and a server bundle that renders the
|
|
14
|
+
* document and streams it.
|
|
15
|
+
*
|
|
16
|
+
* The document's own HTML is inlined into the server bundle rather than uploaded
|
|
17
|
+
* with the rest: served statically at the mount root it would shadow the SSR
|
|
18
|
+
* route and answer with an empty shell that fails to hydrate. Every URL it
|
|
19
|
+
* carries is a **Request-time base** away from the mount.
|
|
20
|
+
*/
|
|
21
|
+
declare function wawesomeReact(options?: WawesomeReactOptions): Plugin;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { WawesomeReactOptions, wawesomeReact as default, wawesomeReact };
|
package/dist/vite.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { a as unsupportedGlobals, n as methodRemedy, r as polyfilledGlobals, t as declaredMethods } from "./guest-surface-CXON0L5V.mjs";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
//#region src/vite.ts
|
|
5
|
+
const DOCUMENT_MODULE_ID = "virtual:wawesome/document";
|
|
6
|
+
const BASE_MODULE_ID = "virtual:wawesome/base";
|
|
7
|
+
const RESOLVED_DOCUMENT = `\0${DOCUMENT_MODULE_ID}`;
|
|
8
|
+
const RESOLVED_BASE = `\0${BASE_MODULE_ID}`;
|
|
9
|
+
/** Where the rendered application is spliced into the document. */
|
|
10
|
+
const APP_MARKER = "<!--app-html-->";
|
|
11
|
+
const BASE_GLOBAL = "__WAWESOME_BASE__";
|
|
12
|
+
/**
|
|
13
|
+
* Stands where the **Request-time base** goes. The document is escaped whole
|
|
14
|
+
* and split on this afterwards, so no part of the markup can be read as the
|
|
15
|
+
* interpolation.
|
|
16
|
+
*/
|
|
17
|
+
const BASE_SLOT = "\0wawesome-base\0";
|
|
18
|
+
/**
|
|
19
|
+
* Builds a React application into the two halves the platform deploys: a client
|
|
20
|
+
* bundle uploaded as **Static assets**, and a server bundle that renders the
|
|
21
|
+
* document and streams it.
|
|
22
|
+
*
|
|
23
|
+
* The document's own HTML is inlined into the server bundle rather than uploaded
|
|
24
|
+
* with the rest: served statically at the mount root it would shadow the SSR
|
|
25
|
+
* route and answer with an empty shell that fails to hydrate. Every URL it
|
|
26
|
+
* carries is a **Request-time base** away from the mount.
|
|
27
|
+
*/
|
|
28
|
+
function wawesomeReact(options = {}) {
|
|
29
|
+
const clientEntry = normalize(options.client ?? "src/entry.client.tsx");
|
|
30
|
+
const serverEntry = normalize(options.server ?? "src/entry.server.tsx");
|
|
31
|
+
const htmlFile = normalize(options.html ?? "index.html");
|
|
32
|
+
let root = process.cwd();
|
|
33
|
+
let dev = null;
|
|
34
|
+
let clientBuild = null;
|
|
35
|
+
return {
|
|
36
|
+
name: "wawesome:react-ssr",
|
|
37
|
+
config(_userConfig, env) {
|
|
38
|
+
const building = env.command === "build";
|
|
39
|
+
return {
|
|
40
|
+
appType: "custom",
|
|
41
|
+
base: "./",
|
|
42
|
+
environments: {
|
|
43
|
+
client: { build: {
|
|
44
|
+
outDir: "dist/client",
|
|
45
|
+
rollupOptions: { input: { index: clientEntry } }
|
|
46
|
+
} },
|
|
47
|
+
ssr: {
|
|
48
|
+
define: building ? { "process.env.NODE_ENV": "\"production\"" } : absentGlobalDefines(root),
|
|
49
|
+
resolve: building ? {
|
|
50
|
+
noExternal: true,
|
|
51
|
+
external: [],
|
|
52
|
+
conditions: [
|
|
53
|
+
"workerd",
|
|
54
|
+
"worker",
|
|
55
|
+
"browser"
|
|
56
|
+
]
|
|
57
|
+
} : {},
|
|
58
|
+
build: {
|
|
59
|
+
outDir: "dist/server",
|
|
60
|
+
ssr: true,
|
|
61
|
+
sourcemap: true,
|
|
62
|
+
copyPublicDir: false,
|
|
63
|
+
rollupOptions: {
|
|
64
|
+
input: { index: serverEntry },
|
|
65
|
+
output: { codeSplitting: false }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
builder: {
|
|
71
|
+
sharedConfigBuild: true,
|
|
72
|
+
async buildApp(builder) {
|
|
73
|
+
await builder.build(builder.environments.client);
|
|
74
|
+
await builder.build(builder.environments.ssr);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
configResolved(config) {
|
|
80
|
+
root = config.root;
|
|
81
|
+
},
|
|
82
|
+
configureServer(server) {
|
|
83
|
+
dev = server;
|
|
84
|
+
reportUnenforceableGaps(server, root);
|
|
85
|
+
return () => {
|
|
86
|
+
server.middlewares.use((req, res, next) => {
|
|
87
|
+
render(server, serverEntry, req, res).catch(next);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
hotUpdate({ file }) {
|
|
92
|
+
if (file !== path.join(root, htmlFile)) return;
|
|
93
|
+
const module = this.environment.moduleGraph.getModuleById(RESOLVED_DOCUMENT);
|
|
94
|
+
if (module) this.environment.moduleGraph.invalidateModule(module);
|
|
95
|
+
dev?.environments.client.hot.send({ type: "full-reload" });
|
|
96
|
+
},
|
|
97
|
+
generateBundle(_output, bundle) {
|
|
98
|
+
if (this.environment.name !== "client") return;
|
|
99
|
+
const entry = Object.values(bundle).find((chunk) => chunk.type === "chunk" && chunk.isEntry);
|
|
100
|
+
if (!entry || entry.type !== "chunk") {
|
|
101
|
+
this.error(`The client build produced no entry chunk for '${clientEntry}'.`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
clientBuild = {
|
|
105
|
+
entry: entry.fileName,
|
|
106
|
+
stylesheets: [...entry.viteMetadata?.importedCss ?? []]
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
resolveId(id) {
|
|
110
|
+
if (id === DOCUMENT_MODULE_ID) return RESOLVED_DOCUMENT;
|
|
111
|
+
if (id === BASE_MODULE_ID) return RESOLVED_BASE;
|
|
112
|
+
},
|
|
113
|
+
async load(id) {
|
|
114
|
+
if (id === RESOLVED_BASE) return baseModule();
|
|
115
|
+
if (id !== RESOLVED_DOCUMENT) return;
|
|
116
|
+
const html = fs.readFileSync(path.join(root, htmlFile), "utf-8");
|
|
117
|
+
if (dev) return documentModule(await dev.transformIndexHtml("/", html), clientEntry, {
|
|
118
|
+
entry: clientEntry,
|
|
119
|
+
stylesheets: []
|
|
120
|
+
});
|
|
121
|
+
if (!clientBuild) {
|
|
122
|
+
this.error("The client bundle has not been built, so the document has no script to name. Build both environments together with `vite build`.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
return documentModule(html, clientEntry, clientBuild);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The globals the guest does not have, taken away from the application's own
|
|
131
|
+
* modules while it is served locally.
|
|
132
|
+
*
|
|
133
|
+
* Rewritten in the application's source rather than deleted off the global
|
|
134
|
+
* scope, because the dev server is Node and Vite's own logger formats its
|
|
135
|
+
* timestamps with `Intl`. A reference becomes a name nothing defines, which is
|
|
136
|
+
* the ReferenceError the guest gives, and `typeof` still answers "undefined".
|
|
137
|
+
*/
|
|
138
|
+
function absentGlobalDefines(projectDir) {
|
|
139
|
+
return Object.fromEntries(absentGlobals(projectDir).map((name) => [name, `__wawesome_absent_${name}`]));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Read off the declaration rather than listed here, and skipped for a global the
|
|
143
|
+
* project's manifest says it polyfills — the two rules the build's surface scan
|
|
144
|
+
* applies, so local development cannot disagree with the deploy about what is
|
|
145
|
+
* there.
|
|
146
|
+
*/
|
|
147
|
+
function absentGlobals(projectDir) {
|
|
148
|
+
const polyfilled = polyfilledGlobals(projectDir);
|
|
149
|
+
return unsupportedGlobals().map((entry) => entry.name).filter((name) => !polyfilled.has(name));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The declared gaps this dev server cannot reproduce, said once at startup.
|
|
153
|
+
*
|
|
154
|
+
* The locale-sensitive methods are the quiet half of the **Declared guest
|
|
155
|
+
* surface**: the engine has them and answers wrongly, so an application
|
|
156
|
+
* formatting a price renders different markup here and in production. They
|
|
157
|
+
* cannot be replaced on the prototypes, which are Node's own and shared with
|
|
158
|
+
* Vite — its shortcut handler lowercases with one. So they are named instead,
|
|
159
|
+
* and the two places that do hold an application to them are named with them:
|
|
160
|
+
* `wawesome build` reports every use with the file and line, and a suite run
|
|
161
|
+
* under `wawesome/vitest-setup` refuses them outright.
|
|
162
|
+
*/
|
|
163
|
+
function reportUnenforceableGaps(server, projectDir) {
|
|
164
|
+
const gone = absentGlobals(projectDir).join(", ");
|
|
165
|
+
const methods = declaredMethods().map((method) => `${method.target}.${method.name}`).join(", ");
|
|
166
|
+
if (gone) server.config.logger.info(` wawesome ${gone} is not defined here, as on the guest`);
|
|
167
|
+
server.config.logger.warn(` wawesome ${methods} still answer here and will not on the guest`);
|
|
168
|
+
server.config.logger.warn(` ${methodRemedy()}`);
|
|
169
|
+
server.config.logger.warn(" `wawesome build` reports each use; `npm test` refuses them.");
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Drive the same handler the platform invokes, over the dev server's own module
|
|
173
|
+
* graph, and write what it answers out as it arrives.
|
|
174
|
+
*
|
|
175
|
+
* No forwarded prefix is set, because locally there is no **Mount** to strip.
|
|
176
|
+
*/
|
|
177
|
+
async function render(server, serverEntry, req, res) {
|
|
178
|
+
const { runner } = server.environments.ssr;
|
|
179
|
+
const handler = await runner.import(`/${serverEntry}`);
|
|
180
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
181
|
+
const response = await handler.default.fetch(new Request(url, { method: req.method ?? "GET" }));
|
|
182
|
+
res.statusCode = response.status;
|
|
183
|
+
response.headers.forEach((value, name) => res.setHeader(name, value));
|
|
184
|
+
if (response.body) for await (const chunk of response.body) res.write(chunk);
|
|
185
|
+
res.end();
|
|
186
|
+
}
|
|
187
|
+
function baseModule() {
|
|
188
|
+
return [
|
|
189
|
+
`const held = typeof window === "undefined" ? undefined : window[${JSON.stringify(BASE_GLOBAL)}];`,
|
|
190
|
+
"export const base = typeof held === \"string\" ? held : \"\";",
|
|
191
|
+
""
|
|
192
|
+
].join("\n");
|
|
193
|
+
}
|
|
194
|
+
function documentModule(html, clientEntry, built) {
|
|
195
|
+
if (html.includes("\0")) throw new Error("The HTML document carries a NUL byte, which is where the request-time base goes.");
|
|
196
|
+
if (!html.includes(APP_MARKER)) throw new Error(`The HTML document has no ${APP_MARKER} for the application to render into.`);
|
|
197
|
+
const document = withStylesheets(withoutEntryScript(html, clientEntry), built.stylesheets);
|
|
198
|
+
const at = document.indexOf(APP_MARKER);
|
|
199
|
+
return [
|
|
200
|
+
`export const clientEntry = ${JSON.stringify(built.entry)};`,
|
|
201
|
+
"",
|
|
202
|
+
"export function documentShell(base) {",
|
|
203
|
+
` return {`,
|
|
204
|
+
` before: ${interpolated(document.slice(0, at))},`,
|
|
205
|
+
` after: ${interpolated(document.slice(at + 15))},`,
|
|
206
|
+
` };`,
|
|
207
|
+
"}",
|
|
208
|
+
"",
|
|
209
|
+
"export function baseScript(base) {",
|
|
210
|
+
" // A `<` inside a script body ends the element carrying it, whatever the",
|
|
211
|
+
" // JSON around it says.",
|
|
212
|
+
` return "window[" + ${JSON.stringify(JSON.stringify(BASE_GLOBAL))} + "]=" +`,
|
|
213
|
+
" JSON.stringify(base).replace(/</g, \"\\\\u003c\");",
|
|
214
|
+
"}",
|
|
215
|
+
""
|
|
216
|
+
].join("\n");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The entry script is dropped from the document because React emits it itself,
|
|
220
|
+
* as a bootstrap module whose URL carries the base — the same string the rest
|
|
221
|
+
* of the document is built from.
|
|
222
|
+
*/
|
|
223
|
+
function withoutEntryScript(html, clientEntry) {
|
|
224
|
+
return html.replace(/[ \t]*<script\b[^>]*><\/script>\n?/g, (tag) => {
|
|
225
|
+
const src = /\bsrc=["']([^"']+)["']/.exec(tag);
|
|
226
|
+
return src && normalize(src[1]) === clientEntry ? "" : tag;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Stylesheets go in as links rather than through React, so they are in the
|
|
231
|
+
* bytes the shell is committed with — a stylesheet React discovers while
|
|
232
|
+
* rendering arrives after the markup it styles.
|
|
233
|
+
*/
|
|
234
|
+
function withStylesheets(html, stylesheets) {
|
|
235
|
+
if (stylesheets.length === 0) return html;
|
|
236
|
+
const links = stylesheets.map((href) => `<link rel="stylesheet" href="${BASE_SLOT}/${href}">`).join("");
|
|
237
|
+
return html.includes("</head>") ? html.replace("</head>", `${links}</head>`) : links + html;
|
|
238
|
+
}
|
|
239
|
+
/** A JS expression for `html`, with the base slot as the one live piece. */
|
|
240
|
+
function interpolated(html) {
|
|
241
|
+
return html.split(BASE_SLOT).map((part) => JSON.stringify(part)).join(" + base + ");
|
|
242
|
+
}
|
|
243
|
+
function normalize(file) {
|
|
244
|
+
return file.replace(/\\/g, "/").replace(/^\.?\//, "");
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
export { wawesomeReact as default, wawesomeReact };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wawesome",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "CLI tool for building and deploying serverless functions on wawesome.io platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
"types": "./dist/guest-parity.d.mts",
|
|
18
18
|
"default": "./dist/guest-parity.mjs"
|
|
19
19
|
},
|
|
20
|
+
"./vite": {
|
|
21
|
+
"types": "./dist/vite.d.mts",
|
|
22
|
+
"default": "./dist/vite.mjs"
|
|
23
|
+
},
|
|
24
|
+
"./vite-env": {
|
|
25
|
+
"types": "./vite-env.d.ts"
|
|
26
|
+
},
|
|
20
27
|
"./vitest-setup": {
|
|
21
28
|
"types": "./dist/vitest-setup.d.mts",
|
|
22
29
|
"default": "./dist/vitest-setup.mjs"
|
|
@@ -25,7 +32,8 @@
|
|
|
25
32
|
},
|
|
26
33
|
"files": [
|
|
27
34
|
"bin",
|
|
28
|
-
"dist"
|
|
35
|
+
"dist",
|
|
36
|
+
"vite-env.d.ts"
|
|
29
37
|
],
|
|
30
38
|
"scripts": {
|
|
31
39
|
"build": "tsdown",
|
|
@@ -45,9 +53,23 @@
|
|
|
45
53
|
"devDependencies": {
|
|
46
54
|
"@changesets/cli": "^2.31.1",
|
|
47
55
|
"@types/node": "^26.1.1",
|
|
56
|
+
"@types/react": "^19.2.7",
|
|
57
|
+
"@types/react-dom": "^19.2.4",
|
|
58
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
48
59
|
"publint": "^0.3.22",
|
|
60
|
+
"react": "^19.2.8",
|
|
61
|
+
"react-dom": "^19.2.8",
|
|
49
62
|
"tsdown": "^0.22.5",
|
|
50
63
|
"typescript": "^7.0.2",
|
|
64
|
+
"vite": "^8.2.2",
|
|
51
65
|
"vitest": "^4.1.10"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"vite": "^8.0.0"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"vite": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
52
74
|
}
|
|
53
75
|
}
|
package/vite-env.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The modules `wawesome/vite` generates, as TypeScript sees them.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written because they exist only during a build, so nothing can emit
|
|
5
|
+
* declarations for them. What they actually contain is `documentModule` and
|
|
6
|
+
* `baseModule` in `src/vite.ts`; the CLI's deploy-seam suite builds a real
|
|
7
|
+
* project and runs its bundle, which is what holds the two together.
|
|
8
|
+
*
|
|
9
|
+
* Reached through `"types": ["wawesome/vite-env"]` in a project's tsconfig.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
declare module "virtual:wawesome/document" {
|
|
13
|
+
/**
|
|
14
|
+
* The document with the rendered application cut out of it, resolved against
|
|
15
|
+
* `base` — the **Mount** the platform stripped off this request.
|
|
16
|
+
*/
|
|
17
|
+
export function documentShell(base: string): { before: string; after: string };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The client bundle's path, relative to the mount. Joined to a base to become
|
|
21
|
+
* the module the browser hydrates from.
|
|
22
|
+
*/
|
|
23
|
+
export const clientEntry: string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A script body that hands `base` to the browser, for
|
|
27
|
+
* `renderToReadableStream`'s `bootstrapScriptContent`. It is what
|
|
28
|
+
* `virtual:wawesome/base` reads, so the hydrating client resolves its URLs
|
|
29
|
+
* against exactly what the server rendered against.
|
|
30
|
+
*/
|
|
31
|
+
export function baseScript(base: string): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare module "virtual:wawesome/base" {
|
|
35
|
+
/** The base the server rendered this document against. Empty at the root. */
|
|
36
|
+
export const base: string;
|
|
37
|
+
}
|