@moku-labs/web 0.1.0-alpha.1 → 0.1.0-alpha.4
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 +36 -1
- package/dist/bin/moku.cjs +575 -1
- package/dist/bin/moku.mjs +576 -2
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/{factory-CixCpR9C.cjs → factory-CMOo4n6a.cjs} +13 -1
- package/dist/{factory-BBVQO5ZG.d.mts → factory-DRFGSslp.d.mts} +26 -2
- package/dist/{factory-DwpBwjDk.mjs → factory-DiKypQqs.mjs} +2 -2
- package/dist/{factory-D0m7Xil2.d.cts → factory-k-YoScgB.d.cts} +26 -2
- package/dist/{index-CWdZdegx.d.mts → index-DH3jlpNi.d.mts} +215 -61
- package/dist/{route-builder-Lv6HUVvP.d.cts → index-DaY7vTuo.d.cts} +215 -61
- package/dist/index.cjs +75 -3
- package/dist/index.d.cts +41 -40
- package/dist/index.d.mts +41 -40
- package/dist/index.mjs +6 -5
- package/dist/plugins/head/build.d.cts +1 -1
- package/dist/plugins/head/build.d.mts +1 -1
- package/dist/plugins/head/build.mjs +1 -1
- package/dist/plugins/spa/index.cjs +1 -1
- package/dist/plugins/spa/index.d.cts +1 -1
- package/dist/plugins/spa/index.d.mts +1 -1
- package/dist/plugins/spa/index.mjs +1 -1
- package/dist/{primitives-BBo4wxUL.d.cts → primitives-DKgZfRAO.d.mts} +4 -2
- package/dist/{primitives-kuZFxqV7.d.mts → primitives-yZqQkOVR.d.cts} +4 -2
- package/dist/{project-C1vtMxE8.cjs → project-B8z4jeMC.cjs} +307 -5
- package/dist/{project-BTNUWbGQ.mjs → project-guCYpUeD.mjs} +232 -8
- package/dist/test.cjs +2 -2
- package/dist/test.d.cts +1 -1
- package/dist/test.d.mts +1 -1
- package/dist/test.mjs +2 -2
- package/dist/wrangler-BlZWVmX9.mjs +369 -0
- package/dist/wrangler-Bomk9mU-.cjs +423 -0
- package/package.json +9 -1
- /package/dist/{primitives-gO5i1tD8.mjs → primitives-Dhko-oLM.mjs} +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { t as __exportAll } from "./chunk-DQk6qfdC.mjs";
|
|
2
|
+
import { l as site, o as head, p as createPlugin, s as router, u as i18n } from "./factory-DiKypQqs.mjs";
|
|
3
|
+
import { a as runWrangler, c as readWranglerConfig, i as extractDeploymentInfo, r as buildWranglerArgs } from "./wrangler-BlZWVmX9.mjs";
|
|
4
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
5
|
import { readFile, readdir } from "node:fs/promises";
|
|
4
|
-
import { resolve, sep } from "node:path";
|
|
6
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
5
7
|
import matter from "gray-matter";
|
|
6
8
|
import rehypeShiki from "@shikijs/rehype";
|
|
7
9
|
import rehypeRaw from "rehype-raw";
|
|
@@ -404,7 +406,7 @@ const wireContentApi = (ctx) => {
|
|
|
404
406
|
//#endregion
|
|
405
407
|
//#region src/plugins/content/index.ts
|
|
406
408
|
/** @file content plugin: markdown pipeline + invalidate(). Complex tier. */
|
|
407
|
-
const defaultConfig$
|
|
409
|
+
const defaultConfig$2 = {
|
|
408
410
|
dir: "",
|
|
409
411
|
trustedContent: false,
|
|
410
412
|
rehypePlugins: [],
|
|
@@ -412,7 +414,7 @@ const defaultConfig$1 = {
|
|
|
412
414
|
};
|
|
413
415
|
const content = createPlugin("content", {
|
|
414
416
|
depends: [i18n],
|
|
415
|
-
config: defaultConfig$
|
|
417
|
+
config: defaultConfig$2,
|
|
416
418
|
events: (register) => ({
|
|
417
419
|
"content:ready": register("Articles loaded"),
|
|
418
420
|
"content:invalidated": register("Paths invalidated")
|
|
@@ -943,7 +945,7 @@ const validateBuildConfig = (ctx) => {
|
|
|
943
945
|
//#endregion
|
|
944
946
|
//#region src/plugins/build/index.ts
|
|
945
947
|
/** @file build plugin: app.build.run() orchestrator + in-memory manifest. Complex tier. */
|
|
946
|
-
const defaultConfig = {
|
|
948
|
+
const defaultConfig$1 = {
|
|
947
949
|
outdir: "dist",
|
|
948
950
|
mode: "production",
|
|
949
951
|
renderMode: "hybrid"
|
|
@@ -955,7 +957,7 @@ const build = createPlugin("build", {
|
|
|
955
957
|
router,
|
|
956
958
|
head
|
|
957
959
|
],
|
|
958
|
-
config: defaultConfig,
|
|
960
|
+
config: defaultConfig$1,
|
|
959
961
|
events: (register) => ({
|
|
960
962
|
"build:phase": register("Build phase started"),
|
|
961
963
|
"build:complete": register("Build pipeline complete")
|
|
@@ -965,6 +967,228 @@ const build = createPlugin("build", {
|
|
|
965
967
|
onInit: validateBuildConfig
|
|
966
968
|
});
|
|
967
969
|
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/plugins/build/types.ts
|
|
972
|
+
var types_exports$9 = /* @__PURE__ */ __exportAll({});
|
|
973
|
+
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/plugins/content/types.ts
|
|
976
|
+
var types_exports$8 = /* @__PURE__ */ __exportAll({});
|
|
977
|
+
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/plugins/deploy/api.ts
|
|
980
|
+
/** @file deploy plugin API factory — orchestrates app.deploy.run(). */
|
|
981
|
+
/** Free-tier Cloudflare Pages limit. The paid tier (100,000) requires extra env. */
|
|
982
|
+
const FREE_TIER_FILE_LIMIT = 2e4;
|
|
983
|
+
/** Per-file size cap for Cloudflare Pages (25 MiB). */
|
|
984
|
+
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
|
985
|
+
const sanitizedProcessEnv = () => {
|
|
986
|
+
const out = {};
|
|
987
|
+
for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") out[k] = v;
|
|
988
|
+
return out;
|
|
989
|
+
};
|
|
990
|
+
const checkFile = (path, state) => {
|
|
991
|
+
state.count += 1;
|
|
992
|
+
if (statSync(path).size > MAX_FILE_SIZE_BYTES) state.oversize = path;
|
|
993
|
+
};
|
|
994
|
+
const walkOutdir = (current, state) => {
|
|
995
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
996
|
+
if (state.oversize !== null) return;
|
|
997
|
+
const path = join(current, entry.name);
|
|
998
|
+
if (entry.isDirectory()) walkOutdir(path, state);
|
|
999
|
+
else if (entry.isFile()) checkFile(path, state);
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
/** Recursively count files and check max size under `dir`. Short-circuits on first oversize file. */
|
|
1003
|
+
const inspectOutdir = (dir) => {
|
|
1004
|
+
const state = {
|
|
1005
|
+
count: 0,
|
|
1006
|
+
oversize: null
|
|
1007
|
+
};
|
|
1008
|
+
walkOutdir(dir, state);
|
|
1009
|
+
return {
|
|
1010
|
+
fileCount: state.count,
|
|
1011
|
+
oversize: state.oversize
|
|
1012
|
+
};
|
|
1013
|
+
};
|
|
1014
|
+
/** Throw if any pre-flight constraint on the resolved outdir fails. */
|
|
1015
|
+
const assertOutdirReady = (outdir, outdirAbsolute) => {
|
|
1016
|
+
if (!existsSync(outdirAbsolute)) throw new Error(`deploy: outdir "${outdir}" does not exist — run \`moku build\` first (or pass --build).`);
|
|
1017
|
+
const inspection = inspectOutdir(outdirAbsolute);
|
|
1018
|
+
if (inspection.fileCount === 0) throw new Error(`deploy: outdir "${outdir}" is empty — nothing to deploy.`);
|
|
1019
|
+
if (inspection.fileCount > FREE_TIER_FILE_LIMIT) throw new Error(`deploy: outdir contains ${inspection.fileCount} files; free tier limit is ${FREE_TIER_FILE_LIMIT}. Paid tier supports up to 100,000 files (requires PAGES_WRANGLER_MAJOR_VERSION=4 in CI env).`);
|
|
1020
|
+
if (inspection.oversize !== null) throw new Error(`deploy: file "${inspection.oversize}" exceeds Cloudflare Pages per-file limit (25 MiB).`);
|
|
1021
|
+
return inspection;
|
|
1022
|
+
};
|
|
1023
|
+
/** Resolve outdir from `wrangler.jsonc#pages_build_output_dir` or throw if missing. */
|
|
1024
|
+
const resolveOutdir = (cwd) => {
|
|
1025
|
+
const wrangler = readWranglerConfig(cwd);
|
|
1026
|
+
if (wrangler === null) throw new Error("deploy: wrangler.jsonc not found — run `moku deploy init` first");
|
|
1027
|
+
return wrangler.pages_build_output_dir;
|
|
1028
|
+
};
|
|
1029
|
+
/**
|
|
1030
|
+
* Build the public `app.deploy` API surface.
|
|
1031
|
+
*
|
|
1032
|
+
* `run()` reads outdir from `wrangler.jsonc#pages_build_output_dir` (the
|
|
1033
|
+
* deploy-time SSoT — `DeployConfig.outdir` is only the init-time default).
|
|
1034
|
+
* Errors with a "run `moku deploy init` first" message if `wrangler.jsonc` is
|
|
1035
|
+
* missing.
|
|
1036
|
+
*
|
|
1037
|
+
* @param ctx - The deploy plugin context.
|
|
1038
|
+
* @param deps - Optional injectables for testing.
|
|
1039
|
+
* @returns The {@link DeployApi}.
|
|
1040
|
+
*/
|
|
1041
|
+
/** Trigger the optional pre-build hook for `--build`. */
|
|
1042
|
+
const maybeRunBuild = async (deps, build) => {
|
|
1043
|
+
if (build !== true) return;
|
|
1044
|
+
if (deps.runBuild === void 0) throw new Error("deploy: --build flag set but no `runBuild` hook provided to createDeployApi. Wire `app.build.run` through the plugin index.");
|
|
1045
|
+
await deps.runBuild();
|
|
1046
|
+
};
|
|
1047
|
+
/** Invoke wrangler and shape its output into a {@link DeployResult}. */
|
|
1048
|
+
const invokeWranglerForDeploy = async (ctx, deps, outdir, branch, startedAt, now) => {
|
|
1049
|
+
const env = deps.env ?? sanitizedProcessEnv();
|
|
1050
|
+
const info = extractDeploymentInfo((await runWrangler(buildWranglerArgs(ctx.config.target, outdir, branch), env, deps.spawn)).ndJson);
|
|
1051
|
+
if (info === null) ctx.log.warn("deploy:no-deployment-info", { note: "wrangler exited 0 but no deployment URL/id was found in ND-JSON output." });
|
|
1052
|
+
return {
|
|
1053
|
+
url: info?.url ?? "",
|
|
1054
|
+
deploymentId: info?.deploymentId ?? "",
|
|
1055
|
+
branch,
|
|
1056
|
+
durationMs: now() - startedAt
|
|
1057
|
+
};
|
|
1058
|
+
};
|
|
1059
|
+
/**
|
|
1060
|
+
* Build the public `app.deploy` API surface.
|
|
1061
|
+
*
|
|
1062
|
+
* `run()` reads outdir from `wrangler.jsonc#pages_build_output_dir` (the
|
|
1063
|
+
* deploy-time SSoT — `DeployConfig.outdir` is only the init-time default).
|
|
1064
|
+
* Errors with a "run `moku deploy init` first" message if `wrangler.jsonc` is
|
|
1065
|
+
* missing.
|
|
1066
|
+
*
|
|
1067
|
+
* @param ctx - The deploy plugin context.
|
|
1068
|
+
* @param deps - Optional injectables for testing.
|
|
1069
|
+
* @returns The {@link DeployApi}.
|
|
1070
|
+
*/
|
|
1071
|
+
const createDeployApi = (ctx, deps = {}) => {
|
|
1072
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1073
|
+
const now = deps.now ?? Date.now;
|
|
1074
|
+
const run = async (options = {}) => {
|
|
1075
|
+
await maybeRunBuild(deps, options.build);
|
|
1076
|
+
const outdir = resolveOutdir(cwd);
|
|
1077
|
+
const branch = options.branch ?? ctx.config.productionBranch;
|
|
1078
|
+
const inspection = assertOutdirReady(outdir, join(cwd, outdir));
|
|
1079
|
+
const deployResult = await invokeWranglerForDeploy(ctx, deps, outdir, branch, now(), now);
|
|
1080
|
+
ctx.state.lastDeployment = deployResult;
|
|
1081
|
+
ctx.log.info("deploy:complete", {
|
|
1082
|
+
url: deployResult.url,
|
|
1083
|
+
branch,
|
|
1084
|
+
durationMs: deployResult.durationMs,
|
|
1085
|
+
files: inspection.fileCount
|
|
1086
|
+
});
|
|
1087
|
+
return deployResult;
|
|
1088
|
+
};
|
|
1089
|
+
const getLastDeployment = () => ctx.state.lastDeployment;
|
|
1090
|
+
return {
|
|
1091
|
+
run,
|
|
1092
|
+
getLastDeployment
|
|
1093
|
+
};
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region src/plugins/deploy/state.ts
|
|
1098
|
+
/**
|
|
1099
|
+
* Create a fresh deploy state.
|
|
1100
|
+
*
|
|
1101
|
+
* `lastDeployment` starts as `null` and is set after the first successful
|
|
1102
|
+
* `app.deploy.run()` call. State is per-`createApp` instance — never a module
|
|
1103
|
+
* singleton — so parallel vitest workers do not share state.
|
|
1104
|
+
*
|
|
1105
|
+
* @returns A new {@link DeployState}.
|
|
1106
|
+
*/
|
|
1107
|
+
const createDeployState = () => ({ lastDeployment: null });
|
|
1108
|
+
|
|
1109
|
+
//#endregion
|
|
1110
|
+
//#region src/plugins/deploy/validate.ts
|
|
1111
|
+
/** @file deploy plugin onInit hook — config-shape validation only. NEVER spawns wrangler. */
|
|
1112
|
+
const VALID_TARGETS = new Set(["pages", "workers"]);
|
|
1113
|
+
/**
|
|
1114
|
+
* Validate `DeployConfig` shape at `onInit` time.
|
|
1115
|
+
*
|
|
1116
|
+
* Checks: `target` is in the valid enum; `outdir` is a non-empty string and
|
|
1117
|
+
* does not escape the current working directory via `..` traversal;
|
|
1118
|
+
* `productionBranch` is a non-empty string.
|
|
1119
|
+
*
|
|
1120
|
+
* Does NOT check wrangler presence — `ensureWrangler()` in `wrangler.ts` is
|
|
1121
|
+
* lazy and called only on first `run()`/`init()` invocation. This keeps
|
|
1122
|
+
* `moku dev`, `moku build`, and `moku preview` unaffected by wrangler absence.
|
|
1123
|
+
*
|
|
1124
|
+
* @param ctx - The deploy plugin context.
|
|
1125
|
+
* @throws Error when any config field is structurally invalid.
|
|
1126
|
+
*/
|
|
1127
|
+
const validateDeployConfig = (ctx) => {
|
|
1128
|
+
const { target, outdir, productionBranch } = ctx.config;
|
|
1129
|
+
if (!VALID_TARGETS.has(target)) throw new Error(`deploy: target must be "pages" or "workers" (got "${target}")`);
|
|
1130
|
+
if (typeof outdir !== "string" || outdir === "") throw new Error("deploy: outdir must be a non-empty string");
|
|
1131
|
+
const resolved = isAbsolute(outdir) ? outdir : resolve(process.cwd(), outdir);
|
|
1132
|
+
const cwd = process.cwd();
|
|
1133
|
+
if (!resolved.startsWith(cwd)) throw new Error(`deploy: outdir "${outdir}" must resolve inside the project root (got "${resolved}")`);
|
|
1134
|
+
if (typeof productionBranch !== "string" || productionBranch === "") throw new Error("deploy: productionBranch must be a non-empty string");
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
//#endregion
|
|
1138
|
+
//#region src/plugins/deploy/index.ts
|
|
1139
|
+
/** @file deploy plugin: Cloudflare Pages publishing via wrangler subprocess. Standard tier.
|
|
1140
|
+
*
|
|
1141
|
+
* Wave 4 placement alongside `build`. `depends: []` — no plugin-graph edge to `build`;
|
|
1142
|
+
* ordering via the framework's plugin registration array is sufficient. Consumers who want
|
|
1143
|
+
* auto-outdir resolution must register the `build` plugin alongside this one; otherwise the
|
|
1144
|
+
* `'dist'` default fallback applies.
|
|
1145
|
+
*
|
|
1146
|
+
* @see README.md
|
|
1147
|
+
*/
|
|
1148
|
+
const defaultConfig = {
|
|
1149
|
+
target: "pages",
|
|
1150
|
+
outdir: "dist",
|
|
1151
|
+
productionBranch: "main"
|
|
1152
|
+
};
|
|
1153
|
+
const deploy = createPlugin("deploy", {
|
|
1154
|
+
config: defaultConfig,
|
|
1155
|
+
createState: createDeployState,
|
|
1156
|
+
api: createDeployApi,
|
|
1157
|
+
onInit: validateDeployConfig
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
//#endregion
|
|
1161
|
+
//#region src/plugins/deploy/types.ts
|
|
1162
|
+
var types_exports$7 = /* @__PURE__ */ __exportAll({});
|
|
1163
|
+
|
|
1164
|
+
//#endregion
|
|
1165
|
+
//#region src/plugins/env/types.ts
|
|
1166
|
+
var types_exports$6 = /* @__PURE__ */ __exportAll({});
|
|
1167
|
+
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/plugins/head/types.ts
|
|
1170
|
+
var types_exports$5 = /* @__PURE__ */ __exportAll({});
|
|
1171
|
+
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/plugins/i18n/types.ts
|
|
1174
|
+
var types_exports$4 = /* @__PURE__ */ __exportAll({});
|
|
1175
|
+
|
|
1176
|
+
//#endregion
|
|
1177
|
+
//#region src/plugins/log/types.ts
|
|
1178
|
+
var types_exports$3 = /* @__PURE__ */ __exportAll({});
|
|
1179
|
+
|
|
1180
|
+
//#endregion
|
|
1181
|
+
//#region src/plugins/router/types.ts
|
|
1182
|
+
var types_exports$2 = /* @__PURE__ */ __exportAll({});
|
|
1183
|
+
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/plugins/site/types.ts
|
|
1186
|
+
var types_exports$1 = /* @__PURE__ */ __exportAll({});
|
|
1187
|
+
|
|
1188
|
+
//#endregion
|
|
1189
|
+
//#region src/plugins/spa/types.ts
|
|
1190
|
+
var types_exports = /* @__PURE__ */ __exportAll({});
|
|
1191
|
+
|
|
968
1192
|
//#endregion
|
|
969
1193
|
//#region src/project.ts
|
|
970
1194
|
/**
|
|
@@ -1017,4 +1241,4 @@ function extractSpecs(routes) {
|
|
|
1017
1241
|
}
|
|
1018
1242
|
|
|
1019
1243
|
//#endregion
|
|
1020
|
-
export {
|
|
1244
|
+
export { types_exports$3 as a, types_exports$6 as c, types_exports$8 as d, types_exports$9 as f, types_exports$2 as i, types_exports$7 as l, content as m, types_exports as n, types_exports$4 as o, build as p, types_exports$1 as r, types_exports$5 as s, project as t, deploy as u };
|
package/dist/test.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
-
const require_project = require('./project-
|
|
3
|
-
const require_factory = require('./factory-
|
|
2
|
+
const require_project = require('./project-B8z4jeMC.cjs');
|
|
3
|
+
const require_factory = require('./factory-CMOo4n6a.cjs');
|
|
4
4
|
const require_index = require('./index.cjs');
|
|
5
5
|
let _moku_labs_core = require("@moku-labs/core");
|
|
6
6
|
|
package/dist/test.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { G as EnvState, H as EnvApi, J as LogApi, K as EnvVarSpec, U as EnvConfig, W as EnvProvider, Y as LogState, f as WebAppConfig, x as RouteBuilder } from "./index-DaY7vTuo.cjs";
|
|
2
2
|
import { WebApp } from "./index.cjs";
|
|
3
3
|
import * as _moku_labs_core0 from "@moku-labs/core";
|
|
4
4
|
|
package/dist/test.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { G as EnvState, H as EnvApi, J as LogApi, K as EnvVarSpec, U as EnvConfig, W as EnvProvider, Y as LogState, f as WebAppConfig, x as RouteBuilder } from "./index-DH3jlpNi.mjs";
|
|
2
2
|
import { WebApp } from "./index.mjs";
|
|
3
3
|
import * as _moku_labs_core0 from "@moku-labs/core";
|
|
4
4
|
|
package/dist/test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import "./project-
|
|
1
|
+
import { b as createEnvApi, g as createLogApi, h as createLogState, v as validateSchema, y as createEnvState } from "./factory-DiKypQqs.mjs";
|
|
2
|
+
import "./project-guCYpUeD.mjs";
|
|
3
3
|
import { createApp } from "./index.mjs";
|
|
4
4
|
import { createCorePlugin } from "@moku-labs/core";
|
|
5
5
|
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
//#region src/plugins/deploy/generators/wrangler-config.ts
|
|
7
|
+
/** @file deploy plugin wrangler.jsonc generator — emit + read + diff. */
|
|
8
|
+
const todayIsoDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
9
|
+
/**
|
|
10
|
+
* Generate the textual contents of `wrangler.jsonc`.
|
|
11
|
+
*
|
|
12
|
+
* The file is written by `init` once and treated as the deploy-time SSoT
|
|
13
|
+
* thereafter. A header comment warns against using Cloudflare's dashboard
|
|
14
|
+
* git-auto-build, which does NOT recognize `.jsonc`.
|
|
15
|
+
*
|
|
16
|
+
* @param input - Slug, outdir, and optional compatibility date.
|
|
17
|
+
* @returns JSONC text suitable for writing to `wrangler.jsonc`.
|
|
18
|
+
*/
|
|
19
|
+
const generateWranglerConfig = (input) => {
|
|
20
|
+
const compat = input.compatibilityDate ?? todayIsoDate();
|
|
21
|
+
const body = {
|
|
22
|
+
name: input.slug,
|
|
23
|
+
pages_build_output_dir: input.outdir,
|
|
24
|
+
compatibility_date: compat
|
|
25
|
+
};
|
|
26
|
+
return [
|
|
27
|
+
"// wrangler.jsonc — generated by `moku deploy init`.",
|
|
28
|
+
"// Do NOT use Cloudflare Pages dashboard git-push auto-build with this config —",
|
|
29
|
+
"// wrangler.jsonc is recognized by wrangler CLI only. Use .github/workflows/deploy.yml instead.",
|
|
30
|
+
"//",
|
|
31
|
+
"// Edit `name` to change the Cloudflare Pages project this repo deploys to.",
|
|
32
|
+
"// Edit `pages_build_output_dir` to change the upload directory.",
|
|
33
|
+
JSON.stringify(body, null, 2),
|
|
34
|
+
""
|
|
35
|
+
].join("\n");
|
|
36
|
+
};
|
|
37
|
+
/** Strip `//` line comments from JSONC text (block comments are not supported by the generator). */
|
|
38
|
+
const stripLineComments = (text) => text.split("\n").map((line) => {
|
|
39
|
+
const idx = line.indexOf("//");
|
|
40
|
+
if (idx === -1) return line;
|
|
41
|
+
const before = line.slice(0, idx);
|
|
42
|
+
if ((before.match(/"/g) ?? []).length % 2 === 1) return line;
|
|
43
|
+
return before.replace(/\s+$/, "");
|
|
44
|
+
}).join("\n");
|
|
45
|
+
/** Narrow an unknown parsed value into the {@link WranglerConfigShape} or `null`. */
|
|
46
|
+
const parseShape = (value) => {
|
|
47
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
48
|
+
const fields = {};
|
|
49
|
+
for (const [k, v] of Object.entries(value)) fields[k] = v;
|
|
50
|
+
const name = fields.name;
|
|
51
|
+
const outdir = fields.pages_build_output_dir;
|
|
52
|
+
const compat = fields.compatibility_date;
|
|
53
|
+
if (typeof name !== "string" || typeof outdir !== "string" || typeof compat !== "string") return null;
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
pages_build_output_dir: outdir,
|
|
57
|
+
compatibility_date: compat
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Read and parse `wrangler.jsonc` from disk.
|
|
62
|
+
*
|
|
63
|
+
* @param cwd - Project root.
|
|
64
|
+
* @returns The parsed shape, or `null` if missing or unparseable.
|
|
65
|
+
*/
|
|
66
|
+
const readWranglerConfig = (cwd) => {
|
|
67
|
+
const filePath = join(cwd, "wrangler.jsonc");
|
|
68
|
+
if (!existsSync(filePath)) return null;
|
|
69
|
+
try {
|
|
70
|
+
const stripped = stripLineComments(readFileSync(filePath, "utf8"));
|
|
71
|
+
return parseShape(JSON.parse(stripped));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Compute the diff between an on-disk config and the proposed new values.
|
|
78
|
+
*
|
|
79
|
+
* @param current - Parsed wrangler.jsonc on disk, or null if file is missing.
|
|
80
|
+
* @param proposed - Newly computed values (slug, outdir).
|
|
81
|
+
* @returns Array of {@link DiffEntry} — empty when no drift.
|
|
82
|
+
*/
|
|
83
|
+
const diffWranglerConfig = (current, proposed) => {
|
|
84
|
+
if (current === null) return [{
|
|
85
|
+
field: "name",
|
|
86
|
+
current: "(missing)",
|
|
87
|
+
proposed: proposed.slug
|
|
88
|
+
}, {
|
|
89
|
+
field: "pages_build_output_dir",
|
|
90
|
+
current: "(missing)",
|
|
91
|
+
proposed: proposed.outdir
|
|
92
|
+
}];
|
|
93
|
+
const out = [];
|
|
94
|
+
if (current.name !== proposed.slug) out.push({
|
|
95
|
+
field: "name",
|
|
96
|
+
current: current.name,
|
|
97
|
+
proposed: proposed.slug
|
|
98
|
+
});
|
|
99
|
+
if (current.pages_build_output_dir !== proposed.outdir) out.push({
|
|
100
|
+
field: "pages_build_output_dir",
|
|
101
|
+
current: current.pages_build_output_dir,
|
|
102
|
+
proposed: proposed.outdir
|
|
103
|
+
});
|
|
104
|
+
return out;
|
|
105
|
+
};
|
|
106
|
+
/** Write `wrangler.jsonc` content to disk. */
|
|
107
|
+
const writeWranglerConfig = async (cwd, content) => {
|
|
108
|
+
await writeFile(join(cwd, "wrangler.jsonc"), content, "utf8");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/plugins/deploy/wrangler.ts
|
|
113
|
+
/** @file deploy plugin subprocess wrapper — Bun.spawn wrangler invocation, scrubSecrets, error taxonomy.
|
|
114
|
+
*
|
|
115
|
+
* Security note: This file is the credential seam — every wrangler call flows through here. `scrubSecrets()`
|
|
116
|
+
* must be applied to every stderr capture BEFORE any `ctx.log` call. Tests for `scrubSecrets` must pass
|
|
117
|
+
* before any other module touches wrangler stderr.
|
|
118
|
+
*/
|
|
119
|
+
/**
|
|
120
|
+
* Single source of truth for the wrangler version, consumed by `ensureWrangler()` AND
|
|
121
|
+
* `generators/github-workflow.ts`. Bumping this constant atomically updates both local and
|
|
122
|
+
* CI surfaces. 4.34.0 is the documented minimum for 100k-file paid-tier Cloudflare Pages deploys.
|
|
123
|
+
*/
|
|
124
|
+
const MOKU_WRANGLER_VERSION = "4.34.0";
|
|
125
|
+
/**
|
|
126
|
+
* Pinned SHA of `cloudflare/wrangler-action`. Reviewable on each plugin release.
|
|
127
|
+
* Semver tags are mutable and supply-chain-risky; SHA-pinning is required by
|
|
128
|
+
* GitHub org/enterprise policy since August 2025.
|
|
129
|
+
*
|
|
130
|
+
* To bump: pick a fresh commit SHA from https://github.com/cloudflare/wrangler-action/commits/main,
|
|
131
|
+
* paste it here, and add a CHANGELOG entry.
|
|
132
|
+
*/
|
|
133
|
+
const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
134
|
+
const MIN_ENTROPY_BITS_PER_CHAR = 3.5;
|
|
135
|
+
const MIN_SECRET_LENGTH = 16;
|
|
136
|
+
const ERROR_CODE_PROJECT_NOT_FOUND = 8000007;
|
|
137
|
+
/** Default allowlist — values for these env vars are NEVER scrubbed. */
|
|
138
|
+
const DEFAULT_SCRUB_ALLOWLIST = new Set(["CLOUDFLARE_ACCOUNT_ID"]);
|
|
139
|
+
/**
|
|
140
|
+
* Build the wrangler argv array — pure function consumed by BOTH `runWrangler()` (runtime)
|
|
141
|
+
* AND `generators/github-workflow.ts` (CI workflow command string). Single point of change
|
|
142
|
+
* for the future `target: 'workers'` migration.
|
|
143
|
+
*
|
|
144
|
+
* The Pages branch deliberately omits `--project-name` — wrangler reads the project name
|
|
145
|
+
* from `wrangler.jsonc#name`, which is the deploy-time single source of truth.
|
|
146
|
+
*
|
|
147
|
+
* @param target - Deploy target. `'workers'` is not implemented in Phase 1.
|
|
148
|
+
* @param outdir - Resolved build output directory (typically read from wrangler.jsonc).
|
|
149
|
+
* @param branch - Branch name to deploy to (defaults to production branch).
|
|
150
|
+
* @returns Argv array suitable for `Bun.spawn(['bunx', 'wrangler@VERSION', ...args])`.
|
|
151
|
+
* @throws Error when `target === 'workers'`.
|
|
152
|
+
*/
|
|
153
|
+
const buildWranglerArgs = (target, outdir, branch) => {
|
|
154
|
+
if (target === "workers") throw new Error("deploy: target \"workers\" is not implemented in Phase 1 — Cloudflare Pages only");
|
|
155
|
+
return [
|
|
156
|
+
"pages",
|
|
157
|
+
"deploy",
|
|
158
|
+
outdir,
|
|
159
|
+
"--branch",
|
|
160
|
+
branch
|
|
161
|
+
];
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Compute Shannon entropy (bits per character) of a string.
|
|
165
|
+
*
|
|
166
|
+
* Exported for testing — used by `scrubSecrets` to decide whether a candidate value
|
|
167
|
+
* is high-entropy enough to be treated as a secret.
|
|
168
|
+
*
|
|
169
|
+
* @param value - The string to measure.
|
|
170
|
+
* @returns Entropy in bits per character (0 for empty strings).
|
|
171
|
+
*/
|
|
172
|
+
const shannonEntropy = (value) => {
|
|
173
|
+
if (value.length === 0) return 0;
|
|
174
|
+
const counts = /* @__PURE__ */ new Map();
|
|
175
|
+
for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
176
|
+
let entropy = 0;
|
|
177
|
+
for (const count of counts.values()) {
|
|
178
|
+
const p = count / value.length;
|
|
179
|
+
entropy -= p * Math.log2(p);
|
|
180
|
+
}
|
|
181
|
+
return entropy;
|
|
182
|
+
};
|
|
183
|
+
/** Escape a string for safe insertion into a regular expression. */
|
|
184
|
+
const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
185
|
+
/**
|
|
186
|
+
* Replace high-entropy environment-variable values in `text` with `[REDACTED]`.
|
|
187
|
+
*
|
|
188
|
+
* Algorithm: for each env var, scrub its value when (a) the var name is NOT in
|
|
189
|
+
* `allowlist`, (b) the value's length is at least {@link MIN_SECRET_LENGTH}, and
|
|
190
|
+
* (c) the value's Shannon entropy is at least {@link MIN_ENTROPY_BITS_PER_CHAR} bits/char.
|
|
191
|
+
*
|
|
192
|
+
* Account IDs and similar low-entropy non-secret identifiers are NEVER scrubbed
|
|
193
|
+
* (they appear in legitimate diagnostic URLs). Error code prefixes like `8000007`
|
|
194
|
+
* are too short and too low-entropy to be flagged.
|
|
195
|
+
*
|
|
196
|
+
* @param text - The text to scrub (typically wrangler stderr).
|
|
197
|
+
* @param env - Environment variable map whose values may appear in `text`.
|
|
198
|
+
* @param allowlist - Env var names whose values must NEVER be scrubbed. Defaults to {@link DEFAULT_SCRUB_ALLOWLIST}.
|
|
199
|
+
* @returns The scrubbed text — high-entropy values replaced by `[REDACTED]`.
|
|
200
|
+
*/
|
|
201
|
+
const scrubSecrets = (text, env, allowlist = DEFAULT_SCRUB_ALLOWLIST) => {
|
|
202
|
+
let scrubbed = text;
|
|
203
|
+
for (const [name, value] of Object.entries(env)) {
|
|
204
|
+
if (value === void 0 || value === "") continue;
|
|
205
|
+
if (allowlist.has(name)) continue;
|
|
206
|
+
if (value.length < MIN_SECRET_LENGTH) continue;
|
|
207
|
+
if (shannonEntropy(value) < MIN_ENTROPY_BITS_PER_CHAR) continue;
|
|
208
|
+
scrubbed = scrubbed.replaceAll(new RegExp(escapeRegExp(value), "g"), "[REDACTED]");
|
|
209
|
+
}
|
|
210
|
+
return scrubbed;
|
|
211
|
+
};
|
|
212
|
+
/** Read the optional `Bun` global without using an inline type assertion. */
|
|
213
|
+
const getBunGlobal = () => globalThis;
|
|
214
|
+
const defaultSpawn = (cmd, options) => {
|
|
215
|
+
const { Bun: bun } = getBunGlobal();
|
|
216
|
+
if (bun === void 0) throw new Error("deploy: Bun runtime is required to spawn wrangler subprocesses");
|
|
217
|
+
return bun.spawn(cmd, options);
|
|
218
|
+
};
|
|
219
|
+
/** Structured wrangler error carrying scrubbed stderr and classified `kind`. */
|
|
220
|
+
var WranglerError = class extends Error {
|
|
221
|
+
kind;
|
|
222
|
+
exitCode;
|
|
223
|
+
scrubbedStderr;
|
|
224
|
+
constructor(kind, exitCode, scrubbedStderr, message) {
|
|
225
|
+
super(message ?? `wrangler exited with ${exitCode} (${kind})`);
|
|
226
|
+
this.name = "WranglerError";
|
|
227
|
+
this.kind = kind;
|
|
228
|
+
this.exitCode = exitCode;
|
|
229
|
+
this.scrubbedStderr = scrubbedStderr;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Classify a wrangler failure into a structured {@link WranglerError}.
|
|
234
|
+
*
|
|
235
|
+
* Inspects parsed ND-JSON output first (looking for error code 8000007), then
|
|
236
|
+
* falls back to stderr pattern matching for JWT-expiry, network, and auth signals.
|
|
237
|
+
*
|
|
238
|
+
* @param exitCode - Process exit code.
|
|
239
|
+
* @param scrubbedStderr - Stderr text AFTER `scrubSecrets`.
|
|
240
|
+
* @param ndJson - Parsed ND-JSON entries from `WRANGLER_OUTPUT_FILE_PATH` (may be empty).
|
|
241
|
+
* @returns A {@link WranglerError} with a user-actionable message.
|
|
242
|
+
*/
|
|
243
|
+
const classifyWranglerError = (exitCode, scrubbedStderr, ndJson) => {
|
|
244
|
+
for (const entry of ndJson) {
|
|
245
|
+
const code = entry.code;
|
|
246
|
+
if (typeof code === "number" && code === ERROR_CODE_PROJECT_NOT_FOUND) return new WranglerError("project-not-found", exitCode, scrubbedStderr, "Cloudflare project not found. Run `wrangler pages project create <slug>` or `moku deploy init --create-project`.");
|
|
247
|
+
}
|
|
248
|
+
if (/Expired JWT|403[^\n]*upload/i.test(scrubbedStderr)) return new WranglerError("jwt-expired", exitCode, scrubbedStderr, "Wrangler JWT expired mid-upload (large site). Re-run `moku deploy` to retry.");
|
|
249
|
+
if (/ETIMEDOUT|ENETUNREACH|ECONNREFUSED/i.test(scrubbedStderr)) return new WranglerError("network", exitCode, scrubbedStderr, "Network error contacting Cloudflare. Check connectivity and retry.");
|
|
250
|
+
if (exitCode === 401 || exitCode === 403 || /unauthorized|forbidden/i.test(scrubbedStderr)) return new WranglerError("auth", exitCode, scrubbedStderr, "Cloudflare auth failed. Local: run `wrangler login`. CI: verify CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID secrets are set and the token has \"Account → Cloudflare Pages → Edit\" permission.");
|
|
251
|
+
return new WranglerError("unknown", exitCode, scrubbedStderr, `wrangler exited with code ${exitCode}.`);
|
|
252
|
+
};
|
|
253
|
+
/** Cast a parsed JSON value to a typed Record without an inline assertion. */
|
|
254
|
+
const asObject = (value) => {
|
|
255
|
+
if (typeof value !== "object" || value === null) return null;
|
|
256
|
+
if (Array.isArray(value)) return null;
|
|
257
|
+
const result = {};
|
|
258
|
+
for (const [k, v] of Object.entries(value)) result[k] = v;
|
|
259
|
+
return result;
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* Parse a wrangler ND-JSON output file (one JSON object per line).
|
|
263
|
+
*
|
|
264
|
+
* Lines that fail to parse are silently skipped — wrangler emits human-readable
|
|
265
|
+
* lines interleaved with structured entries in some failure modes.
|
|
266
|
+
*
|
|
267
|
+
* @param filePath - Path written by `WRANGLER_OUTPUT_FILE_PATH`.
|
|
268
|
+
* @returns Array of parsed entries; empty array if the file is missing or empty.
|
|
269
|
+
*/
|
|
270
|
+
const parseNdJson = async (filePath) => {
|
|
271
|
+
let raw;
|
|
272
|
+
try {
|
|
273
|
+
raw = await readFile(filePath, "utf8");
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
const lines = raw.split("\n").filter((line) => line.trim() !== "");
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const line of lines) try {
|
|
280
|
+
const parsed = asObject(JSON.parse(line));
|
|
281
|
+
if (parsed !== null) out.push(parsed);
|
|
282
|
+
} catch {}
|
|
283
|
+
return out;
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* Spawn `bunx wrangler@VERSION <args>` and return structured output.
|
|
287
|
+
*
|
|
288
|
+
* Always sets `WRANGLER_OUTPUT_FILE_PATH` (ND-JSON) and `WRANGLER_SEND_METRICS=false`.
|
|
289
|
+
* Always scrubs stderr via {@link scrubSecrets} before returning or throwing.
|
|
290
|
+
*
|
|
291
|
+
* @param args - Wrangler subcommand argv (e.g., `['pages', 'deploy', 'dist', '--branch', 'main']`).
|
|
292
|
+
* @param env - Environment passed to the subprocess. Token-bearing values are scrubbed from stderr output.
|
|
293
|
+
* @param spawn - Spawn implementation. Injectable for tests.
|
|
294
|
+
* @returns Stdout, scrubbed stderr, parsed ND-JSON, and exit code.
|
|
295
|
+
* @throws {@link WranglerError} on non-zero exit.
|
|
296
|
+
*/
|
|
297
|
+
const runWrangler = async (args, env, spawn = defaultSpawn) => {
|
|
298
|
+
const temporaryDir = await mkdtemp(join(tmpdir(), "moku-deploy-"));
|
|
299
|
+
const outputPath = join(temporaryDir, "wrangler-output.ndjson");
|
|
300
|
+
const spawnEnv = {
|
|
301
|
+
...env,
|
|
302
|
+
WRANGLER_OUTPUT_FILE_PATH: outputPath,
|
|
303
|
+
WRANGLER_SEND_METRICS: "false"
|
|
304
|
+
};
|
|
305
|
+
try {
|
|
306
|
+
const proc = spawn([
|
|
307
|
+
"bunx",
|
|
308
|
+
`wrangler@${MOKU_WRANGLER_VERSION}`,
|
|
309
|
+
...args
|
|
310
|
+
], {
|
|
311
|
+
env: spawnEnv,
|
|
312
|
+
stdout: "pipe",
|
|
313
|
+
stderr: "pipe"
|
|
314
|
+
});
|
|
315
|
+
const [stdoutText, stderrText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
316
|
+
const exitCode = await proc.exited;
|
|
317
|
+
const scrubbedStderr = scrubSecrets(stderrText, env);
|
|
318
|
+
const ndJson = await parseNdJson(outputPath);
|
|
319
|
+
if (exitCode !== 0) throw classifyWranglerError(exitCode, scrubbedStderr, ndJson);
|
|
320
|
+
return {
|
|
321
|
+
stdout: stdoutText,
|
|
322
|
+
scrubbedStderr,
|
|
323
|
+
ndJson,
|
|
324
|
+
exitCode
|
|
325
|
+
};
|
|
326
|
+
} finally {
|
|
327
|
+
await rm(temporaryDir, {
|
|
328
|
+
recursive: true,
|
|
329
|
+
force: true
|
|
330
|
+
}).catch(() => {});
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
/** Read `url` + deployment id from a single ND-JSON entry, if both are present. */
|
|
334
|
+
const readDeploymentEntry = (entry) => {
|
|
335
|
+
const url = entry.url;
|
|
336
|
+
const id = entry.deployment_id ?? entry.id;
|
|
337
|
+
if (typeof url !== "string" || typeof id !== "string") return null;
|
|
338
|
+
return {
|
|
339
|
+
url,
|
|
340
|
+
deploymentId: id
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* Extract the deployment URL and deployment ID from wrangler's ND-JSON output.
|
|
345
|
+
*
|
|
346
|
+
* Wrangler `pages deploy` writes a `deployment` entry containing the live URL
|
|
347
|
+
* and ID once the upload finalizes. Falls back to scanning the entries for any
|
|
348
|
+
* `url` field if the canonical entry shape is missing.
|
|
349
|
+
*
|
|
350
|
+
* @param ndJson - Parsed entries from {@link parseNdJson}.
|
|
351
|
+
* @returns `{ url, deploymentId }` if discoverable; `null` otherwise.
|
|
352
|
+
*/
|
|
353
|
+
const extractDeploymentInfo = (ndJson) => {
|
|
354
|
+
for (const entry of ndJson) {
|
|
355
|
+
const type = entry.type;
|
|
356
|
+
if (type === "deployment" || type === "pages-deployment") {
|
|
357
|
+
const info = readDeploymentEntry(entry);
|
|
358
|
+
if (info !== null) return info;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
for (const entry of ndJson) {
|
|
362
|
+
const info = readDeploymentEntry(entry);
|
|
363
|
+
if (info !== null) return info;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
//#endregion
|
|
369
|
+
export { runWrangler as a, readWranglerConfig as c, extractDeploymentInfo as i, writeWranglerConfig as l, WRANGLER_ACTION_SHA as n, diffWranglerConfig as o, buildWranglerArgs as r, generateWranglerConfig as s, MOKU_WRANGLER_VERSION as t };
|