diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8b96a7..151eef2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,10 +95,64 @@ jobs: echo "changed=true" >> $GITHUB_OUTPUT fi - build-and-publish: + # Build each WASM band in parallel using matrix strategy + build-wasm: needs: check-version if: needs.check-version.outputs.version_changed == 'true' runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + band: [v0_82_87, v26, v42, v48] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.22.0 + + - name: Setup Rust (stable + WASI) + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip1 + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ matrix.band }}-${{ hashFiles('codepress-swc-plugin/src/**') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.band }}- + ${{ runner.os }}-cargo- + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build WASM band ${{ matrix.band }} + run: node scripts/build-swc.mjs + env: + BAND: ${{ matrix.band }} + + - name: Upload WASM artifact + uses: actions/upload-artifact@v4 + with: + name: wasm-${{ matrix.band }} + path: swc/*.wasm + retention-days: 1 + + build-and-publish: + needs: [check-version, build-wasm] + if: needs.check-version.outputs.version_changed == 'true' + runs-on: ubuntu-latest permissions: contents: write packages: write @@ -118,16 +172,26 @@ jobs: with: version: 10.22.0 - - name: Setup Rust (stable + WASI) - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-wasip1 - - name: Install deps run: pnpm install --frozen-lockfile env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Download all WASM artifacts + uses: actions/download-artifact@v4 + with: + pattern: wasm-* + path: swc-artifacts + merge-multiple: false + + - name: Combine WASM artifacts + run: | + mkdir -p swc + for dir in swc-artifacts/wasm-*; do + cp "$dir"/*.wasm swc/ 2>/dev/null || true + done + ls -la swc/ + - name: Update package version for release env: RELEASE_VERSION: ${{ needs.check-version.outputs.release_version }} @@ -144,9 +208,6 @@ jobs: - name: Build JS run: pnpm run build - - name: Build Rust WASM plugin - run: pnpm run build:rust - - name: Run tests run: pnpm test diff --git a/package.json b/package.json index 3847402..1cfe07b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codepress/codepress-engine", - "version": "0.7.9", + "version": "0.7.10", "packageManager": "pnpm@10.22.0", "description": "CodePress engine - Babel and SWC plug-ins", "main": "./dist/index.js", @@ -100,6 +100,7 @@ "@babel/preset-typescript": "^7.26.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.37.0", + "@swc/core": "^1.15.3", "@swc/wasm": "^1", "@types/babel__core": "^7.20.5", "@types/jest": "^30.0.0", @@ -124,7 +125,6 @@ }, "dependencies": { "@fastify/cors": "^11.0.1", - "@swc/core": "^1.15.3", "fastify": "^5.3.3", "node-fetch": "^2.6.7", "prettier": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad4e7ac..e62bc5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@fastify/cors': specifier: ^11.0.1 version: 11.1.0 - '@swc/core': - specifier: ^1.15.3 - version: 1.15.3 fastify: specifier: ^5.3.3 version: 5.6.1 @@ -51,6 +48,9 @@ importers: '@eslint/js': specifier: ^9.37.0 version: 9.39.1 + '@swc/core': + specifier: ^1.15.3 + version: 1.15.3 '@swc/wasm': specifier: ^1 version: 1.13.20 diff --git a/scripts/build-swc.mjs b/scripts/build-swc.mjs index 59a0d42..71ab414 100644 --- a/scripts/build-swc.mjs +++ b/scripts/build-swc.mjs @@ -63,13 +63,35 @@ const BANDS = [ // BAND=v42 node scripts/build-swc.mjs // BANDS=v26,v42 node scripts/build-swc.mjs // node scripts/build-swc.mjs v42 v26 -const args = process.argv.slice(2).filter(Boolean); +const cliArgs = process.argv.slice(2).filter(Boolean); +// Filter out CLI flags and their values so they don't get misinterpreted as band IDs +// Flags that take values: -n, --next, -b, --band, -t, --target, -p, --parallel +const flagsWithValues = new Set([ + "-n", + "--next", + "-b", + "--band", + "-t", + "--target", + "-p", + "--parallel", +]); +const bandArgsFromCli = []; +for (let i = 0; i < cliArgs.length; i++) { + const arg = cliArgs[i]; + if (arg.startsWith("-")) { + // Skip flags; if it takes a value, skip the next arg too + if (flagsWithValues.has(arg)) i++; + continue; + } + bandArgsFromCli.push(arg); +} const bandEnvRaw = process.env.BAND || process.env.BANDS || ""; const bandIdsFromEnv = bandEnvRaw .split(",") .map((s) => s.trim()) .filter(Boolean); -const requestedIds = [...bandIdsFromEnv, ...args]; +const requestedIds = [...bandIdsFromEnv, ...bandArgsFromCli]; const validIds = new Set(BANDS.map((b) => b.id)); let BANDS_TO_BUILD = BANDS; if (requestedIds.length) { @@ -206,6 +228,7 @@ function parseArgs(argv) { next: undefined, band: undefined, target: undefined, + parallel: 2, // number of concurrent builds, 0 = unlimited listBands: false, help: false, }; @@ -224,6 +247,24 @@ function parseArgs(argv) { case "-t": args.target = argv[++i]; break; + case "--parallel": + case "-p": { + const raw = argv[++i]; + const n = Number(raw); + if ( + raw == null || + !Number.isFinite(n) || + !Number.isInteger(n) || + n < 0 + ) { + console.error( + `[codepress] --parallel expects a non-negative integer (0 = unlimited); received: "${raw ?? ""}"` + ); + process.exit(1); + } + args.parallel = n; + break; + } case "--list-bands": args.listBands = true; break; @@ -239,6 +280,32 @@ function parseArgs(argv) { return args; } +async function runWithConcurrency(tasks, concurrency) { + if (concurrency <= 0) { + // Unlimited parallelism + return Promise.all(tasks.map((fn) => fn())); + } + const results = []; + const executing = new Set(); + for (const task of tasks) { + const p = Promise.resolve() + .then(task) + .finally(() => { + executing.delete(p); + }); + results.push(p); + executing.add(p); + if (executing.size >= concurrency) { + try { + await Promise.race(executing); + } catch { + // Swallow here; final Promise.all(results) will reject with the first error. + } + } + } + return Promise.all(results); +} + function parseSemver(input) { if (!input) return null; const m = String(input) @@ -275,11 +342,14 @@ function usage() { ` -n, --next Build band matching Next.js version (e.g. 15.4.0)\n` + ` -b, --band Build specific band id (one of: ${BANDS.map((b) => b.id).join(", ")})\n` + ` -t, --target Build target(s): wasip1 | wasi | all | comma-list (default: all)\n` + + ` -p, --parallel Concurrency limit (default: 2, 0 = unlimited)\n` + ` --list-bands Print available band ids and exit\n` + ` -h, --help Show this help\n\n` + `Examples:\n` + ` node scripts/build-swc.mjs --next 15.4.0\n` + ` node scripts/build-swc.mjs --band v26 --target wasip1\n` + + ` node scripts/build-swc.mjs --parallel 2 # Build 2 bands at a time\n` + + ` node scripts/build-swc.mjs --parallel 0 # Build all bands in parallel\n` + ` npm run build:rust -- --next 15.4.0\n` ); } @@ -336,7 +406,15 @@ function selectTargets(targetArg) { } const targets = selectTargets(args.target); + const concurrency = args.parallel; - for (const band of bands) await buildOneBand(band, targets); + console.log( + `[codepress] Building ${bands.length} band(s)` + + (concurrency > 0 + ? ` (max ${concurrency} concurrent)` + : " (unlimited parallelism)") + ); + const tasks = bands.map((band) => () => buildOneBand(band, targets)); + await runWithConcurrency(tasks, concurrency); console.log("Finished SWC bands built."); })(); diff --git a/src/webpack-plugin.ts b/src/webpack-plugin.ts index c85fefd..f445b36 100644 --- a/src/webpack-plugin.ts +++ b/src/webpack-plugin.ts @@ -78,7 +78,7 @@ export default class CodePressWebpackPlugin { } /** - * Get alias mappings from webpack's resolve configuration. + * Get alias mappings from webpack's resolve configuration AND tsconfig.json. * Returns a map of alias prefix -> resolved directory path. * * For example, with tsconfig paths: { "@/*": ["./src/*"] } @@ -86,8 +86,9 @@ export default class CodePressWebpackPlugin { */ private getAliasMap(compiler: Compiler): Map { const aliases = new Map(); - const resolveAlias = compiler.options.resolve?.alias; + // First, try webpack's resolve.alias + const resolveAlias = compiler.options.resolve?.alias; if (resolveAlias && typeof resolveAlias === "object") { for (const [alias, target] of Object.entries(resolveAlias)) { if (typeof target === "string") { @@ -105,6 +106,68 @@ export default class CodePressWebpackPlugin { } } + // Always try to read @ alias from tsconfig.json if not already present + // resolve.alias usually has Next.js internals but not the @ path alias + const fs = require("fs"); + const path = require("path"); + + if (!aliases.has("@")) { + const tsconfigPath = path.join(compiler.context, "tsconfig.json"); + console.log("[CodePress] Looking for @ alias in tsconfig:", tsconfigPath); + + try { + if (fs.existsSync(tsconfigPath)) { + const tsconfigContent = fs.readFileSync(tsconfigPath, "utf8"); + + // Extract paths directly using regex (avoids JSON parsing issues with comments/globs) + // Match: "paths": { "@/*": ["./src/*"] } or similar + const pathsMatch = tsconfigContent.match( + /"paths"\s*:\s*\{([^}]+)\}/ + ); + + if (pathsMatch) { + const pathsContent = pathsMatch[1]; + console.log("[CodePress] Found paths block:", pathsContent.trim()); + + // Extract individual path mappings: "@/*": ["./src/*"] + const pathPattern = /"([^"]+)"\s*:\s*\[\s*"([^"]+)"/g; + let match; + while ((match = pathPattern.exec(pathsContent)) !== null) { + const aliasPattern = match[1]; // "@/*" + const targetPattern = match[2]; // "./src/*" + + // Convert "@/*" -> "@" and "./src/*" -> "src" + const alias = aliasPattern.replace(/\/\*$/, ""); + const targetPath = targetPattern + .replace(/^\.\//, "") + .replace(/\/\*$/, ""); + + aliases.set(alias, targetPath); + console.log( + "[CodePress] Added alias from tsconfig:", + alias, + "->", + targetPath + ); + } + } else { + console.log("[CodePress] No paths block found in tsconfig"); + } + } + } catch (e) { + console.warn("[CodePress] Error reading tsconfig.json:", e); + } + + // Fallback: Next.js convention is @/* -> ./src/* + if (!aliases.has("@")) { + const srcDir = path.join(compiler.context, "src"); + if (fs.existsSync(srcDir)) { + aliases.set("@", "src"); + console.log("[CodePress] Using default Next.js alias: @ → src"); + } + } + } + return aliases; } @@ -133,17 +196,26 @@ export default class CodePressWebpackPlugin { * Apply the plugin to the webpack compiler */ public apply(compiler: Compiler): void { + console.log("[CodePress] Plugin apply() called with options:", { + isServer: this.options.isServer, + dev: this.options.dev, + }); + // Skip server builds entirely if (this.options.isServer) { + console.log("[CodePress] Skipping: isServer=true"); return; } // Skip dev mode - module mapping not needed (dev has named IDs) // and env vars are handled by the SWC plugin if (this.options.dev) { + console.log("[CodePress] Skipping: dev=true"); return; } + console.log("[CodePress] Running in production mode, will build MODULE_MAP"); + // Disable optimizations that break CodePress preview in production builds. // This is REQUIRED for CodePress preview to work because: // @@ -363,6 +435,19 @@ export default class CodePressWebpackPlugin { ? finalExports : undefined, }; + // For index files, also add a key without /index suffix + // so imports like "@/features/dashboard" resolve to "@/features/dashboard/index" + if (aliasPath.endsWith("/index")) { + const withoutIndex = aliasPath.replace(/\/index$/, ""); + moduleMap[withoutIndex] = { + path: runtime, + moduleId: id, + exports: + Object.keys(finalExports).length > 0 + ? finalExports + : undefined, + }; + } } } } else { @@ -488,6 +573,16 @@ export default class CodePressWebpackPlugin { moduleId: id, ...(hasExportMappings ? { exports: exportMappings } : {}), }; + // For index files, also add a key without /index suffix + // so imports like "@/features/dashboard" resolve to "@/features/dashboard/index" + if (aliasPath.endsWith("/index")) { + const withoutIndex = aliasPath.replace(/\/index$/, ""); + moduleMap[withoutIndex] = { + path: runtimePath, + moduleId: id, + ...(hasExportMappings ? { exports: exportMappings } : {}), + }; + } } } });