create-daloy 1.0.0-rc.3 → 1.0.0-rc.5
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/bin/create-daloy.mjs +176 -11
- package/package.json +1 -1
- package/sbom.cdx.json +9 -9
- package/sbom.spdx.json +5 -5
- package/templates/bun-basic/AGENTS.md +1 -1
- package/templates/bun-basic/_agents/skills/daloyjs-best-practices/SKILL.md +42 -19
- package/templates/bun-basic/package.json +3 -3
- package/templates/bun-basic/src/build-app.ts +24 -22
- package/templates/cloudflare-worker/AGENTS.md +1 -1
- package/templates/cloudflare-worker/_agents/skills/daloyjs-best-practices/SKILL.md +40 -17
- package/templates/cloudflare-worker/package.json +2 -2
- package/templates/cloudflare-worker/src/index.ts +24 -22
- package/templates/deno-basic/AGENTS.md +1 -1
- package/templates/deno-basic/_agents/skills/daloyjs-best-practices/SKILL.md +40 -16
- package/templates/deno-basic/deno.json +5 -5
- package/templates/deno-basic/src/build-app.ts +24 -22
- package/templates/node-basic/AGENTS.md +1 -4
- package/templates/node-basic/_agents/skills/daloyjs-best-practices/SKILL.md +43 -20
- package/templates/node-basic/package.json +3 -3
- package/templates/node-basic/src/build-app.ts +24 -22
- package/templates/vercel/AGENTS.md +3 -3
- package/templates/vercel/_agents/skills/daloyjs-best-practices/SKILL.md +41 -17
- package/templates/vercel/api/index.ts +24 -22
- package/templates/vercel/package.json +2 -2
package/bin/create-daloy.mjs
CHANGED
|
@@ -58,8 +58,8 @@ const PACKAGE_MANAGERS = PACKAGE_MANAGER_OPTIONS.map((option) => option.value);
|
|
|
58
58
|
// `--template <old>` commands (and published docs/blog posts) keep working.
|
|
59
59
|
// Each maps to its current canonical template value.
|
|
60
60
|
const TEMPLATE_ALIASES = new Map([
|
|
61
|
-
// `vercel`
|
|
62
|
-
//
|
|
61
|
+
// The `vercel` template targets the Node.js runtime, which Vercel
|
|
62
|
+
// recommends over Edge for standalone functions (both run on Fluid compute).
|
|
63
63
|
]);
|
|
64
64
|
|
|
65
65
|
const RENAME_ON_COPY = new Map([
|
|
@@ -487,6 +487,62 @@ const TOOL_INSTALL_GUIDES = {
|
|
|
487
487
|
deno: { label: "Deno", url: "https://docs.deno.com/runtime/getting_started/installation/" },
|
|
488
488
|
};
|
|
489
489
|
|
|
490
|
+
// Version floors the scaffolder and the projects it generates depend on. The
|
|
491
|
+
// CLI itself targets Node >= 24; npm scaffolds require npm >= 12 (see the
|
|
492
|
+
// injected `engines.npm` + engine-strict `.npmrc`); pnpm scaffolds require
|
|
493
|
+
// pnpm >= 11 (see the injected `engines.pnpm`, which pnpm always enforces).
|
|
494
|
+
// The pnpm floor is security-relevant: older pnpm silently ignores the
|
|
495
|
+
// `minimumReleaseAge` guardrail in the generated `pnpm-workspace.yaml`, so
|
|
496
|
+
// the 24h supply-chain cooldown would be off without any warning. We surface
|
|
497
|
+
// these up front so a user on an old runtime gets a clear "here's what to
|
|
498
|
+
// install" message instead of a cryptic syntax error, npm's raw EBADENGINE
|
|
499
|
+
// dump, or a silently weakened install posture.
|
|
500
|
+
const MIN_NODE_MAJOR = 24;
|
|
501
|
+
const MIN_NPM_MAJOR = 12;
|
|
502
|
+
const MIN_PNPM_MAJOR = 11;
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Parse the leading major-version integer out of a version string such as
|
|
506
|
+
* `"v24.3.0"`, `"24.3.0"`, or `"10.8.2\n"`. Returns `NaN` when no leading
|
|
507
|
+
* number can be found so callers can fail open on an unrecognizable value.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} version - a version string, e.g. `process.version` or `npm --version` output.
|
|
510
|
+
* @returns {number} the major version, or `NaN` if it cannot be parsed.
|
|
511
|
+
*/
|
|
512
|
+
function parseMajorVersion(version) {
|
|
513
|
+
const match = /(\d+)/.exec(String(version ?? "").trim());
|
|
514
|
+
return match ? Number(match[1]) : NaN;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Decide whether the running Node.js satisfies the DaloyJS floor. Fails **open**
|
|
519
|
+
* on an unparseable version so a valid-but-unusual runtime is never wrongly
|
|
520
|
+
* blocked.
|
|
521
|
+
*
|
|
522
|
+
* @param {string} [version=process.version] - the Node.js version to check.
|
|
523
|
+
* @returns {{ ok: boolean, major: number }} whether it meets the floor, plus the parsed major.
|
|
524
|
+
*/
|
|
525
|
+
function checkNodeVersion(version = process.version) {
|
|
526
|
+
const major = parseMajorVersion(version);
|
|
527
|
+
const ok = !Number.isFinite(major) || major >= MIN_NODE_MAJOR;
|
|
528
|
+
return { ok, major };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Resolve the installed major version of a CLI tool by running `<tool>
|
|
533
|
+
* --version`. Returns `NaN` when the tool is absent, errors, or prints an
|
|
534
|
+
* unparseable version — callers treat `NaN` as "unknown, don't block".
|
|
535
|
+
*
|
|
536
|
+
* @param {string} tool - bare executable name, e.g. `npm`.
|
|
537
|
+
* @param {string} [cwd] - working directory for the probe.
|
|
538
|
+
* @returns {Promise<number>} the tool's major version, or `NaN`.
|
|
539
|
+
*/
|
|
540
|
+
async function detectToolMajor(tool, cwd) {
|
|
541
|
+
const { code, output } = await runQuiet(tool, ["--version"], cwd);
|
|
542
|
+
if (code !== 0) return NaN;
|
|
543
|
+
return parseMajorVersion(output);
|
|
544
|
+
}
|
|
545
|
+
|
|
490
546
|
/**
|
|
491
547
|
* Compute the CLIs a freshly-scaffolded project needs to run, given the chosen
|
|
492
548
|
* template (which fixes the runtime) and package manager. Runtime-only
|
|
@@ -621,6 +677,21 @@ async function patchPackageJson(dir, projectName, packageManager) {
|
|
|
621
677
|
if (json.scripts && packageManager && packageManager !== "pnpm") {
|
|
622
678
|
json.scripts = rewriteScriptsForPackageManager(json.scripts, packageManager);
|
|
623
679
|
}
|
|
680
|
+
// Package-manager engine floors are injected per manager so no project
|
|
681
|
+
// inherits an irrelevant constraint (templates ship without them):
|
|
682
|
+
// - npm scaffolds get `engines.npm >= 12`, paired with the engine-strict
|
|
683
|
+
// `.npmrc` from normalizePackageManagerFiles so the floor is enforced,
|
|
684
|
+
// not just advisory.
|
|
685
|
+
// - pnpm scaffolds get `engines.pnpm >= 11`; pnpm always enforces the
|
|
686
|
+
// project's `engines.pnpm` (no engine-strict needed). The floor matters
|
|
687
|
+
// because pnpm < 11 silently ignores `minimumReleaseAge` in
|
|
688
|
+
// pnpm-workspace.yaml — the 24h supply-chain cooldown would be off.
|
|
689
|
+
if (packageManager === "npm") {
|
|
690
|
+
json.engines = { ...json.engines, npm: ">=12.0.0" };
|
|
691
|
+
}
|
|
692
|
+
if (packageManager === "pnpm") {
|
|
693
|
+
json.engines = { ...json.engines, pnpm: `>=${MIN_PNPM_MAJOR}.0.0` };
|
|
694
|
+
}
|
|
624
695
|
await writeFile(file, JSON.stringify(json, null, 2) + "\n", "utf8");
|
|
625
696
|
}
|
|
626
697
|
|
|
@@ -707,16 +778,36 @@ function rewriteScriptsForPackageManager(scripts, pm) {
|
|
|
707
778
|
return out;
|
|
708
779
|
}
|
|
709
780
|
|
|
781
|
+
// npm-native `.npmrc` written for npm scaffolds. The pnpm-specific hardening
|
|
782
|
+
// keys (minimum-release-age, verify-store-integrity, …) mean nothing to npm,
|
|
783
|
+
// but `engine-strict=true` is honored by npm and turns the `engines.npm`
|
|
784
|
+
// floor in package.json into a hard failure instead of a silent warning — so
|
|
785
|
+
// DaloyJS's "npm >= 12" requirement is actually enforced at install time.
|
|
786
|
+
const NPM_STRICT_NPMRC = `# DaloyJS install-time guardrails for the npm CLI.
|
|
787
|
+
#
|
|
788
|
+
# engine-strict makes npm refuse to install when the developer's npm (or
|
|
789
|
+
# Node.js) is older than the "engines" floor in package.json — DaloyJS
|
|
790
|
+
# requires npm >= 12 — instead of only printing a warning. Remove this line
|
|
791
|
+
# only if you deliberately want to install on an unsupported npm version.
|
|
792
|
+
engine-strict=true
|
|
793
|
+
`;
|
|
794
|
+
|
|
710
795
|
async function normalizePackageManagerFiles(dir, packageManager) {
|
|
711
796
|
if (packageManager === "pnpm") return;
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
797
|
+
// pnpm-workspace.yaml is pnpm-only; drop it for every other manager.
|
|
798
|
+
const workspace = path.join(dir, "pnpm-workspace.yaml");
|
|
799
|
+
if (existsSync(workspace)) {
|
|
800
|
+
await rm(workspace, { force: true });
|
|
801
|
+
}
|
|
802
|
+
// The hardened `.npmrc` is authored for pnpm. For npm we replace it with an
|
|
803
|
+
// npm-native `.npmrc` that keeps the one guardrail npm understands
|
|
804
|
+
// (`engine-strict=true`, enforcing the npm >= 12 engine floor). yarn and bun
|
|
805
|
+
// ignore or misinterpret these keys, so they get no `.npmrc` at all.
|
|
806
|
+
const npmrc = path.join(dir, ".npmrc");
|
|
807
|
+
if (packageManager === "npm") {
|
|
808
|
+
await writeFile(npmrc, NPM_STRICT_NPMRC, "utf8");
|
|
809
|
+
} else if (existsSync(npmrc)) {
|
|
810
|
+
await rm(npmrc, { force: true });
|
|
720
811
|
}
|
|
721
812
|
}
|
|
722
813
|
|
|
@@ -1789,6 +1880,26 @@ async function main() {
|
|
|
1789
1880
|
process.exit(0);
|
|
1790
1881
|
}
|
|
1791
1882
|
|
|
1883
|
+
// Preflight: the CLI and every template it emits require a modern Node.js.
|
|
1884
|
+
// `npm create daloy` / npx will happily run this on an old Node (engines are
|
|
1885
|
+
// advisory there), so we check ourselves and stop with a clear, actionable
|
|
1886
|
+
// message rather than scaffolding a project the user's runtime can't run.
|
|
1887
|
+
const nodeCheck = checkNodeVersion();
|
|
1888
|
+
if (!nodeCheck.ok) {
|
|
1889
|
+
printBanner(await readPkgVersion());
|
|
1890
|
+
logError(
|
|
1891
|
+
`DaloyJS needs Node.js ${MIN_NODE_MAJOR} or newer — you're on ${process.version}.`,
|
|
1892
|
+
);
|
|
1893
|
+
console.log(
|
|
1894
|
+
` ${color(COLORS.yellow, SYMBOLS.pointer)} Install an up-to-date Node.js: ${color(COLORS.cyan + COLORS.underline, TOOL_INSTALL_GUIDES.node.url)}`,
|
|
1895
|
+
);
|
|
1896
|
+
console.log(
|
|
1897
|
+
` ${color(COLORS.yellow, SYMBOLS.pointer)} ${color(COLORS.dim, "Or switch versions with a manager like nvm, fnm, or Volta.")}`,
|
|
1898
|
+
);
|
|
1899
|
+
console.log("");
|
|
1900
|
+
process.exit(1);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1792
1903
|
printBanner(await readPkgVersion());
|
|
1793
1904
|
|
|
1794
1905
|
const detectedPm = detectPackageManager();
|
|
@@ -1989,6 +2100,49 @@ async function main() {
|
|
|
1989
2100
|
installDeps = false;
|
|
1990
2101
|
}
|
|
1991
2102
|
|
|
2103
|
+
// npm scaffolds require npm >= 12 (enforced by the injected engines.npm +
|
|
2104
|
+
// engine-strict .npmrc). Detect an older npm here so we can explain the fix
|
|
2105
|
+
// clearly instead of leaving the user with npm's raw EBADENGINE failure.
|
|
2106
|
+
if (packageManager === "npm" && !missingTools.some((tool) => tool.tool === "npm")) {
|
|
2107
|
+
const npmMajor = await detectToolMajor("npm", targetDir);
|
|
2108
|
+
if (Number.isFinite(npmMajor) && npmMajor < MIN_NPM_MAJOR) {
|
|
2109
|
+
logWarn(`This project requires npm >= ${MIN_NPM_MAJOR}, but you're on npm ${npmMajor}.x.`);
|
|
2110
|
+
console.log(
|
|
2111
|
+
` ${color(COLORS.yellow, SYMBOLS.pointer)} Upgrade npm: ${color(COLORS.cyan, "npm install -g npm@latest")} ${color(COLORS.dim, `(or reinstall Node.js: ${TOOL_INSTALL_GUIDES.npm.url})`)}`,
|
|
2112
|
+
);
|
|
2113
|
+
if (installDeps) {
|
|
2114
|
+
console.log(
|
|
2115
|
+
` ${color(COLORS.yellow, SYMBOLS.pointer)} ${color(COLORS.dim, "Skipping the automatic install until npm is up to date.")}`,
|
|
2116
|
+
);
|
|
2117
|
+
installDeps = false;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// pnpm scaffolds require pnpm >= 11 (enforced by the injected
|
|
2123
|
+
// engines.pnpm, which pnpm always checks). The floor is security-relevant:
|
|
2124
|
+
// pnpm < 11 silently ignores the pnpm-workspace.yaml `minimumReleaseAge`
|
|
2125
|
+
// guardrail, so the 24h supply-chain cooldown would be off without any
|
|
2126
|
+
// warning. Detect an older pnpm here and explain the fix clearly.
|
|
2127
|
+
if (packageManager === "pnpm" && !missingTools.some((tool) => tool.tool === "pnpm")) {
|
|
2128
|
+
const pnpmMajor = await detectToolMajor("pnpm", targetDir);
|
|
2129
|
+
if (Number.isFinite(pnpmMajor) && pnpmMajor < MIN_PNPM_MAJOR) {
|
|
2130
|
+
logWarn(`This project requires pnpm >= ${MIN_PNPM_MAJOR}, but you're on pnpm ${pnpmMajor}.x.`);
|
|
2131
|
+
console.log(
|
|
2132
|
+
` ${color(COLORS.yellow, SYMBOLS.pointer)} Upgrade pnpm: ${color(COLORS.cyan, "npm install -g pnpm@latest")} ${color(COLORS.dim, `(or corepack use pnpm@latest — ${TOOL_INSTALL_GUIDES.pnpm.url})`)}`,
|
|
2133
|
+
);
|
|
2134
|
+
console.log(
|
|
2135
|
+
` ${color(COLORS.yellow, SYMBOLS.pointer)} ${color(COLORS.dim, `pnpm < ${MIN_PNPM_MAJOR} silently ignores minimumReleaseAge in pnpm-workspace.yaml, disabling the 24h supply-chain cooldown.`)}`,
|
|
2136
|
+
);
|
|
2137
|
+
if (installDeps) {
|
|
2138
|
+
console.log(
|
|
2139
|
+
` ${color(COLORS.yellow, SYMBOLS.pointer)} ${color(COLORS.dim, "Skipping the automatic install until pnpm is up to date.")}`,
|
|
2140
|
+
);
|
|
2141
|
+
installDeps = false;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
|
|
1992
2146
|
if (installDeps) {
|
|
1993
2147
|
const spinner = createSpinner(`Installing dependencies with ${color(COLORS.cyan, packageManager)}\u2026`);
|
|
1994
2148
|
spinner.start();
|
|
@@ -2033,4 +2187,15 @@ if (process.env.DALOY_TEST_IMPORT !== "1") {
|
|
|
2033
2187
|
await main();
|
|
2034
2188
|
}
|
|
2035
2189
|
|
|
2036
|
-
export {
|
|
2190
|
+
export {
|
|
2191
|
+
choiceInputMode,
|
|
2192
|
+
requiredTools,
|
|
2193
|
+
missingToolGuides,
|
|
2194
|
+
isToolInstalled,
|
|
2195
|
+
TOOL_INSTALL_GUIDES,
|
|
2196
|
+
parseMajorVersion,
|
|
2197
|
+
checkNodeVersion,
|
|
2198
|
+
MIN_NODE_MAJOR,
|
|
2199
|
+
MIN_NPM_MAJOR,
|
|
2200
|
+
MIN_PNPM_MAJOR,
|
|
2201
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-daloy",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.5",
|
|
4
4
|
"description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/sbom.cdx.json
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:02713320-c651-582f-b203-48552fa27a2e",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-07-
|
|
7
|
+
"timestamp": "2026-07-20T09:04:50.510Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.0.0-rc.
|
|
12
|
+
"version": "1.0.0-rc.5"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [],
|
|
16
16
|
"component": {
|
|
17
17
|
"type": "library",
|
|
18
|
-
"bom-ref": "pkg:npm/create-daloy@1.0.0-rc.
|
|
18
|
+
"bom-ref": "pkg:npm/create-daloy@1.0.0-rc.5",
|
|
19
19
|
"name": "create-daloy",
|
|
20
|
-
"version": "1.0.0-rc.
|
|
20
|
+
"version": "1.0.0-rc.5",
|
|
21
21
|
"description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
|
|
22
|
-
"purl": "pkg:npm/create-daloy@1.0.0-rc.
|
|
22
|
+
"purl": "pkg:npm/create-daloy@1.0.0-rc.5",
|
|
23
23
|
"licenses": [
|
|
24
24
|
{
|
|
25
25
|
"license": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
}
|
|
43
43
|
],
|
|
44
44
|
"swid": {
|
|
45
|
-
"tagId": "swidtag-create-daloy-1.0.0-rc.
|
|
45
|
+
"tagId": "swidtag-create-daloy-1.0.0-rc.5",
|
|
46
46
|
"name": "create-daloy",
|
|
47
|
-
"version": "1.0.0-rc.
|
|
47
|
+
"version": "1.0.0-rc.5",
|
|
48
48
|
"tagVersion": 0,
|
|
49
49
|
"patch": false
|
|
50
50
|
}
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"components": [],
|
|
54
54
|
"dependencies": [
|
|
55
55
|
{
|
|
56
|
-
"ref": "pkg:npm/create-daloy@1.0.0-rc.
|
|
56
|
+
"ref": "pkg:npm/create-daloy@1.0.0-rc.5",
|
|
57
57
|
"dependsOn": []
|
|
58
58
|
}
|
|
59
59
|
]
|
package/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "create-daloy-1.0.0-rc.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/create-daloy-1.0.0-rc.
|
|
5
|
+
"name": "create-daloy-1.0.0-rc.5",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/create-daloy-1.0.0-rc.5-02713320-c651-582f-b203-48552fa27a2e",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-07-
|
|
8
|
+
"created": "2026-07-20T09:04:50.510Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package-create-daloy",
|
|
18
18
|
"name": "create-daloy",
|
|
19
|
-
"versionInfo": "1.0.0-rc.
|
|
19
|
+
"versionInfo": "1.0.0-rc.5",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/create-daloy@1.0.0-rc.
|
|
30
|
+
"referenceLocator": "pkg:npm/create-daloy@1.0.0-rc.5"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
|
@@ -42,7 +42,7 @@ Do not write `.js` here — that's the Node NodeNext convention and will fail to
|
|
|
42
42
|
|
|
43
43
|
## Core rules
|
|
44
44
|
|
|
45
|
-
1. The route definition is the contract. Method, path, request schemas, and response schemas live in one place — `app.route({...})
|
|
45
|
+
1. The route definition is the contract. Method, path, request schemas, and response schemas live in one place — `app.get(path, contract, handler)` (or `app.route({...})` for reusable `defineRoute()` contracts or metadata-heavy routes).
|
|
46
46
|
2. Validate every input with Zod or another Standard Schema-compatible validator. For Zod object schemas, use `.strict()` to reject unknown keys at the boundary.
|
|
47
47
|
3. Preserve literal types in responses: `status: 200 as const`, `z.literal(...)` on discriminator fields. Codegen depends on these.
|
|
48
48
|
4. Throw typed errors (`NotFoundError`, `BadRequestError`, etc.) from `@daloyjs/core` — never return raw error responses.
|
|
@@ -34,9 +34,13 @@ DaloyJS is a **contract-first** framework. Internalize these rules — every
|
|
|
34
34
|
recommendation below follows from them:
|
|
35
35
|
|
|
36
36
|
1. **The route definition is the contract.** Method, path, request schemas,
|
|
37
|
-
and response schemas live in one place
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
and response schemas live in one place — `app.get(path, contract, handler)`
|
|
38
|
+
(and the matching `app.post`/`put`/`patch`/`delete`/`head` shorthands), or
|
|
39
|
+
`app.route({...})` when you need a reusable `defineRoute()` contract or a
|
|
40
|
+
metadata-heavy route. Both forms produce identical runtime behavior,
|
|
41
|
+
validation, security, and OpenAPI output. The OpenAPI spec, the typed
|
|
42
|
+
client, and the runtime validation are all derived from the route
|
|
43
|
+
definition.
|
|
40
44
|
2. **Validation schemas protect every boundary.** This template uses Zod,
|
|
41
45
|
and Daloy accepts any Standard Schema-compatible library. Body, params,
|
|
42
46
|
query, and headers go through the declared schema.
|
|
@@ -118,10 +122,14 @@ when it helps consumers understand or safely automate the route:
|
|
|
118
122
|
2. **Design schemas first.** Define request body/params/query/headers and a
|
|
119
123
|
response body per status code. Prefer `z.object({...}).strict()` for
|
|
120
124
|
inputs so unknown keys are rejected at the boundary.
|
|
121
|
-
3. **Call `app.
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
3. **Call the method shorthand: `app.get(path, contract, handler)`** (or
|
|
126
|
+
`app.post`/`put`/`patch`/`delete`/`head` for other methods). The contract
|
|
127
|
+
object's required keys are `operationId`, `tags`, `responses`. Add
|
|
128
|
+
`request` when the route accepts input, and add `meta` examples /
|
|
129
|
+
descriptions when the route is user-facing or consumed by agents. Reach
|
|
130
|
+
for the full `app.route({ method, path, ...contract, handler })` form
|
|
131
|
+
instead when the route is built from a reusable `defineRoute()` contract,
|
|
132
|
+
or when composing many routes at once via `registerRoutes()`.
|
|
125
133
|
4. **Return `{ status, body, headers? }` from the handler.** Always use
|
|
126
134
|
`status: 200 as const` so the typed client can narrow.
|
|
127
135
|
5. **Throw typed errors**, do not return raw error responses. Use
|
|
@@ -143,22 +151,23 @@ import { NotFoundError } from "@daloyjs/core";
|
|
|
143
151
|
const Book = z.object({ id: z.string(), title: z.string() }).strict();
|
|
144
152
|
const BookParams = z.object({ id: z.string().min(1) }).strict();
|
|
145
153
|
|
|
146
|
-
app.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
app.get(
|
|
155
|
+
"/books/:id",
|
|
156
|
+
{
|
|
157
|
+
operationId: "getBookById",
|
|
158
|
+
tags: ["Books"],
|
|
159
|
+
request: { params: BookParams },
|
|
160
|
+
responses: {
|
|
161
|
+
200: { description: "Found", body: Book },
|
|
162
|
+
404: { description: "Not found" },
|
|
163
|
+
},
|
|
155
164
|
},
|
|
156
|
-
|
|
165
|
+
async ({ params }) => {
|
|
157
166
|
const book = await store.find(params.id);
|
|
158
167
|
if (!book) throw new NotFoundError(`Book ${params.id} not found`);
|
|
159
168
|
return { status: 200 as const, body: book };
|
|
160
|
-
}
|
|
161
|
-
|
|
169
|
+
}
|
|
170
|
+
);
|
|
162
171
|
```
|
|
163
172
|
|
|
164
173
|
## Validation & schema conventions
|
|
@@ -222,6 +231,9 @@ Cover both **happy paths and unhappy paths** for every route: valid input,
|
|
|
222
231
|
validation failures (400), auth failures (401/403), not-found (404),
|
|
223
232
|
conflicts (409), rate limiting (429). For external services, inject an
|
|
224
233
|
in-memory fake via `buildApp({ store })` rather than mocking `fetch`.
|
|
234
|
+
For user-owned or tenant-owned resources, use at least two principals and
|
|
235
|
+
prove that Alice's valid token cannot list, read, update, or delete Bob's
|
|
236
|
+
record.
|
|
225
237
|
The shipped contract test should fail invalid examples, duplicate/missing
|
|
226
238
|
`operationId`, or missing responses.
|
|
227
239
|
|
|
@@ -240,6 +252,17 @@ Aim for complete happy- and unhappy-path test coverage of the routes you add.
|
|
|
240
252
|
at boot. Fail fast on missing config.
|
|
241
253
|
- For auth, verify JWT signatures against an allowlist of keys, never
|
|
242
254
|
trust the `alg` header, always check `exp` / `nbf`.
|
|
255
|
+
- Authentication and scopes are not resource authorization. For every route
|
|
256
|
+
that accepts a resource id, classify the resource as public, user-owned,
|
|
257
|
+
tenant-owned, shared, or administrator-only.
|
|
258
|
+
- Scope user-owned and tenant-owned database reads and writes with both the
|
|
259
|
+
caller-controlled id and the trusted owner / tenant from the verified
|
|
260
|
+
principal. Do not fetch by id alone and rely on the UI or a later caller to
|
|
261
|
+
remember the ownership check.
|
|
262
|
+
- Never accept `ownerId`, `userId`, `tenantId`, `role`, or another privileged
|
|
263
|
+
ownership field from an ordinary request body. Derive it from the verified
|
|
264
|
+
principal and reject the field with a strict request schema.
|
|
265
|
+
- Use an explicit, permissioned, audited path for administrator bypasses.
|
|
243
266
|
- Validate redirects against an allowlist.
|
|
244
267
|
- Set `bodyLimitBytes` and `requestTimeoutMs` on `new App({...})` to
|
|
245
268
|
mitigate DoS.
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
"hooks:install": "git config core.hooksPath .githooks"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@daloyjs/core": "^1.0.0-rc.
|
|
22
|
+
"@daloyjs/core": "^1.0.0-rc.5",
|
|
23
23
|
"zod": "^4.4.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@hey-api/openapi-ts": "
|
|
26
|
+
"@hey-api/openapi-ts": "0.0.0-next-20260711024907",
|
|
27
27
|
"@types/bun": "^1.1.0",
|
|
28
|
-
"typescript": "^
|
|
28
|
+
"typescript": "^7.0.2"
|
|
29
29
|
}
|
|
30
30
|
}
|
|
@@ -58,22 +58,23 @@ export function buildApp(): App {
|
|
|
58
58
|
app.use(secureHeaders());
|
|
59
59
|
app.use(rateLimit({ windowMs: 60_000, max: 120 }));
|
|
60
60
|
|
|
61
|
-
app.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
app.get(
|
|
62
|
+
"/healthz",
|
|
63
|
+
{
|
|
64
|
+
operationId: "healthz",
|
|
65
|
+
tags: ["Ops"],
|
|
66
|
+
responses: {
|
|
67
|
+
200: {
|
|
68
|
+
description: "Service is healthy",
|
|
69
|
+
body: z.object({ ok: z.literal(true), runtime: z.literal("bun") }),
|
|
70
|
+
},
|
|
70
71
|
},
|
|
71
72
|
},
|
|
72
|
-
|
|
73
|
+
async () => ({
|
|
73
74
|
status: 200 as const,
|
|
74
75
|
body: { ok: true as const, runtime: "bun" as const },
|
|
75
76
|
}),
|
|
76
|
-
|
|
77
|
+
);
|
|
77
78
|
|
|
78
79
|
// daloy-minimal:strip-start books
|
|
79
80
|
const Book = z.object({ id: z.string(), title: z.string() }).strict();
|
|
@@ -82,22 +83,23 @@ export function buildApp(): App {
|
|
|
82
83
|
["2", { id: "2", title: "El Filibusterismo" }],
|
|
83
84
|
]);
|
|
84
85
|
|
|
85
|
-
app.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
app.get(
|
|
87
|
+
"/books/:id",
|
|
88
|
+
{
|
|
89
|
+
operationId: "getBookById",
|
|
90
|
+
tags: ["Books"],
|
|
91
|
+
request: { params: z.object({ id: z.string().min(1) }).strict() },
|
|
92
|
+
responses: {
|
|
93
|
+
200: { description: "Found", body: Book },
|
|
94
|
+
404: { description: "Not found" },
|
|
95
|
+
},
|
|
94
96
|
},
|
|
95
|
-
|
|
97
|
+
async ({ params }) => {
|
|
96
98
|
const book = books.get(params.id);
|
|
97
99
|
if (!book) throw new NotFoundError(`Book ${params.id} not found`);
|
|
98
100
|
return { status: 200 as const, body: book };
|
|
99
101
|
},
|
|
100
|
-
|
|
102
|
+
);
|
|
101
103
|
// daloy-minimal:strip-end books
|
|
102
104
|
|
|
103
105
|
return app;
|
|
@@ -30,7 +30,7 @@ A [DaloyJS](https://daloyjs.dev) REST API deployed to **Cloudflare Workers**. **
|
|
|
30
30
|
|
|
31
31
|
## Core rules
|
|
32
32
|
|
|
33
|
-
1. The route definition is the contract. Method, path, request schemas, and response schemas live in one place — `app.route({...})
|
|
33
|
+
1. The route definition is the contract. Method, path, request schemas, and response schemas live in one place — `app.get(path, contract, handler)` (or `app.route({...})` for reusable `defineRoute()` contracts or metadata-heavy routes).
|
|
34
34
|
2. Validate every input with Zod or another Standard Schema-compatible validator. For Zod object schemas, use `.strict()` to reject unknown keys at the boundary.
|
|
35
35
|
3. Preserve literal types in responses: `status: 200 as const`, `z.literal(...)` on discriminator fields.
|
|
36
36
|
4. Throw typed errors (`NotFoundError`, `BadRequestError`, etc.) from `@daloyjs/core`.
|
|
@@ -37,7 +37,11 @@ DaloyJS is a **contract-first** framework. On Workers, additionally:
|
|
|
37
37
|
Cloudflare-specific bindings. No `node:` modules unless the user
|
|
38
38
|
explicitly adds `nodejs_compat` to `wrangler.toml` and opts in.
|
|
39
39
|
2. **The route definition is the contract.** Method, path, request
|
|
40
|
-
schemas, and response schemas live in one place
|
|
40
|
+
schemas, and response schemas live in one place. Use the shorthand
|
|
41
|
+
`app.get(path, contract, handler)` (`app.post`, `app.put`, `app.patch`,
|
|
42
|
+
`app.delete`, `app.head`) for ordinary routes; use `app.route({...})`
|
|
43
|
+
for reusable `defineRoute()` contracts, metadata-heavy routes, or when
|
|
44
|
+
composing many routes via `registerRoutes()`.
|
|
41
45
|
3. **Validation schemas protect every boundary.** This template uses Zod,
|
|
42
46
|
and Daloy accepts any Standard Schema-compatible library.
|
|
43
47
|
4. **Preserve literal types.** Return `status: 200 as const`.
|
|
@@ -112,10 +116,14 @@ when it helps consumers understand or safely automate the route:
|
|
|
112
116
|
|
|
113
117
|
1. **Open `src/index.ts`.**
|
|
114
118
|
2. **Design schemas first.** Use `z.object({...}).strict()` for inputs.
|
|
115
|
-
3. **Call `app.
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
3. **Call `app.get(path, contract, handler)`** (or the matching
|
|
120
|
+
`app.post`/`app.put`/`app.patch`/`app.delete`/`app.head` shorthand) with
|
|
121
|
+
a contract object holding `operationId`, `tags`, `responses` (plus
|
|
122
|
+
`request` when accepting input). Add `meta` examples / descriptions when
|
|
123
|
+
the route is user-facing or consumed by agents. Reach for the full
|
|
124
|
+
`app.route({...})` form instead when the contract is a reusable
|
|
125
|
+
`defineRoute()` value, is metadata-heavy, or is being composed with many
|
|
126
|
+
other routes via `registerRoutes()`.
|
|
119
127
|
4. **Return `{ status, body, headers? }`** with `status: 200 as const`.
|
|
120
128
|
5. **Throw typed errors** (`NotFoundError`, `BadRequestError`, etc.).
|
|
121
129
|
6. **Add a test** under `tests/`. Use `app.request(...)` for pure logic;
|
|
@@ -144,22 +152,23 @@ function buildApp(env: Env) {
|
|
|
144
152
|
app.use(secureHeaders());
|
|
145
153
|
app.use(rateLimit({ windowMs: 60_000, max: 120 }));
|
|
146
154
|
|
|
147
|
-
app.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
app.get(
|
|
156
|
+
"/books/:id",
|
|
157
|
+
{
|
|
158
|
+
operationId: "getBookById",
|
|
159
|
+
tags: ["Books"],
|
|
160
|
+
request: { params: z.object({ id: z.string().min(1) }).strict() },
|
|
161
|
+
responses: {
|
|
162
|
+
200: { description: "Found", body: Book },
|
|
163
|
+
404: { description: "Not found" },
|
|
164
|
+
},
|
|
156
165
|
},
|
|
157
|
-
|
|
166
|
+
async ({ params }) => {
|
|
158
167
|
const raw = await env.BOOKS.get(params.id, "json");
|
|
159
168
|
if (!raw) throw new NotFoundError(`Book ${params.id} not found`);
|
|
160
169
|
return { status: 200 as const, body: Book.parse(raw) };
|
|
161
|
-
}
|
|
162
|
-
|
|
170
|
+
}
|
|
171
|
+
);
|
|
163
172
|
|
|
164
173
|
return app;
|
|
165
174
|
}
|
|
@@ -221,6 +230,9 @@ Cover **happy paths and unhappy paths** for every route: valid input,
|
|
|
221
230
|
validation failures (400), auth failures (401/403), not-found (404),
|
|
222
231
|
conflict (409), rate limiting (429). For external services, inject an
|
|
223
232
|
in-memory fake into `buildApp(env)` during tests.
|
|
233
|
+
For user-owned or tenant-owned resources, use at least two principals and
|
|
234
|
+
prove that Alice's valid token cannot list, read, update, or delete Bob's
|
|
235
|
+
record.
|
|
224
236
|
The shipped contract test should fail invalid examples, duplicate/missing
|
|
225
237
|
`operationId`, or missing responses.
|
|
226
238
|
|
|
@@ -240,6 +252,17 @@ Aim for complete happy- and unhappy-path test coverage of the routes you add.
|
|
|
240
252
|
`wrangler.toml`.
|
|
241
253
|
- For auth, verify JWT signatures with the Web Crypto API
|
|
242
254
|
(`crypto.subtle`). Never trust the `alg` header from the token.
|
|
255
|
+
- Authentication and scopes are not resource authorization. For every route
|
|
256
|
+
that accepts a resource id, classify the resource as public, user-owned,
|
|
257
|
+
tenant-owned, shared, or administrator-only.
|
|
258
|
+
- Scope user-owned and tenant-owned database reads and writes with both the
|
|
259
|
+
caller-controlled id and the trusted owner / tenant from the verified
|
|
260
|
+
principal. Do not fetch by id alone and rely on the UI or a later caller to
|
|
261
|
+
remember the ownership check.
|
|
262
|
+
- Never accept `ownerId`, `userId`, `tenantId`, `role`, or another privileged
|
|
263
|
+
ownership field from an ordinary request body. Derive it from the verified
|
|
264
|
+
principal and reject the field with a strict request schema.
|
|
265
|
+
- Use an explicit, permissioned, audited path for administrator bypasses.
|
|
243
266
|
- Validate redirects against an allowlist.
|
|
244
267
|
- Set `bodyLimitBytes` and `requestTimeoutMs` on `new App({...})` to
|
|
245
268
|
mitigate DoS.
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
"hooks:install": "git config core.hooksPath .githooks"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@daloyjs/core": "^1.0.0-rc.
|
|
19
|
+
"@daloyjs/core": "^1.0.0-rc.5",
|
|
20
20
|
"zod": "^4.4.3"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@cloudflare/workers-types": "^4.20240909.0",
|
|
24
|
-
"typescript": "^
|
|
24
|
+
"typescript": "^7.0.2",
|
|
25
25
|
"wrangler": "^4.0.0"
|
|
26
26
|
}
|
|
27
27
|
}
|