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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# [0.17.0-beta.1](https://github.com/mi-examples/pp-dev/compare/v0.16.1...v0.17.0-beta.1) (2026-04-06)


### Bug Fixes

* **tests:** align sandbox overrides with audited dependency policy ([5841aa8](https://github.com/mi-examples/pp-dev/commit/5841aa831833d74342e9bc134141f9913c1c0bab))


### Features

* **cli:** Webpack fallback for Next dev when Turbopack native SWC fails ([5940f31](https://github.com/mi-examples/pp-dev/commit/5940f316cf86056196ad3f69a8b3667460866aeb))
Comment on lines +1 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the heading jump and expand the release notes.

The new entry goes from # to ###, which is why markdownlint reports MD001 here. I'd also call out the dependency/toolchain compatibility changes and the packaged security patch in this release so the changelog matches what 0.17.0-beta.1 actually ships.

As per coding guidelines, "Use proper markdown headers (##, ###) for GitHub compatibility in PR messages", "List specific technical improvements in PR messages", and "Specify breaking changes or compatibility in PR messages".

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 4-4: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 1 - 11, The release header uses a single '#' which
triggers MD001 and skips a proper top-level release grouping; change the header
for the release "[0.17.0-beta.1]" from '#' to '##' and add or expand a short
"Highlights" or "Compatibility" subsection (under that release) calling out the
dependency/toolchain compatibility changes (e.g., Turbopack/SWC fallback
behavior) and the packaged security patch, and include a brief "Breaking Changes
/ Compatibility" note if any; update the existing "Bug Fixes" and "Features"
entries (5841aa8, 5940f31) to include one-line technical details so the
changelog matches the shipped contents and satisfies the markdownlint and PR
message guidelines.


# [0.16.0-beta.1](https://github.com/mi-examples/pp-dev/compare/v0.15.1...v0.16.0-beta.1) (2026-03-23)


Expand Down
1,285 changes: 601 additions & 684 deletions package-lock.json

Large diffs are not rendered by default.

42 changes: 23 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{
"name": "@metricinsights/pp-dev",
"type": "module",
"version": "0.16.0-beta.1",
"version": "0.17.0-beta.1",
"description": "Portal Page dev build tool",
"bin": {
"pp-dev": "bin/pp-dev.js"
},
"main": "dist/esm/index.js",
"types": "dist/types/index.d.ts",
"scripts": {
"postinstall": "node scripts/patch-npm-bundled-vulnerabilities.mjs",
"clean": "rimraf ./dist",
"prebuild": "npm run clean",
"build": "npm run build:parallel",
Expand Down Expand Up @@ -66,11 +67,13 @@
"node": ">=22.14"
},
"overrides": {
"@xmldom/xmldom": ">=0.8.12",
"brace-expansion": "5.0.5",
"chokidar": "^5.0.0",
"file-type": "^21.3.3",
"file-type": "^22.0.0",
"minimatch": "10.2.4",
"npm": ">=11.11.0",
"serialize-javascript": ">=7.0.3",
"npm": ">=11.12.1",
"serialize-javascript": ">=7.0.5",
"undici": ">=6.24.0"
},
"peerDependencies": {
Expand All @@ -83,37 +86,37 @@
}
},
"dependencies": {
"axios": "^1.13.6",
"axios": "^1.14.0",
"cac": "^7.0.0",
"chokidar": "^5.0.0",
"deepmerge-ts": "^7.1.5",
"diff-match-patch": "^1.0.5",
"dir-compare": "^5.0.0",
"ejs": "^5.0.1",
"esbuild": "^0.27.4",
"esbuild": "^0.27.7",
"express": "^5.2.1",
"extract-zip": "^2.0.1",
"file-type": "^21.3.3",
"file-type": "^22.0.0",
"formdata-node": "^6.0.3",
"http-proxy-middleware": "^3.0.5",
"isbinaryfile": "^6.0.0",
"jsdom": "^29.0.0",
"jsdom": "28.0.0",
"memory-cache": "^0.2.0",
"picocolors": "^1.1.1",
"rollup": "^4.59.0",
"sass": "^1.98.0",
"rollup": "^4.60.1",
"sass": "^1.99.0",
"sharp": "^0.34.5",
"source-map-support": "^0.5.21",
"svgo": "^4.0.1",
"svgtofont": "^6.5.1",
"typescript": "^5.9.3",
"vite": "^8.0.1",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite-plugin-image-optimizer": "^2.0.3",
"vite-plugin-zip-pack": "^1.2.4",
"winston": "^3.19.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.59.1",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@rollup/plugin-url": "^8.0.2",
Expand All @@ -123,26 +126,27 @@
"@types/diff-match-patch": "^1.0.36",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.6",
"@types/jsdom": "^28.0.0",
"@types/jsdom": "^28.0.1",
"@types/memory-cache": "^0.2.6",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"@types/node": "^25.5.2",
"@vitest/coverage-v8": "^4.1.2",
"connect": "^3.7.0",
"next": "^16.2.0",
"next": "^16.2.2",
"prettier": "^3.8.1",
"rimraf": "^6.1.3",
"rollup-plugin-dts": "^6.4.0",
"rollup-plugin-dts": "^6.4.1",
"rollup-plugin-scss": "^4.0.1",
"rollup-plugin-visualizer": "7.0.1",
"semantic-release": "^25.0.3",
"tslib": "^2.8.1",
"tsx": "^4.21.0",
"vitest": "^4.1.0",
"vitest": "^4.1.2",
"yargs": "^18.0.0"
},
"files": [
"bin",
"dist",
"scripts/patch-npm-bundled-vulnerabilities.mjs",
"package.json",
"README.md",
"LICENSE.md",
Expand Down
30 changes: 30 additions & 0 deletions scripts/patch-npm-bundled-vulnerabilities.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* The `npm` package vendors dependencies (bundleDependencies). npm overrides do not
* replace those copies, so `npm audit` still flags known-fixed versions that exist
* hoisted at the project root. Sync patched trees into npm's bundle after install.
*/
import { cpSync, existsSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const npmRoot = join(root, "node_modules", "npm");

function replaceDir(src, dest) {
if (!existsSync(src) || !existsSync(dirname(dest))) {
return;
}
rmSync(dest, { recursive: true, force: true });
cpSync(src, dest, { recursive: true });
}
Comment on lines +13 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't let a best-effort postinstall patch fail the install.

This helper is called from the published postinstall hook, so any rmSync/cpSync exception here turns a security cleanup into a hard install failure for consumers. Wrap the filesystem mutation in try/catch and warn instead of throwing.

🔧 Safer postinstall patching
 function replaceDir(src, dest) {
   if (!existsSync(src) || !existsSync(dirname(dest))) {
     return;
   }
-  rmSync(dest, { recursive: true, force: true });
-  cpSync(src, dest, { recursive: true });
+  try {
+    rmSync(dest, { recursive: true, force: true });
+    cpSync(src, dest, { recursive: true });
+  } catch (error) {
+    console.warn(
+      `[pp-dev] Skipping bundled npm patch for ${dest}: ${
+        error instanceof Error ? error.message : String(error)
+      }`,
+    );
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function replaceDir(src, dest) {
if (!existsSync(src) || !existsSync(dirname(dest))) {
return;
}
rmSync(dest, { recursive: true, force: true });
cpSync(src, dest, { recursive: true });
}
function replaceDir(src, dest) {
if (!existsSync(src) || !existsSync(dirname(dest))) {
return;
}
try {
rmSync(dest, { recursive: true, force: true });
cpSync(src, dest, { recursive: true });
} catch (error) {
console.warn(
`[pp-dev] Skipping bundled npm patch for ${dest}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/patch-npm-bundled-vulnerabilities.mjs` around lines 13 - 19, The
replaceDir helper currently performs rmSync and cpSync which can throw and abort
consumers' installs; modify replaceDir (the function named replaceDir) to wrap
the filesystem mutation calls (rmSync and cpSync) in a try/catch block so any
exceptions are caught, and on error emit a non-fatal warning (e.g., console.warn
or the module's logger) with contextual info including src, dest and the caught
error; retain the existing existsSync checks (src and dirname(dest)) before
attempting mutations and return early as now, but ensure failures during
rmSync/cpSync do not rethrow.


if (existsSync(npmRoot)) {
replaceDir(
join(root, "node_modules", "brace-expansion"),
join(npmRoot, "node_modules", "brace-expansion"),
);
replaceDir(
join(root, "node_modules", "picomatch"),
join(npmRoot, "node_modules", "tinyglobby", "node_modules", "picomatch"),
);
}
154 changes: 139 additions & 15 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,61 @@ interface GlobalCLIOptions {
force?: boolean;
}

/** Extra flags for `pp-dev next` (Next.js custom server bundler selection; Next 15+). */
interface NextCommandCLIOptions extends GlobalCLIOptions {
/** Use Webpack for dev (same idea as `next dev --webpack`). */
webpack?: boolean;
/** Use Turbopack when native bindings are available. */
turbopack?: boolean;
}

type NextBundlerChoice = { webpack?: boolean; turbopack?: boolean };

function parseNextBundlerCli(opts: NextCommandCLIOptions): NextBundlerChoice {
const envWebpack =
process.env.PP_DEV_NEXT_WEBPACK === '1' ||
process.env.PP_DEV_NEXT_WEBPACK === 'true';

if (envWebpack && (opts.webpack || opts.turbopack)) {
throw new Error(
'Do not combine PP_DEV_NEXT_WEBPACK with --webpack or --turbopack',
);
}

if (envWebpack) {
return { webpack: true };
}

if (opts.webpack && opts.turbopack) {
throw new Error('Use only one of --webpack or --turbopack');
}

if (opts.webpack) {
return { webpack: true };
}

if (opts.turbopack) {
return { turbopack: true };
}

return {};
}

/** Next dev chose Turbopack but native @next/swc bindings failed (e.g. WDAC on Windows). */
function isNextTurbopackNativeBindingsError(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('message' in error)) {
return false;
}

const msg = String((error as Error).message);

return (
/Turbopack is not supported/i.test(msg) ||
/native bindings are not available/i.test(msg) ||
/Only WebAssembly \(WASM\) bindings were loaded/i.test(msg)
);
}

interface ChangelogOptions {
oldAssetsPath?: string;
newAssetsPath?: string;
Expand Down Expand Up @@ -503,7 +558,15 @@ cli
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle`,
)
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
.option(
'--webpack',
`[boolean] use Webpack for Next dev (use when Turbopack/native SWC is unavailable)`,
)
.option(
'--turbopack',
`[boolean] use Turbopack for Next dev when native bindings work`,
)
.action(async (root: string, options: ServerOptions & NextCommandCLIOptions) => {
filterDuplicateOptions(options);

let nextApp: ReturnType<typeof import('next').default> | null = null;
Expand Down Expand Up @@ -646,7 +709,7 @@ cli
1;

// Get template name from config, package.json, or fallback to project directory name
let templateName = null;
let templateName: string | null = null;

if (!templateName) {
try {
Expand Down Expand Up @@ -678,19 +741,77 @@ cli
base += `/${templateName}`;
}

nextApp = next({
dev: true,
hostname: (opts.host as string) || 'localhost',
port: opts.port,
dir: projectRoot,
conf: {
...config,
basePath: base,
assetPrefix: `${templateLess ? pathPagePrefix : '/pt'}/${templateName}`, // Fixed: Make assetPrefix consistent with basePath
},
});
const bundlerChoice = parseNextBundlerCli(options);

await nextApp.prepare();
const createAndPrepareNext = async (bundler: NextBundlerChoice) => {
// Next's createServer() mutates process.env.TURBOPACK. Clear it before each
// attempt so a failed "auto" run does not leave TURBOPACK=auto and block Webpack.
delete process.env.TURBOPACK;

if (nextApp && typeof nextApp.close === 'function') {
await nextApp.close();

nextApp = null;
}

const nextOptions: Parameters<typeof next>[0] = {
dev: true,
customServer: true,
hostname: (opts.host as string) || 'localhost',
port: opts.port,
Comment on lines +757 to +761

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the exact code at lines 757-761
sed -n '750,770p' src/cli.ts

Repository: mi-examples/pp-dev

Length of output: 661


🏁 Script executed:

# Now let's find all usages of opts.host in the file to understand the pattern
rg -n 'opts\.host' src/cli.ts -B 2 -A 2

Repository: mi-examples/pp-dev

Length of output: 500


🏁 Script executed:

# Let's also check the type of opts to understand opts.host
rg -n 'typeof opts\.host' src/cli.ts -B 3 -A 3

Repository: mi-examples/pp-dev

Length of output: 383


🏁 Script executed:

# Find the httpServer.listen call to see how host is used there
rg -n 'httpServer\.listen' src/cli.ts -B 5 -A 5

Repository: mi-examples/pp-dev

Length of output: 484


🏁 Script executed:

# Let's get a broader view of the function containing lines 757-761
sed -n '720,820p' src/cli.ts

Repository: mi-examples/pp-dev

Length of output: 3229


🏁 Script executed:

# Find the type definition or interface for opts
rg -n 'opts.*:' src/cli.ts -B 5 -A 5 | head -100

Repository: mi-examples/pp-dev

Length of output: 977


🏁 Script executed:

# Search for options or interface definitions near the function
rg -n 'interface.*Options|type.*Options' src/cli.ts

Repository: mi-examples/pp-dev

Length of output: 342


🏁 Script executed:

# Look for where opts is defined/destructured in the command definition
rg -n '\(options\)|\(opts\)' src/cli.ts -B 3 -A 3 | head -50

Repository: mi-examples/pp-dev

Length of output: 1836


🏁 Script executed:

# Check yargs/commander configuration for --host flag to understand its type
rg -n '\-\-host|host.*boolean|host.*string' src/cli.ts -B 2 -A 2

Repository: mi-examples/pp-dev

Length of output: 1478


Normalize opts.host once before building the Next server options.

The CLI option accepts string | boolean (bare --host flag sets it to true). Line 760 unsafely casts to string without a type check, so opts.host === true becomes the string "true" and passes through to next(). Meanwhile, lines 845–846 correctly handle the boolean case by checking type first and defaulting to 'localhost'. This causes the Next.js and HTTP servers to bind to different hosts when using the bare --host flag.

🔧 Suggested normalization
+          const normalizedHost =
+            opts.host === true
+              ? '0.0.0.0'
+              : typeof opts.host === 'string' && opts.host
+                ? opts.host
+                : 'localhost';
+
           const nextOptions: Parameters<typeof next>[0] = {
             dev: true,
             customServer: true,
-            hostname: (opts.host as string) || 'localhost',
+            hostname: normalizedHost,
             port: opts.port,
             dir: projectRoot,

Mirror the same normalizedHost in the later httpServer.listen() call at line 1152.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const nextOptions: Parameters<typeof next>[0] = {
dev: true,
customServer: true,
hostname: (opts.host as string) || 'localhost',
port: opts.port,
const normalizedHost =
opts.host === true
? '0.0.0.0'
: typeof opts.host === 'string' && opts.host
? opts.host
: 'localhost';
const nextOptions: Parameters<typeof next>[0] = {
dev: true,
customServer: true,
hostname: normalizedHost,
port: opts.port,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli.ts` around lines 757 - 761, Normalize opts.host once into a
normalizedHost variable (e.g., const normalizedHost = typeof opts.host ===
'string' ? opts.host : 'localhost') and use that variable for
nextOptions.hostname instead of casting (opts.host as string) and also pass the
same normalizedHost to the HTTP server listen call; update references in the
nextOptions construction (nextOptions.hostname) and the httpServer.listen
invocation to use normalizedHost so the Next.js server and HTTP server bind to
the same host.

dir: projectRoot,
conf: {
...config,
basePath: base,
assetPrefix: `${templateLess ? pathPagePrefix : '/pt'}/${templateName}`,
},
Comment on lines +763 to +767

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the computed base path for assetPrefix too.

base already handles /p, /pl, /pt, and custom basePath, but assetPrefix ignores v7Features and configBasePath. In the /pl/... path, Next will emit asset URLs under /pt/... while the middleware only exempts base and bare /_next paths, so static assets get pushed through proxy/cache instead of being served by Next.

🔧 Proposed fix
           conf: {
             ...config,
             basePath: base,
-            assetPrefix: `${templateLess ? pathPagePrefix : '/pt'}/${templateName}`,
+            assetPrefix: base,
           },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
conf: {
...config,
basePath: base,
assetPrefix: `${templateLess ? pathPagePrefix : '/pt'}/${templateName}`,
},
conf: {
...config,
basePath: base,
assetPrefix: base,
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli.ts` around lines 763 - 767, The assetPrefix should use the previously
computed base value so it respects v7Features and configBasePath; update the
conf.assetPrefix assignment in the conf object to derive from base instead of
using the hardcoded `${templateLess ? pathPagePrefix : '/pt'}/${templateName}`
expression (e.g., build assetPrefix by joining base and templateName while
normalizing slashes) so assets are emitted under the same base path the
middleware expects (references: conf, assetPrefix, base, templateLess,
pathPagePrefix, templateName).

};

if (bundler.webpack) {
nextOptions.webpack = true;
} else if (bundler.turbopack) {
nextOptions.turbopack = true;
}

nextApp = next(nextOptions);
await nextApp!.prepare();
};

if (bundlerChoice.webpack) {
await createAndPrepareNext({ webpack: true });
} else if (bundlerChoice.turbopack) {
try {
await createAndPrepareNext({ turbopack: true });
} catch (e) {
if (!isNextTurbopackNativeBindingsError(e)) {
throw e;
}
logger.warn(
colors.yellow(
'⚠ Turbopack is unavailable (native bindings). Falling back to Webpack.',
),
);
await createAndPrepareNext({ webpack: true });
}
} else {
try {
await createAndPrepareNext({});
} catch (e) {
if (!isNextTurbopackNativeBindingsError(e)) {
throw e;
}
logger.warn(
colors.yellow(
'⚠ Turbopack cannot run (native Next.js bindings unavailable). Falling back to Webpack.',
),
);
await createAndPrepareNext({ webpack: true });
}
}

if (!nextApp) {
throw new Error('Next.js app failed to initialize');
}

if (!base.endsWith('/')) {
base += '/';
Expand Down Expand Up @@ -870,7 +991,10 @@ cli
// Note: We need to adapt Express middlewares to work with raw HTTP requests

// 1. PP Redirect middleware (essential for all routes)
const ppRedirectMiddleware = initPPRedirect(base, templateName);
const ppRedirectMiddleware = initPPRedirect(
base,
templateName ?? undefined,
);
const ppRedirectWrapper = (req: any, res: any, next: () => void) => {
ppRedirectMiddleware(req, res, next);
};
Expand Down
Loading