wawesome 0.2.0 → 0.4.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 +26 -4
- package/dist/index.mjs +88 -33
- 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,32 @@ 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.
|
|
238
260
|
|
|
239
261
|
### Reserved headers
|
|
240
262
|
|
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.4.0";
|
|
724
763
|
//#endregion
|
|
725
764
|
//#region src/prompt.ts
|
|
726
765
|
/**
|
|
@@ -1192,11 +1231,10 @@ const MONTHS = [
|
|
|
1192
1231
|
];
|
|
1193
1232
|
/** Indented to sit inside the receipt the deploy already prints. */
|
|
1194
1233
|
function headroomLines(usage) {
|
|
1195
|
-
const lines = [
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
];
|
|
1234
|
+
const lines = [` Plan: ${usage.plan.name} — this deploy does not change your bill.`, ` Apps: ${usage.occupied_app_slots} / ${usage.plan.limits.app_slots} slots`];
|
|
1235
|
+
const stored = storedLine(usage);
|
|
1236
|
+
if (stored !== null) lines.push(stored);
|
|
1237
|
+
lines.push(` Usage: ${periodLabel(usage.period.start, usage.period.end)}`);
|
|
1200
1238
|
const entries = Object.entries(usage.allowances);
|
|
1201
1239
|
const labelWidth = widest(entries.map(([key]) => allowanceLabel(key)));
|
|
1202
1240
|
const amountWidth = widest(entries.map(([key, allowance]) => amount(key, allowance.used, allowance.limit)));
|
|
@@ -1209,6 +1247,17 @@ function headroomLines(usage) {
|
|
|
1209
1247
|
if (refused !== null) lines.push(` Refused: ${formatCount(refused)} request${refused === 1 ? "" : "s"} at your share`);
|
|
1210
1248
|
return lines;
|
|
1211
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* What the workspace holds against what its plan grants. Beside the App slots
|
|
1252
|
+
* rather than among the allowances below, because neither is spent over the
|
|
1253
|
+
* period those are read against.
|
|
1254
|
+
*/
|
|
1255
|
+
function storedLine(usage) {
|
|
1256
|
+
const occupied = usage.occupied_stored_bytes;
|
|
1257
|
+
const granted = usage.plan.limits.stored_bytes;
|
|
1258
|
+
if (typeof occupied !== "number" || typeof granted !== "number") return null;
|
|
1259
|
+
return ` Storage: ${formatBytes(occupied)} / ${formatBytes(granted)}`;
|
|
1260
|
+
}
|
|
1212
1261
|
function refusedAtShare(usage) {
|
|
1213
1262
|
const refused = usage.refusals?.at_granted_share;
|
|
1214
1263
|
if (typeof refused !== "number" || !Number.isFinite(refused) || refused <= 0) return null;
|
|
@@ -1265,6 +1314,21 @@ function widest(values) {
|
|
|
1265
1314
|
return values.reduce((longest, value) => Math.max(longest, value.length), 0);
|
|
1266
1315
|
}
|
|
1267
1316
|
//#endregion
|
|
1317
|
+
//#region src/billing.ts
|
|
1318
|
+
function billingPageUrl() {
|
|
1319
|
+
const base = getDashboardUrl().replace(/\/+$/, "");
|
|
1320
|
+
try {
|
|
1321
|
+
return new URL("billing", `${base}/`).toString();
|
|
1322
|
+
} catch {
|
|
1323
|
+
return `${base}/billing`;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/** The rule itself is the gateway's prose, and is deliberately not restated here. */
|
|
1327
|
+
function planLimitAdvice(reason) {
|
|
1328
|
+
if (reason !== "app-slots-exhausted" && reason !== "storage-exhausted") return "";
|
|
1329
|
+
return `Where to resolve it: ${billingPageUrl()}`;
|
|
1330
|
+
}
|
|
1331
|
+
//#endregion
|
|
1268
1332
|
//#region src/assets.ts
|
|
1269
1333
|
/**
|
|
1270
1334
|
* The files under `dir`, hashed.
|
|
@@ -1616,6 +1680,8 @@ async function uploadAssets(creds, app, funcName, bundle, assets, isVerbose) {
|
|
|
1616
1680
|
const errorBody = await res.text();
|
|
1617
1681
|
const refusal = rejectionOf(errorBody, res.status, `Upload of '${asset.path}' failed (HTTP ${res.status}).`);
|
|
1618
1682
|
console.error(`[wawesome] Error: ${refusal.message}`);
|
|
1683
|
+
const advice = planLimitAdvice(refusal.reason);
|
|
1684
|
+
if (advice) console.error(`[wawesome] ${advice}`);
|
|
1619
1685
|
if (isVerbose) console.error(`[wawesome:verbose] Response: ${errorBody}`);
|
|
1620
1686
|
process.exit(1);
|
|
1621
1687
|
}
|
|
@@ -1623,20 +1689,6 @@ async function uploadAssets(creds, app, funcName, bundle, assets, isVerbose) {
|
|
|
1623
1689
|
console.log(`[wawesome] ✅ ${toUpload.length} asset(s) uploaded.`);
|
|
1624
1690
|
}
|
|
1625
1691
|
//#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
1692
|
//#region src/env.ts
|
|
1641
1693
|
const STANDARD_SECRET_MESSAGES = [
|
|
1642
1694
|
"Encrypted at rest using AES-256",
|
|
@@ -2559,7 +2611,7 @@ async function wireUp(creds, appSlug, manifest, answers) {
|
|
|
2559
2611
|
try {
|
|
2560
2612
|
await ensureApp(creds, appSlug);
|
|
2561
2613
|
} catch (err) {
|
|
2562
|
-
fail(errorText(err), ...[
|
|
2614
|
+
fail(errorText(err), ...[planLimitAdvice(reasonOf(err)), scaffolded].filter(Boolean));
|
|
2563
2615
|
}
|
|
2564
2616
|
for (const { declared, value } of answers) {
|
|
2565
2617
|
if (!value) {
|
|
@@ -2815,6 +2867,8 @@ export default {
|
|
|
2815
2867
|
}
|
|
2816
2868
|
//#endregion
|
|
2817
2869
|
//#region src/version.ts
|
|
2870
|
+
/** How both surfaces name a version nobody can switch to. */
|
|
2871
|
+
const RECLAIMED = "⚠ [assets reclaimed]";
|
|
2818
2872
|
async function fetchVersions(gatewayUrl, tenantJwt, app, funcName, env, isVerbose) {
|
|
2819
2873
|
const url = `${gatewayUrl}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/versions?environment=${encodeURIComponent(env)}`;
|
|
2820
2874
|
if (isVerbose) console.log(`[wawesome:verbose] GET ${url}`);
|
|
@@ -2864,7 +2918,8 @@ async function listVersions(options) {
|
|
|
2864
2918
|
const hashStr = v.cwasm_hash.slice(0, 8).padEnd(8);
|
|
2865
2919
|
const dateStr = new Date(v.created_at).toISOString().replace("T", " ").slice(0, 19).padEnd(20);
|
|
2866
2920
|
const statusStr = v.status.padEnd(9);
|
|
2867
|
-
|
|
2921
|
+
const badge = isActive ? "\x1B[32m★ [active]\x1B[0m" : v.rollbackable === false ? `\x1b[33m${RECLAIMED}\x1b[0m` : "";
|
|
2922
|
+
console.log(`${verStr} | ${hashStr} | ${dateStr} | ${statusStr} | ${badge}`);
|
|
2868
2923
|
}
|
|
2869
2924
|
console.log("");
|
|
2870
2925
|
}
|
|
@@ -2912,7 +2967,7 @@ async function switchVersion(targetInput, options) {
|
|
|
2912
2967
|
const isActive = v.version_number === data.active_version_number;
|
|
2913
2968
|
const hashShort = v.cwasm_hash.slice(0, 8);
|
|
2914
2969
|
const dateStr = new Date(v.created_at).toISOString().replace("T", " ").slice(0, 19);
|
|
2915
|
-
const badge = isActive ? " ★ [active]" : "";
|
|
2970
|
+
const badge = isActive ? " ★ [active]" : v.rollbackable === false ? ` ${RECLAIMED}` : "";
|
|
2916
2971
|
return {
|
|
2917
2972
|
name: `v${v.version_number} (${hashShort}) • ${dateStr}${badge}`,
|
|
2918
2973
|
value: v.version_number
|
|
@@ -2943,9 +2998,9 @@ async function switchVersion(targetInput, options) {
|
|
|
2943
2998
|
})
|
|
2944
2999
|
});
|
|
2945
3000
|
if (!deployRes.ok) {
|
|
2946
|
-
const
|
|
2947
|
-
console.error(`[wawesome] Error:
|
|
2948
|
-
if (isVerbose) console.error(`[wawesome:verbose] Response: ${
|
|
3001
|
+
const rejection = await asGatewayError(deployRes, `Failed to set active version (HTTP ${deployRes.status}).`);
|
|
3002
|
+
console.error(`[wawesome] Error: ${rejection.message}`);
|
|
3003
|
+
if (isVerbose) console.error(`[wawesome:verbose] Response: ${rejection.body}`);
|
|
2949
3004
|
process.exit(1);
|
|
2950
3005
|
}
|
|
2951
3006
|
console.log(`\n======================================================`);
|
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.4.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
|
+
}
|