Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/fix-json-import-attribute-stripped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@create-node-app/core": patch
---

Fix dependencies declared in a template's or extension's `package.json` being
silently dropped during scaffolding.

`importIfExists` loaded `.json` files via a dynamic `import()` with a
`with { type: "json" }` attribute. Although valid at the source level,
tsup/esbuild strips the attribute when bundling, so the published `dist`
contained a bare `import("…json")`. On Node >= 20.10 that throws
`ERR_IMPORT_ATTRIBUTE_MISSING`, and the surrounding `try/catch` swallowed it —
so every dependency coming from a static `package.json` (i.e. almost every
extension) vanished from the generated project, breaking type-check/build.

JSON manifests are now read with `readFileSync` + `JSON.parse`, which is immune
to bundler transforms and works across Node versions. Added a regression test
that asserts dependencies from both `package.json` and `package/index.js` are
merged into the installable result.
21 changes: 11 additions & 10 deletions packages/create-node-app-core/package.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Removed unused eslint-disable (global-require) after migration to flat config
import { existsSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import merge from "lodash.merge";
import type { TemplateOrExtension } from "./loaders.js";
import { getPackagePath } from "./paths.js";
Expand Down Expand Up @@ -37,20 +36,22 @@ const localRequire = createRequire(import.meta.url);
/**
* Load a module from disk if it exists. Throws if missing.
*
* For `.json` files we use dynamic `import()` with the `with { type: "json" }`
* assertion (Node 22+). For `.js`/`.cjs` files we use `createRequire`. This
* avoids the legacy `require()` pattern that only works because of tsup's
* `--shims` build flag.
* For `.json` files we read the file and `JSON.parse` it. We deliberately avoid
* a dynamic `import()` with an import attribute (`with { type: "json" }`):
* although valid at the source level, tsup/esbuild strips the attribute when
* bundling, producing a bare `import("...json")` in the published `dist`. On
* Node >= 20.10 that throws `ERR_IMPORT_ATTRIBUTE_MISSING`, which the callers
* swallow — silently dropping every dependency declared in a template's or
* extension's `package.json`. Reading + parsing is bundler- and Node-agnostic.
*
* For `.js`/`.cjs` files (e.g. `package/index.js`) we use `createRequire`.
*/
const importIfExists = async (filePath: string): Promise<unknown> => {
if (!existsSync(filePath)) {
throw new Error(`File ${filePath} does not exist`);
}
if (filePath.endsWith(".json")) {
const mod = (await import(pathToFileURL(filePath).href, {
with: { type: "json" },
})) as { default?: unknown };
return mod.default ?? mod;
return JSON.parse(readFileSync(filePath, "utf8"));
}
// Fallback to createRequire for JS/CJS template modules.
return localRequire(filePath);
Expand Down
88 changes: 88 additions & 0 deletions packages/create-node-app-core/tests/package.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { pathToFileURL } from "node:url";
import { tmpdir } from "node:os";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import path from "node:path";

import { loadPackages } from "../package.js";

const safeRm = (d: string) => {
try {
rmSync(d, { recursive: true, force: true });
} catch {
// ignore
}
};

/**
* Regression test for the bundler-stripped JSON import attribute.
*
* `importIfExists` used to load `package.json` via a dynamic `import()` with a
* `with { type: "json" }` attribute. tsup/esbuild stripped the attribute in the
* published `dist`, so on Node >= 20.10 the import threw and the surrounding
* `try/catch` swallowed it — silently dropping every dependency declared in a
* template's or extension's `package.json`.
*
* This test asserts that dependencies coming from a static `package.json`
* (extension) and from a `package/index.js` module (template) are both present
* in the merged, installable result.
*/
test("loadPackages merges dependencies from package.json and package/index.js", async () => {
const root = mkdtempSync(path.join(tmpdir(), "cna-loadpkg-"));
try {
// Template exposes its manifest through package/index.js (resolved via require).
const templateDir = path.join(root, "template");
mkdirSync(path.join(templateDir, "package"), { recursive: true });
writeFileSync(
path.join(templateDir, "package", "index.js"),
`module.exports = function resolvePackage(_setup, { appName }) {
return {
name: appName,
scripts: { build: "tsc" },
dependencies: { react: "^19.0.0" },
devDependencies: { typescript: "^5.0.0" },
};
};\n`,
);

// Extension declares its dependencies through a static package.json.
const extensionDir = path.join(root, "extension");
mkdirSync(extensionDir, { recursive: true });
writeFileSync(
path.join(extensionDir, "package.json"),
JSON.stringify({
name: "example-extension",
dependencies: { "@tanstack/react-query": "^5.0.0" },
devDependencies: { vitest: "^3.0.0" },
}),
);

const result = (await loadPackages({
appName: "my-app",
templatesOrExtensions: [
{ url: pathToFileURL(templateDir).toString() },
{ url: pathToFileURL(extensionDir).toString() },
],
})) as { dependencies: string[]; devDependencies: string[] };

assert.ok(
result.dependencies.includes("react@^19.0.0"),
"template dependency should be present",
);
assert.ok(
result.dependencies.includes("@tanstack/react-query@^5.0.0"),
"extension dependency from package.json should be merged (regression)",
);
assert.ok(
result.devDependencies.includes("typescript@^5.0.0"),
"template devDependency should be present",
);
assert.ok(
result.devDependencies.includes("vitest@^3.0.0"),
"extension devDependency from package.json should be merged (regression)",
);
} finally {
safeRm(root);
}
});
Loading