diff --git a/meteor b/meteor index 756a5f9de5..d407eba907 100755 --- a/meteor +++ b/meteor @@ -1,6 +1,6 @@ #!/usr/bin/env bash -BUNDLE_VERSION=22.18.0.22 +BUNDLE_VERSION=22.18.0.36 # OS Check. Put here because here is where we download the precompiled # bundles that are arch specific. diff --git a/npm-packages/meteor-rspack/entries/eager-app-tests.js b/npm-packages/meteor-rspack/entries/eager-app-tests.js deleted file mode 100644 index 50a1c7fa2c..0000000000 --- a/npm-packages/meteor-rspack/entries/eager-app-tests.js +++ /dev/null @@ -1,9 +0,0 @@ -{ - const ctx = import.meta.webpackContext('/', { - recursive: true, - regExp: /\.app-(?:test|spec)s?\.[^.]+$/, - exclude: /(^|\/)(node_modules|\.meteor|_build)(\/|$)/, - mode: 'eager', - }); - ctx.keys().forEach(ctx); -} diff --git a/npm-packages/meteor-rspack/entries/eager-tests.js b/npm-packages/meteor-rspack/entries/eager-tests.js deleted file mode 100644 index d5044e443b..0000000000 --- a/npm-packages/meteor-rspack/entries/eager-tests.js +++ /dev/null @@ -1,9 +0,0 @@ -{ - const ctx = import.meta.webpackContext('/', { - recursive: true, - regExp: /\.(?:test|spec)s?\.[^.]+$/, - exclude: /(^|\/)(node_modules|\.meteor|_build)(\/|$)/, - mode: 'eager', - }); - ctx.keys().forEach(ctx); -} diff --git a/npm-packages/meteor-rspack/index.js b/npm-packages/meteor-rspack/index.js index a18cdc07b6..696487151f 100644 --- a/npm-packages/meteor-rspack/index.js +++ b/npm-packages/meteor-rspack/index.js @@ -1,5 +1,5 @@ -import { defineConfig as rspackDefineConfig } from '@rspack/cli'; -import HtmlRspackPlugin from './plugins/HtmlRspackPlugin.js'; +const { defineConfig: rspackDefineConfig } = require('@rspack/cli'); +const HtmlRspackPlugin = require('./plugins/HtmlRspackPlugin.js'); /** * @typedef {import('rspack').Configuration & { @@ -16,12 +16,13 @@ import HtmlRspackPlugin from './plugins/HtmlRspackPlugin.js'; * @param {ConfigFactory} factory * @returns {ReturnType} */ -export function defineConfig(factory) { +function defineConfig(factory) { return rspackDefineConfig(factory); } -// Export our helper plus passthrough -export default defineConfig; +// Export our helper plus passthrough as default export +module.exports = defineConfig; -// Export the HtmlRspackPlugin -export { HtmlRspackPlugin }; +// Export the HtmlRspackPlugin and defineConfig as named exports +module.exports.defineConfig = defineConfig; +module.exports.HtmlRspackPlugin = HtmlRspackPlugin; diff --git a/npm-packages/meteor-rspack/lib/ignore.js b/npm-packages/meteor-rspack/lib/ignore.js new file mode 100644 index 0000000000..17058cce19 --- /dev/null +++ b/npm-packages/meteor-rspack/lib/ignore.js @@ -0,0 +1,139 @@ +var fs = require('fs'); +var path = require('path'); + +/** + * Reads the .meteorignore file from the given project directory and returns + * the parsed entries. Empty lines and comment lines (starting with #) are filtered out. + * + * @param {string} projectDir - The project directory path + * @returns {string[]} - Array of ignore patterns + */ +const getMeteorIgnoreEntries = function (projectDir) { + const meteorIgnorePath = path.join(projectDir, '.meteorignore'); + + // Check if .meteorignore file exists + try { + const fileContent = fs.readFileSync(meteorIgnorePath, 'utf8'); + + // Process each line in the file + const entries = fileContent.split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line !== '' && !line.startsWith('#')); + + return entries; + } catch (e) { + // If the file doesn't exist or can't be read, return empty array + return []; + } +}; + +/** + * Creates a glob config array for ignoring specified patterns. + * Transforms .gitignore-style entries into chokidar-compatible glob patterns. + * @param {string[]} entries - Array of .gitignore-style patterns + * @returns {string[]} - Array of glob patterns for chokidar + */ +function createIgnoreGlobConfig(entries = []) { + if (!Array.isArray(entries)) { + throw new Error('Entries must be an array'); + } + + const globPatterns = []; + + entries.forEach(entry => { + // Skip empty entries + if (!entry.trim()) { + return; + } + + // Handle comments + if (entry.startsWith('#')) { + return; + } + + // Check if it's a negation pattern + const isNegation = entry.startsWith('!'); + let pattern = isNegation ? entry.substring(1).trim() : entry.trim(); + + // Remove leading ./ or / if present + pattern = pattern.replace(/^(\.\/|\/)/g, ''); + + // If it ends with /, it's a directory pattern, add ** to match all contents + if (pattern.endsWith('/')) { + pattern = pattern.slice(0, -1) + '/**'; + } + + // If it doesn't include a /, it could match anywhere in the path + if (!pattern.includes('/')) { + pattern = '**/' + pattern; + } else if (!pattern.startsWith('**/') && !pattern.startsWith('/')) { + // If it has a / but doesn't start with **/, add **/ to match anywhere + pattern = '**/' + pattern; + } + + // Add the negation back if it was present + if (isNegation) { + pattern = '!' + pattern; + } + + globPatterns.push(pattern); + }); + + return globPatterns; +} + +/** + * Creates a regex pattern to match the specified glob patterns. + * Converts glob patterns with * and ** into regex equivalents. + * + * @param {string[]} globPatterns - Array of glob patterns from createIgnoreGlobConfig + * @returns {RegExp} - Regex pattern to match the specified patterns + */ +function createIgnoreRegex(globPatterns) { + if (!Array.isArray(globPatterns) || globPatterns.length === 0) { + throw new Error('globPatterns must be a non-empty array'); + } + + // Process each glob pattern and convert to regex + const regexPatterns = globPatterns.map(pattern => { + // Skip negation patterns for the regex + if (pattern.startsWith('!')) { + return null; + } + + // Escape special regex characters, but not * and / + let regexPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + + // Use a temporary placeholder for ** that won't be affected by the * replacement + // This is necessary because if we directly replace ** with .* and then replace * with [^/]* + const DOUBLE_ASTERISK_PLACEHOLDER = '__DOUBLE_ASTERISK__'; + regexPattern = regexPattern.replace(/\*\*/g, DOUBLE_ASTERISK_PLACEHOLDER); + + // Convert * to regex equivalent (any number of characters except /) + regexPattern = regexPattern.replace(/\*/g, '[^/]*'); + + // Convert the ** placeholder to its regex equivalent (any number of characters including /) + regexPattern = regexPattern.replace(new RegExp(DOUBLE_ASTERISK_PLACEHOLDER, 'g'), '.*'); + + // For absolute paths, we don't want to force the pattern to match from the beginning + // but we still want to ensure it matches to the end of the path segment + regexPattern = '(?:^|/)' + regexPattern + '$'; + + return regexPattern; + }).filter(pattern => pattern !== null); + + if (regexPatterns.length === 0) { + // If all patterns were negations, return a regex that matches nothing + return new RegExp('^$'); + } + + // Join all patterns with | to create a single regex + const combinedPattern = regexPatterns.join('|'); + return new RegExp(combinedPattern); +} + +module.exports = { + createIgnoreRegex, + getMeteorIgnoreEntries, + createIgnoreGlobConfig, +}; diff --git a/npm-packages/meteor-rspack/lib/mergeRulesSplitOverlap.js b/npm-packages/meteor-rspack/lib/mergeRulesSplitOverlap.js index 2b8e946939..c7a73234d2 100644 --- a/npm-packages/meteor-rspack/lib/mergeRulesSplitOverlap.js +++ b/npm-packages/meteor-rspack/lib/mergeRulesSplitOverlap.js @@ -3,12 +3,13 @@ * overlapping file extensions in module rules. */ -import { mergeWithCustomize } from 'webpack-merge'; +const { mergeWithCustomize } = require('webpack-merge'); +const isEqual = require('fast-deep-equal'); /** * File extensions to check when determining rule overlaps. */ -export const EXT_CATALOG = [ +const EXT_CATALOG = [ '.tsx', '.ts', '.mts', '.cts', '.jsx', '.js', '.mjs', '.cjs', ]; @@ -87,6 +88,10 @@ function splitOverlapRulesMerge(aRules, bRules) { for (let i = 0; i < result.length; i++) { const aRule = result[i]; + + const isMergeableRule = isEqual(aRule?.include || [], bRule?.include || []); + if (!isMergeableRule) continue; + // Determine which extensions each rule matches (within our catalog) const aExts = EXT_CATALOG.filter(ext => ruleMatchesExt(aRule, ext)); const bExts = EXT_CATALOG.filter(ext => ruleMatchesExt(bRule, ext)); @@ -130,7 +135,7 @@ function splitOverlapRulesMerge(aRules, bRules) { * @param {Function} getter - Function to get the identifier from the plugin * @returns {Function} Customizer function */ -export function unique(key, pluginNames = [], getter = item => item.constructor && item.constructor.name) { +function unique(key, pluginNames = [], getter = item => item.constructor && item.constructor.name) { return (a, b, k) => { if (k !== key) return undefined; @@ -183,7 +188,7 @@ export function unique(key, pluginNames = [], getter = item => item.constructor * @param {Function} [options.warningFn] - Custom warning function that receives the path string * @returns {Object} The cleaned object with specified paths removed */ -export function cleanOmittedPaths(obj, options = {}) { +function cleanOmittedPaths(obj, options = {}) { if (!obj || typeof obj !== 'object') { return obj; } @@ -252,13 +257,35 @@ export function cleanOmittedPaths(obj, options = {}) { return result; } +/** + * Normalizes externals configuration to ensure consistent handling. + * @param {Object} config - The configuration object + * @returns {Object} - The normalized configuration + */ +function normalizeExternals(config) { + if (!config || !config.externals) return config; + + // Create a deep clone of the config to avoid modifying the original + const result = { ...config }; + + // If externals is not an array, convert it to an array + if (!Array.isArray(result.externals)) { + result.externals = [result.externals]; + } + + return result; +} + /** * Merges webpack/rspack configs with smart handling of overlapping rules. * * @param {...Object} configs - Configs to merge * @returns {Object} Merged config */ -export function mergeSplitOverlap(...configs) { +function mergeSplitOverlap(...configs) { + // Normalize externals in all configs before merging + const normalizedConfigs = configs.map(normalizeExternals); + return mergeWithCustomize({ customizeArray(a, b, key) { if (key === 'module.rules') { @@ -267,6 +294,14 @@ export function mergeSplitOverlap(...configs) { return splitOverlapRulesMerge(aRules, bRules); } + // Ensure custom extensions first + if (key === 'resolve.extensions') { + const aRules = Array.isArray(a) ? a : []; + const bRules = Array.isArray(b) ? b : []; + const merged = [...bRules, ...aRules]; + return [...new Set(merged)]; + } + // Handle plugins uniqueness if (key === 'plugins') { return unique( @@ -279,5 +314,12 @@ export function mergeSplitOverlap(...configs) { // fall through to default merging return undefined; } - })(...configs); + })(...normalizedConfigs); } + +module.exports = { + EXT_CATALOG, + unique, + cleanOmittedPaths, + mergeSplitOverlap +}; diff --git a/npm-packages/meteor-rspack/lib/meteorRspackConfigFactory.js b/npm-packages/meteor-rspack/lib/meteorRspackConfigFactory.js new file mode 100644 index 0000000000..3dbeaf04a5 --- /dev/null +++ b/npm-packages/meteor-rspack/lib/meteorRspackConfigFactory.js @@ -0,0 +1,99 @@ +// meteorRspackConfigFactory.js + +const { mergeSplitOverlap } = require("./mergeRulesSplitOverlap.js"); + +const DEFAULT_PREFIX = "meteorRspackConfig"; +let counter = 0; + +/** + * Create a uniquely keyed Rspack config fragment. + * Example return: { meteorRspackConfig1: { ...customConfig } } + * + * @param {object} customConfig + * @param {{ key?: number|string, prefix?: string }} [opts] + * @returns {Record} + */ +function prepareMeteorRspackConfig(customConfig, opts = {}) { + if (!customConfig || typeof customConfig !== "object") { + throw new TypeError("customConfig must be an object"); + } + const prefix = opts.prefix || DEFAULT_PREFIX; + + let name; + if (opts.key != null) { + const k = String(opts.key).trim(); + if (/^\d+$/.test(k)) name = `${prefix}${k}`; + else if (k.startsWith(prefix) && /^\d+$/.test(k.slice(prefix.length))) + name = k; + else + throw new Error(`opts.key must be a positive integer or "${prefix}"`); + + const n = parseInt(name.slice(prefix.length), 10); + if (Number.isFinite(n) && n > counter) counter = n; + } else { + counter += 1; + name = `${prefix}${counter}`; + } + + return { [name]: customConfig }; +} + +/** + * Merge all `{prefix}` fragments into `config` using `mergeSplitOverlap`, + * then remove those temporary keys. Mutates `config`. + * + * Position-aware merge: + * Walk the config in insertion order and fold: + * - for a fragment key: out = mergeSplitOverlap(out, fragment) + * - for a normal key: out = mergeSplitOverlap(out, { [key]: value }) + * + * Result: fragments behave like spreads at their exact position; + * later inline keys override earlier ones (including fragments). + * + * @param {object} config + * @param {{ prefix?: string }} [opts] + * @returns {object} same (mutated) config + */ +function mergeMeteorRspackFragments(config, opts = {}) { + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new TypeError("config must be a plain object"); + } + const prefix = opts.prefix || DEFAULT_PREFIX; + + let out = {}; + for (const key of Object.keys(config)) { + const val = config[key]; + + const isFragment = + typeof key === "string" && + key.startsWith(prefix) && + /^\d+$/.test(key.slice(prefix.length)); + + if (isFragment) { + if (!val || typeof val !== "object" || Array.isArray(val)) { + throw new Error(`Fragment "${key}" must be a plain object`); + } + out = mergeSplitOverlap(out, val); + } else { + out = mergeSplitOverlap(out, { [key]: val }); + } + } + + // keep object identity; fragments disappear because `out` doesn't include them + replaceObject(config, out); + return config; +} + +function replaceObject(target, source) { + for (const k of Object.keys(target)) { + if (!(k in source)) delete target[k]; + } + for (const k of Object.keys(source)) { + target[k] = source[k]; + } +} + +module.exports = { + prepareMeteorRspackConfig, + mergeMeteorRspackFragments, +}; diff --git a/npm-packages/meteor-rspack/lib/meteorRspackHelpers.js b/npm-packages/meteor-rspack/lib/meteorRspackHelpers.js new file mode 100644 index 0000000000..cde87ba899 --- /dev/null +++ b/npm-packages/meteor-rspack/lib/meteorRspackHelpers.js @@ -0,0 +1,106 @@ +const path = require("path"); +const { prepareMeteorRspackConfig } = require("./meteorRspackConfigFactory"); +const { builtinModules } = require("module"); + +/** + * Resolve a package directory from node resolution. + * @param {string} pkg + * @returns {string} absolute directory of the package + */ +function pkgDir(pkg) { + const resolved = require.resolve(`${pkg}/package.json`, { + paths: [process.cwd()], + }); + return path.dirname(resolved); +} + +/** + * Wrap externals for Meteor runtime (marks deps as externals). + * Usage: compileWithMeteor(["sharp", "vimeo", "fs"]) + * + * @param {string[]} deps - package names or module IDs + * @returns {Record} `{ meteorRspackConfigX: { externals: [...] } }` + */ +function compileWithMeteor(deps) { + const flat = deps.flat().filter(Boolean); + return prepareMeteorRspackConfig({ + externals: flat, + }); +} + +/** + * Add SWC transpilation rules limited to specific deps (monorepo-friendly). + * Usage: compileWithRspack(["@org/lib-a", "zod"]) + * + * Requires global `Meteor.swcConfigOptions` (as in your setup). + * + * @param {string[]} deps - package names to include in SWC loader + * @returns {Record} `{ meteorRspackConfigX: { module: { rules: [...] } } }` + */ +function compileWithRspack(deps, { options = {} } = {}) { + const includeDirs = deps.flat().filter(Boolean).map(pkgDir); + + return prepareMeteorRspackConfig({ + module: { + rules: [ + { + test: /\.(?:[mc]?js|jsx|[mc]?ts|tsx)$/i, + include: includeDirs, + loader: "builtin:swc-loader", + options, + }, + ], + }, + }); +} + +/** + * Enable or disable Rspack cache config + * Usage: setCache(false) + * + * @param {boolean} enabled + * @param {Record} cacheConfig + * @returns {Record} `{ meteorRspackConfigX: { cache: {} } }` + */ +function setCache( + enabled, + cacheConfig = { cache: true, experiments: { cache: true } }, +) { + return prepareMeteorRspackConfig( + enabled + ? cacheConfig + : { + cache: false, // disable cache + experiments: { + cache: false, // disable persistent cache (experimental flag) + }, + }, + ); +} + +/** + * Build an alias map that disables ALL Node core modules in a web build. + * - Includes both 'fs' and 'node:fs' keys + * - Optional extras let you block non-core modules too + */ +function makeWebNodeBuiltinsAlias(extras = []) { + // Strip potential 'node:' prefixes then add both forms + const core = new Set(builtinModules.map((m) => m.replace(/^node:/, ""))); + + const names = new Set(); + for (const m of core) { + names.add(m); // e.g. 'fs' + names.add(`node:${m}`); // e.g. 'node:fs' + } + for (const x of extras) names.add(x); + + // Map every name to false (causes hard error if imported) + return Object.fromEntries([...names].map((m) => [m, false])); +} + +module.exports = { + compileWithMeteor, + compileWithRspack, + setCache, + makeWebNodeBuiltinsAlias, +}; diff --git a/npm-packages/meteor-rspack/lib/swc.js b/npm-packages/meteor-rspack/lib/swc.js index c221739330..8b43703059 100644 --- a/npm-packages/meteor-rspack/lib/swc.js +++ b/npm-packages/meteor-rspack/lib/swc.js @@ -1,12 +1,12 @@ -import fs from 'fs'; -import vm from 'vm'; +const fs = require('fs'); +const vm = require('vm'); /** * Reads and parses the SWC configuration file. * @param {string} file - The name of the SWC configuration file (default: '.swcrc') * @returns {Object|undefined} The parsed SWC configuration or undefined if an error occurs */ -export function getMeteorAppSwcrc(file = '.swcrc') { +function getMeteorAppSwcrc(file = '.swcrc') { try { const filePath = `${process.cwd()}/${file}`; if (file.endsWith('.js')) { @@ -42,7 +42,7 @@ export function getMeteorAppSwcrc(file = '.swcrc') { * If the configuration has a baseUrl property, it will be set to process.cwd(). * @returns {Object|undefined} The SWC configuration or undefined if no configuration exists */ -export function getMeteorAppSwcConfig() { +function getMeteorAppSwcConfig() { const hasSwcRc = fs.existsSync(`${process.cwd()}/.swcrc`); const hasSwcJs = !hasSwcRc && fs.existsSync(`${process.cwd()}/swc.config.js`); @@ -60,3 +60,8 @@ export function getMeteorAppSwcConfig() { return config; } + +module.exports = { + getMeteorAppSwcrc, + getMeteorAppSwcConfig +}; diff --git a/npm-packages/meteor-rspack/lib/test.js b/npm-packages/meteor-rspack/lib/test.js new file mode 100644 index 0000000000..6301c73681 --- /dev/null +++ b/npm-packages/meteor-rspack/lib/test.js @@ -0,0 +1,62 @@ +const fs = require('fs'); +const path = require('path'); +const { createIgnoreRegex, createIgnoreGlobConfig } = require("./ignore.js"); + +/** + * Generates eager test files dynamically + * @param {Object} options - Options for generating the test file + * @param {boolean} options.isAppTest - Whether this is an app test + * @param {string} options.projectDir - The project directory + * @param {string} options.buildContext - The build context + * @param {string[]} options.entries - Array of ignore patterns + * @returns {string} The path to the generated file + */ +const generateEagerTestFile = ({ + isAppTest, + projectDir, + buildContext, + entries = [], +}) => { + const distDir = path.resolve(projectDir, ".meteor/local/test"); + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); + } + + // Combine all ignore entries + const ignoreEntries = [ + "**/node_modules/**", + "**/.meteor/**", + "**/public/**", + "**/private/**", + `**/${buildContext}/**`, + ...entries, + ]; + + // Create regex from ignore entries + const excludeFoldersRegex = createIgnoreRegex( + createIgnoreGlobConfig(ignoreEntries) + ); + + const filename = isAppTest ? "eager-app-tests.mjs" : "eager-tests.mjs"; + const filePath = path.resolve(distDir, filename); + const regExp = isAppTest + ? "/\\.app-(?:test|spec)s?\\.[^.]+$/" + : "/\\.(?:test|spec)s?\\.[^.]+$/"; + + const content = `{ + const ctx = import.meta.webpackContext('/', { + recursive: true, + regExp: ${regExp}, + exclude: ${excludeFoldersRegex.toString()}, + mode: 'eager', + }); + ctx.keys().forEach(ctx); +}`; + + fs.writeFileSync(filePath, content); + return filePath; +}; + +module.exports = { + generateEagerTestFile, +}; diff --git a/npm-packages/meteor-rspack/package-lock.json b/npm-packages/meteor-rspack/package-lock.json index 4212d497e0..f0f0dca61d 100644 --- a/npm-packages/meteor-rspack/package-lock.json +++ b/npm-packages/meteor-rspack/package-lock.json @@ -1,14 +1,15 @@ { "name": "@meteorjs/rspack", - "version": "0.0.36", + "version": "0.0.60", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meteorjs/rspack", - "version": "0.0.36", + "version": "0.0.60", "license": "ISC", "dependencies": { + "fast-deep-equal": "^3.1.3", "ignore-loader": "^0.1.2", "webpack-merge": "^6.0.1" }, @@ -1398,8 +1399,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-uri": { "version": "3.0.6", diff --git a/npm-packages/meteor-rspack/package.json b/npm-packages/meteor-rspack/package.json index ae82b79cbe..413fc02091 100644 --- a/npm-packages/meteor-rspack/package.json +++ b/npm-packages/meteor-rspack/package.json @@ -1,12 +1,13 @@ { "name": "@meteorjs/rspack", - "version": "0.0.36", + "version": "0.0.60", "description": "Configuration logic for using Rspack in Meteor projects", "main": "index.js", - "type": "module", + "type": "commonjs", "author": "", "license": "ISC", "dependencies": { + "fast-deep-equal": "^3.1.3", "ignore-loader": "^0.1.2", "webpack-merge": "^6.0.1" }, diff --git a/npm-packages/meteor-rspack/plugins/HtmlRspackPlugin.js b/npm-packages/meteor-rspack/plugins/HtmlRspackPlugin.js index 6e0b9a9511..5af36fc29e 100644 --- a/npm-packages/meteor-rspack/plugins/HtmlRspackPlugin.js +++ b/npm-packages/meteor-rspack/plugins/HtmlRspackPlugin.js @@ -1,10 +1,11 @@ -import RspackMeteorHtmlPlugin, { loadHtmlRspackPluginFromHost } from './RspackMeteorHtmlPlugin.js'; +const RspackMeteorHtmlPlugin = require('./RspackMeteorHtmlPlugin.js'); +const { loadHtmlRspackPluginFromHost } = RspackMeteorHtmlPlugin; /** * A plugin that composes the original HtmlRspackPlugin from @rspack/core * and RspackMeteorHtmlPlugin, in that order. */ -export default class HtmlRspackPlugin { +class HtmlRspackPlugin { constructor(options = {}) { this.options = options; } @@ -26,3 +27,5 @@ export default class HtmlRspackPlugin { meteorPlugin.apply(compiler); } } + +module.exports = HtmlRspackPlugin; diff --git a/npm-packages/meteor-rspack/plugins/RequireExtenalsPlugin.js b/npm-packages/meteor-rspack/plugins/RequireExtenalsPlugin.js index c121cff63b..205863dbad 100644 --- a/npm-packages/meteor-rspack/plugins/RequireExtenalsPlugin.js +++ b/npm-packages/meteor-rspack/plugins/RequireExtenalsPlugin.js @@ -7,10 +7,10 @@ // the externalMap function if provided. // Used for Blaze to translate require of html files to require of js files bundled by Meteor. -import fs from 'fs'; -import path from 'path'; +const fs = require('fs'); +const path = require('path'); -export class RequireExternalsPlugin { +class RequireExternalsPlugin { constructor({ filePath, // Externals can be: @@ -487,3 +487,5 @@ export class RequireExternalsPlugin { return existing; } } + +module.exports = { RequireExternalsPlugin }; diff --git a/npm-packages/meteor-rspack/plugins/RspackMeteorHtmlPlugin.js b/npm-packages/meteor-rspack/plugins/RspackMeteorHtmlPlugin.js index baa93a336f..0e1ec8dd59 100644 --- a/npm-packages/meteor-rspack/plugins/RspackMeteorHtmlPlugin.js +++ b/npm-packages/meteor-rspack/plugins/RspackMeteorHtmlPlugin.js @@ -1,7 +1,7 @@ -import path from 'node:path'; -import { createRequire } from 'node:module'; +const path = require('node:path'); +const { createRequire } = require('node:module'); -export function loadHtmlRspackPluginFromHost(compiler) { +function loadHtmlRspackPluginFromHost(compiler) { // Prefer the compiler's context; fall back to process.cwd() const ctx = compiler.options?.context || compiler.context || process.cwd(); const requireFromHost = createRequire(path.join(ctx, 'package.json')); @@ -16,7 +16,7 @@ export function loadHtmlRspackPluginFromHost(compiler) { * 1. Remove the injected `*-rspack.js` script tags * 2. Strip and … wrappers from the final HTML */ -export default class RspackMeteorHtmlPlugin { +class RspackMeteorHtmlPlugin { apply(compiler) { const HtmlRspackPlugin = loadHtmlRspackPluginFromHost(compiler); if (!HtmlRspackPlugin?.getCompilationHooks) { @@ -45,3 +45,6 @@ export default class RspackMeteorHtmlPlugin { }); } } + +module.exports = RspackMeteorHtmlPlugin; +module.exports.loadHtmlRspackPluginFromHost = loadHtmlRspackPluginFromHost; diff --git a/npm-packages/meteor-rspack/rspack.config.js b/npm-packages/meteor-rspack/rspack.config.js index 6353ea1c74..39e948af87 100644 --- a/npm-packages/meteor-rspack/rspack.config.js +++ b/npm-packages/meteor-rspack/rspack.config.js @@ -1,16 +1,22 @@ -import { DefinePlugin, BannerPlugin } from '@rspack/core'; -import fs from 'fs'; -import { createRequire } from 'module'; -import { inspect } from 'node:util'; -import path from 'path'; -import { merge } from 'webpack-merge'; +const { DefinePlugin, BannerPlugin, NormalModuleReplacementPlugin } = require('@rspack/core'); +const fs = require('fs'); +const { inspect } = require('node:util'); +const path = require('path'); +const { merge } = require('webpack-merge'); -import { cleanOmittedPaths, mergeSplitOverlap } from "./lib/mergeRulesSplitOverlap.js"; -import { getMeteorAppSwcConfig } from './lib/swc.js'; -import HtmlRspackPlugin from './plugins/HtmlRspackPlugin.js'; -import { RequireExternalsPlugin } from './plugins/RequireExtenalsPlugin.js'; - -const require = createRequire(import.meta.url); +const { cleanOmittedPaths, mergeSplitOverlap } = require("./lib/mergeRulesSplitOverlap.js"); +const { getMeteorAppSwcConfig } = require('./lib/swc.js'); +const HtmlRspackPlugin = require('./plugins/HtmlRspackPlugin.js'); +const { RequireExternalsPlugin } = require('./plugins/RequireExtenalsPlugin.js'); +const { generateEagerTestFile } = require("./lib/test.js"); +const { getMeteorIgnoreEntries, createIgnoreGlobConfig } = require("./lib/ignore"); +const { mergeMeteorRspackFragments } = require("./lib/meteorRspackConfigFactory.js"); +const { + compileWithMeteor, + compileWithRspack, + setCache, + makeWebNodeBuiltinsAlias, +} = require('./lib/meteorRspackHelpers.js'); // Safe require that doesn't throw if the module isn't found function safeRequire(moduleName) { @@ -28,17 +34,52 @@ function safeRequire(moduleName) { } // Persistent filesystem cache strategy -function createCacheStrategy(mode) { +function createCacheStrategy(mode, side, { projectConfigPath, configPath } = {}) { + // Check for configuration files + const tsconfigPath = path.join(process.cwd(), 'tsconfig.json'); + const hasTsconfig = fs.existsSync(tsconfigPath); + const babelRcConfig = path.join(process.cwd(), '.babelrc'); + const hasBabelRcConfig = fs.existsSync(babelRcConfig); + const babelJsConfig = path.join(process.cwd(), 'babel.config.js'); + const hasBabelJsConfig = fs.existsSync(babelJsConfig); + const swcrcPath = path.join(process.cwd(), '.swcrc'); + const hasSwcrcConfig = fs.existsSync(swcrcPath); + const swcJsPath = path.join(process.cwd(), 'swc.config.js'); + const hasSwcJsConfig = fs.existsSync(swcJsPath); + const postcssConfigPath = path.join(process.cwd(), 'postcss.config.js'); + const hasPostcssConfig = fs.existsSync(postcssConfigPath); + const packageLockPath = path.join(process.cwd(), 'package-lock.json'); + const hasPackageLock = fs.existsSync(packageLockPath); + const yarnLockPath = path.join(process.cwd(), 'yarn.lock'); + const hasYarnLock = fs.existsSync(yarnLockPath); + + // Build dependencies array + const buildDependencies = [ + ...(projectConfigPath ? [projectConfigPath] : []), + ...(configPath ? [configPath] : []), + ...(hasTsconfig ? [tsconfigPath] : []), + ...(hasBabelRcConfig ? [babelRcConfig] : []), + ...(hasBabelJsConfig ? [babelJsConfig] : []), + ...(hasSwcrcConfig ? [swcrcPath] : []), + ...(hasSwcJsConfig ? [swcJsPath] : []), + ...(hasPostcssConfig ? [postcssConfigPath] : []), + ...(hasPackageLock ? [packageLockPath] : []), + ...(hasYarnLock ? [yarnLockPath] : []), + ].filter(Boolean); + return { cache: true, experiments: { cache: { - version: `swc-${mode}`, - type: 'persistent', + version: `cache-${mode}${(side && `-${side}`) || ""}`, + type: "persistent", storage: { - type: 'filesystem', - directory: 'node_modules/.cache/rspack', + type: "filesystem", + directory: `node_modules/.cache/rspack${(side && `/${side}`) || ""}`, }, + ...(buildDependencies.length > 0 && { + buildDependencies: buildDependencies, + }) }, }, }; @@ -47,32 +88,48 @@ function createCacheStrategy(mode) { // SWC loader rule (JSX/JS) function createSwcConfig({ isTypescriptEnabled, + isReactEnabled, isJsxEnabled, isTsxEnabled, externalHelpers, isDevEnvironment, + isClient, }) { const defaultConfig = { jsc: { baseUrl: process.cwd(), - paths: { '/*': ['*'] }, + paths: { '/*': ['*', '/*'] }, parser: { syntax: isTypescriptEnabled ? 'typescript' : 'ecmascript', ...(isTsxEnabled && { tsx: true }), ...(isJsxEnabled && { jsx: true }), }, target: 'es2015', - transform: { - react: { - development: isDevEnvironment, - refresh: isDevEnvironment, + ...(isReactEnabled && { + transform: { + react: { + development: isDevEnvironment, + ...(isClient && { refresh: isDevEnvironment }), + }, }, - }, + }), externalHelpers, }, }; + + // Swcrc config not customizable + const omitPaths = [ + 'jsc.target', + ]; + // Define warning function + const warningFn = path => { + console.warn( + `[.swcrc] Ignored custom "${path}" — reserved for Meteor-Rspack integration.`, + ); + }; const customConfig = getMeteorAppSwcConfig() || {}; - const swcConfig = merge(defaultConfig, customConfig); + const cleanedCustomConfig = cleanOmittedPaths(customConfig, { omitPaths, warningFn }); + const swcConfig = merge(defaultConfig, cleanedCustomConfig); return { test: /\.(?:[mc]?js|jsx|[mc]?ts|tsx)$/i, exclude: /node_modules|\.meteor\/local/, @@ -81,6 +138,43 @@ function createSwcConfig({ }; } +function createRemoteDevServerConfig() { + const rootUrl = process.env.ROOT_URL; + let hostname; + let protocol; + let port; + + if (rootUrl) { + try { + const url = new URL(rootUrl); + // Detect if it's remote (not localhost or 127.x) + const isLocal = + url.hostname.includes('localhost') || + url.hostname.startsWith('127.') || + url.hostname.endsWith('.local'); + if (!isLocal) { + hostname = url.hostname; + protocol = url.protocol === 'https:' ? 'wss' : 'ws'; + port = url.port ? Number(url.port) : (url.protocol === 'https:' ? 443 : 80); + + return { + client: { + webSocketURL: { + hostname, + port, + protocol, + }, + }, + }; + } + } catch (err) { + console.warn(`Invalid ROOT_URL "${rootUrl}", falling back to localhost`); + } + } + + // If local doesn't provide any extra config + return {}; +} // Keep files outside of build folders function keepOutsideBuild() { @@ -92,17 +186,12 @@ function keepOutsideBuild() { }; } -// Watch options shared across both builds -const defaultWatchOptions = { - ignored: ['**/.meteor/local/**', '**/dist/**'], -}; - /** * @param {{ isClient: boolean; isServer: boolean; isDevelopment?: boolean; isProduction?: boolean; isTest?: boolean }} Meteor * @param {{ mode?: string; clientEntry?: string; serverEntry?: string; clientOutputFolder?: string; serverOutputFolder?: string; chunksContext?: string; assetsContext?: string; serverAssetsContext?: string }} argv - * @returns {import('@rspack/cli').Configuration[]} + * @returns {Promise} */ -export default function (inMeteor = {}, argv = {}) { +module.exports = async function (inMeteor = {}, argv = {}) { // Transform Meteor env properties to proper boolean values const Meteor = { ...inMeteor }; // Convert string boolean values to actual booleans @@ -118,7 +207,9 @@ export default function (inMeteor = {}, argv = {}) { const isDev = !!Meteor.isDevelopment || !isProd; const isTest = !!Meteor.isTest; const isClient = !!Meteor.isClient; + const isServer = !!Meteor.isServer; const isRun = !!Meteor.isRun; + const isBuild = !!Meteor.isBuild; const isReactEnabled = !!Meteor.isReactEnabled; const isTestModule = !!Meteor.isTestModule; const isTestEager = !!Meteor.isTestEager; @@ -126,6 +217,9 @@ export default function (inMeteor = {}, argv = {}) { const swcExternalHelpers = !!Meteor.swcExternalHelpers; const isNative = !!Meteor.isNative; const mode = isProd ? 'production' : 'development'; + const projectDir = process.cwd(); + const projectConfigPath = Meteor.projectConfigPath || path.resolve(projectDir, 'rspack.config.js'); + const configPath = Meteor.configPath; const isTypescriptEnabled = Meteor.isTypescriptEnabled || false; const isJsxEnabled = @@ -147,11 +241,11 @@ export default function (inMeteor = {}, argv = {}) { const runPath = Meteor.runPath; // Determine banner - const bannerOutput = JSON.parse(Meteor.bannerOutput || ''); + const bannerOutput = JSON.parse(Meteor.bannerOutput || process.env.RSPACK_BANNER || '""'); // Determine output directories - const clientOutputDir = path.resolve(process.cwd(), 'public'); - const serverOutputDir = path.resolve(process.cwd(), 'private'); + const clientOutputDir = path.resolve(projectDir, 'public'); + const serverOutputDir = path.resolve(projectDir, 'private'); // Determine context for bundles and assets const buildContext = Meteor.buildContext || '_build'; @@ -159,9 +253,27 @@ export default function (inMeteor = {}, argv = {}) { const chunksContext = Meteor.chunksContext || 'build-chunks'; // Determine build output and pass to Meteor - const buildOutputDir = path.resolve(process.cwd(), buildContext, outputDir); + const buildOutputDir = path.resolve(projectDir, buildContext, outputDir); Meteor.buildOutputDir = buildOutputDir; + const cacheStrategy = createCacheStrategy( + mode, + (Meteor.isClient && 'client') || 'server', + { projectConfigPath, configPath } + ); + + // Expose Meteor's helpers to expand Rspack configs + Meteor.compileWithMeteor = deps => compileWithMeteor(deps); + Meteor.compileWithRspack = deps => + compileWithRspack(deps, { + options: Meteor.swcConfigOptions, + }); + Meteor.setCache = enabled => + setCache( + !!enabled, + enabled === 'memory' ? undefined : cacheStrategy + ); + // Add HtmlRspackPlugin function to Meteor Meteor.HtmlRspackPlugin = (options = {}) => { return new HtmlRspackPlugin({ @@ -184,18 +296,26 @@ export default function (inMeteor = {}, argv = {}) { }); }; - // Set watch options + // Get Meteor ignore entries + const meteorIgnoreEntries = getMeteorIgnoreEntries(projectDir); + + // Additional ignore entries + const additionalEntries = [ + "**/.meteor/local/**", + "**/dist/**", + ...(isTest && isTestEager + ? [`**/${buildContext}/**`, "**/.meteor/local/**", "node_modules/**"] + : []), + ]; + + // Set default watch options const watchOptions = { - ...defaultWatchOptions, - ...(isTest && - isTestEager && { - ignored: [ - ...defaultWatchOptions.ignored, - '**/_build/**', - '**/.meteor/local/**', - '**/node_modules/**', - ], - }), + ignored: [ + ...createIgnoreGlobConfig([ + ...meteorIgnoreEntries, + ...additionalEntries, + ]), + ], }; if (Meteor.isDebug || Meteor.isVerbose) { @@ -203,13 +323,16 @@ export default function (inMeteor = {}, argv = {}) { console.log('[i] Meteor flags:', Meteor); } + const enableSwcExternalHelpers = !isServer && swcExternalHelpers; const isDevEnvironment = isRun && isDev && !isTest && !isNative; const swcConfigRule = createSwcConfig({ isTypescriptEnabled, + isReactEnabled, isJsxEnabled, isTsxEnabled, - externalHelpers: swcExternalHelpers, + externalHelpers: enableSwcExternalHelpers, isDevEnvironment, + isClient, }); // Expose swc config to use in custom configs Meteor.swcConfigOptions = swcConfigRule.options; @@ -217,10 +340,14 @@ export default function (inMeteor = {}, argv = {}) { const externals = [ /^meteor.*/, ...(isReactEnabled ? [/^react$/, /^react-dom$/] : []), + ...(isServer ? [/^bcrypt$/] : []), ]; const alias = { '/': path.resolve(process.cwd()), }; + const fallback = { + ...(isClient && makeWebNodeBuiltinsAlias()), + }; const extensions = [ '.ts', '.tsx', @@ -248,16 +375,26 @@ export default function (inMeteor = {}, argv = {}) { lastImports: [`./${outputFilename}`], }), }), - enableGlobalPolyfill: isDevEnvironment, + enableGlobalPolyfill: isDevEnvironment && !isServer, }); const rsdoctorModule = isBundleVisualizerEnabled ? safeRequire('@rsdoctor/rspack-plugin') : null; - const doctorPluginConfig = isBundleVisualizerEnabled && rsdoctorModule?.RsdoctorRspackPlugin + const doctorPluginConfig = isRun && isBundleVisualizerEnabled && rsdoctorModule?.RsdoctorRspackPlugin ? [ new rsdoctorModule.RsdoctorRspackPlugin({ - port: isClient ? 8081 : 8082, + port: isClient + ? (parseInt(Meteor.rsdoctorClientPort || '8888', 10)) + : (parseInt(Meteor.rsdoctorServerPort || '8889', 10)), + }), + ] + : []; + const bannerPluginConfig = !isBuild + ? [ + new BannerPlugin({ + banner: bannerOutput, + entryOnly: true, }), ] : []; @@ -269,13 +406,24 @@ export default function (inMeteor = {}, argv = {}) { let clientConfig = { name: clientNameConfig, target: 'web', - mode: 'development', + mode, entry: path.resolve(process.cwd(), buildContext, entryPath), output: { path: clientOutputDir, - filename: () => - isDevEnvironment ? outputFilename : `../${buildContext}/${outputPath}`, - libraryTarget: 'commonjs', + filename: (_module) => { + const chunkName = _module.chunk?.name; + const isMainChunk = !chunkName || chunkName === "main"; + const chunkSuffix = `${chunksContext}/[id]${ + isProd ? '.[chunkhash]' : '' + }.js`; + if (isDevEnvironment) { + if (isMainChunk) return outputFilename; + return chunkSuffix; + } + if (isMainChunk) return `../${buildContext}/${outputPath}`; + return chunkSuffix; + }, + libraryTarget: 'commonjs2', publicPath: '/', chunkFilename: `${chunksContext}/[id]${isProd ? '.[chunkhash]' : ''}.js`, assetModuleFilename: `${assetsContext}/[hash][ext][query]`, @@ -305,7 +453,7 @@ export default function (inMeteor = {}, argv = {}) { ...extraRules, ], }, - resolve: { extensions, alias }, + resolve: { extensions, alias, fallback }, externals, plugins: [ ...[ @@ -322,17 +470,18 @@ export default function (inMeteor = {}, argv = {}) { 'Meteor.isDevelopment': JSON.stringify(isDev), 'Meteor.isProduction': JSON.stringify(isProd), }), - new BannerPlugin({ - banner: bannerOutput, - entryOnly: true, - }), + ...bannerPluginConfig, Meteor.HtmlRspackPlugin(), ...doctorPluginConfig, + new NormalModuleReplacementPlugin(/^node:(.*)$/, (res) => { + res.request = res.request.replace(/^node:/, ''); + }), ], watchOptions, devtool: isDevEnvironment || isNative || isTest ? 'source-map' : 'hidden-source-map', ...(isDevEnvironment && { devServer: { + ...createRemoteDevServerConfig(), static: { directory: clientOutputDir, publicPath: '/__rspack__/' }, hot: true, liveReload: true, @@ -344,15 +493,26 @@ export default function (inMeteor = {}, argv = {}) { }, }, }), - experiments: { css: true }, + ...merge(cacheStrategy, { experiments: { css: true } }) }; + const serverEntry = isTest && isTestEager && isTestFullApp - ? path.resolve(process.cwd(), 'node_modules/@meteorjs/rspack/entries/eager-app-tests.js') + ? generateEagerTestFile({ + isAppTest: true, + projectDir, + buildContext, + entries: meteorIgnoreEntries, + }) : isTest && isTestEager - ? path.resolve(process.cwd(), 'node_modules/@meteorjs/rspack/entries/eager-tests.js') - : path.resolve(process.cwd(), buildContext, entryPath); + ? generateEagerTestFile({ + isAppTest: false, + projectDir, + buildContext, + entries: meteorIgnoreEntries, + }) + : path.resolve(projectDir, buildContext, entryPath); const serverNameConfig = `[${(isTest && 'test-') || ''}${ (isTestModule && 'module') || 'server' }-rspack]`; @@ -365,12 +525,16 @@ export default function (inMeteor = {}, argv = {}) { output: { path: serverOutputDir, filename: () => `../${buildContext}/${outputPath}`, - libraryTarget: 'commonjs', + libraryTarget: 'commonjs2', chunkFilename: `${chunksContext}/[id]${isProd ? '.[chunkhash]' : ''}.js`, assetModuleFilename: `${assetsContext}/[hash][ext][query]`, ...(isProd && { clean: { keep: keepOutsideBuild() } }), }, - optimization: { usedExports: true }, + optimization: { + usedExports: true, + splitChunks: false, + runtimeChunk: false, + }, module: { rules: [swcConfigRule, ...extraRules], parser: { @@ -383,10 +547,11 @@ export default function (inMeteor = {}, argv = {}) { resolve: { extensions, alias, - modules: ['node_modules', path.resolve(process.cwd())], + modules: ['node_modules', path.resolve(projectDir)], conditionNames: ['import', 'require', 'node', 'default'], }, externals, + externalsPresets: { node: true }, plugins: [ new DefinePlugin( isTest && (isTestModule || isTestEager) @@ -404,27 +569,47 @@ export default function (inMeteor = {}, argv = {}) { 'Meteor.isProduction': JSON.stringify(isProd), }, ), - new BannerPlugin({ - banner: bannerOutput, - entryOnly: true, - }), - isTestModule && requireExternalsPlugin, + ...bannerPluginConfig, + requireExternalsPlugin, ...doctorPluginConfig, ], watchOptions, devtool: isDevEnvironment || isNative || isTest ? 'source-map' : 'hidden-source-map', ...((isDevEnvironment || (isTest && !isTestEager) || isNative) && - createCacheStrategy(mode)), + cacheStrategy), }; // Load and apply project-level overrides for the selected build - const projectConfigPath = path.resolve(process.cwd(), 'rspack.config.js'); - // Check if we're in a Meteor package directory by looking at the path - const isMeteorPackageConfig = process.cwd().includes('/packages/rspack'); + const isMeteorPackageConfig = projectDir.includes('/packages/rspack'); if (fs.existsSync(projectConfigPath) && !isMeteorPackageConfig) { - const projectConfig = - require(projectConfigPath)?.default || require(projectConfigPath); + // Check if there's a .mjs or .cjs version of the config file + const mjsConfigPath = projectConfigPath.replace(/\.js$/, '.mjs'); + const cjsConfigPath = projectConfigPath.replace(/\.js$/, '.cjs'); + + let configPath = projectConfigPath; + if (fs.existsSync(mjsConfigPath)) { + configPath = mjsConfigPath; + } else if (fs.existsSync(cjsConfigPath)) { + configPath = cjsConfigPath; + } + + // Use require for CommonJS modules and dynamic import for ES modules + let projectConfig; + try { + if (path.extname(configPath) === '.mjs') { + // For ESM modules, we need to use dynamic import + const fileUrl = `file://${configPath}`; + const module = await import(fileUrl); + projectConfig = module.default || module; + } else { + // For CommonJS modules, we can use require + projectConfig = require(configPath)?.default || require(configPath); + } + } catch (error) { + console.error(`Error loading rspack config from ${configPath}:`, error); + throw error; + } const userConfig = typeof projectConfig === 'function' @@ -432,29 +617,38 @@ export default function (inMeteor = {}, argv = {}) { : projectConfig; const omitPaths = [ - 'name', - 'target', - 'entry', - 'output.path', - 'output.filename', - 'output.publicPath', - ]; + "name", + "target", + "entry", + "output.path", + "output.filename", + "output.publicPath", + ...(Meteor.isServer + ? ["optimization.splitChunks", "optimization.runtimeChunk"] + : []), + ].filter(Boolean); const warningFn = path => { console.warn( `[rspack.config.js] Ignored custom "${path}" — reserved for Meteor-Rspack integration.`, ); }; + let nextUserConfig = cleanOmittedPaths(userConfig, { + omitPaths, + warningFn, + }); + nextUserConfig = mergeMeteorRspackFragments(nextUserConfig); + if (Meteor.isClient) { clientConfig = mergeSplitOverlap( clientConfig, - cleanOmittedPaths(userConfig, { omitPaths, warningFn }), + nextUserConfig ); } if (Meteor.isServer) { serverConfig = mergeSplitOverlap( serverConfig, - cleanOmittedPaths(userConfig, { omitPaths, warningFn }), + nextUserConfig ); } } diff --git a/package.json b/package.json index 398337f89a..183ff91e63 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,6 @@ "url": "https://github.com/meteor/meteor/issues" }, "homepage": "https://www.meteor.com/", - "scripts": { - "install:modern": "cd tools/modern-tests && npm install && npx playwright install && npx playwright install --with-deps", - "test:modern": "cd tools/modern-tests && npm test -- " - }, "devDependencies": { "@babel/core": "^7.21.3", "@babel/eslint-parser": "^7.21.3", @@ -39,7 +35,9 @@ "typescript": "^5.4.5" }, "scripts": { - "test:idle-bot": "node --test .github/scripts/__tests__/inactive-issues.test.js" + "install:modern": "cd tools/modern-tests && npm install && npx playwright install && npx playwright install --with-deps", + "test:idle-bot": "node --test .github/scripts/__tests__/inactive-issues.test.js", + "test:modern": "cd tools/modern-tests && npm test -- " }, "jshintConfig": { "esversion": 11 diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js index 4190011694..ef6bbb1cb3 100644 --- a/packages/accounts-base/package.js +++ b/packages/accounts-base/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "A user account system", - version: "3.1.2", + version: "3.2.0-beta340.11", }); Package.onUse((api) => { diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index f41e5b8127..e4c64f8d24 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -11,6 +11,7 @@ Package.describe({ Npm.depends({ bcrypt: "5.0.1", argon2: "0.41.1", + "node-gyp-build": "4.8.4", }); Package.onUse((api) => { diff --git a/packages/babel-compiler/babel-compiler.js b/packages/babel-compiler/babel-compiler.js index 860061818a..fe72514001 100644 --- a/packages/babel-compiler/babel-compiler.js +++ b/packages/babel-compiler/babel-compiler.js @@ -246,7 +246,7 @@ BCp.processOneFileForTarget = function (inputFile, source) { // Check if the file is a Rspack output file // If it is, bypass SWC/Babel and just read the file and its map file // as the contents are already transpiled by Rspack. - if (Plugin?.rspackHelpers?.isRspackOutputFile(inputFilePath)) { +if (Plugin?.rspackHelpers?.isRspackOutputFile(inputFilePath)) { try { // Get the full path to the file const fullPath = inputFile.getPathInPackage(); @@ -260,6 +260,17 @@ BCp.processOneFileForTarget = function (inputFile, source) { toBeAdded.sourceMap = JSON.parse(mapContent); } + if (this.isVerbose()) { + const arch = inputFile.getArch(); + logTranspilation({ + usedRspack: true, + inputFilePath, + packageName, + cacheHit: true, + arch, + }); + } + return toBeAdded; } catch (e) { // If there's an error reading the file or map, log it and continue with normal processing @@ -281,7 +292,8 @@ BCp.processOneFileForTarget = function (inputFile, source) { const features = Object.assign({}, this.extraFeatures); const arch = inputFile.getArch(); - if (arch.startsWith("os.")) { + const isNodeTarget = arch.startsWith("os."); + if (isNodeTarget) { // Start with a much simpler set of Babel presets and plugins if // we're compiling for Node 8. features.nodeMajorVersion = parseInt(process.versions.node, 10); @@ -355,8 +367,11 @@ BCp.processOneFileForTarget = function (inputFile, source) { tsx: hasTSXSupport, }, ...(hasSwcHelpersAvailable && + !isNodeTarget && (packageName == null || - !['modules-runtime'].includes(packageName)) && { + !['core-runtime', 'modules', 'modules-runtime'].includes( + packageName, + )) && { externalHelpers: true, }), }, @@ -373,6 +388,7 @@ BCp.processOneFileForTarget = function (inputFile, source) { // Merge with app-level SWC config if (lastModifiedSwcConfig) { swcOptions = deepMerge(swcOptions, lastModifiedSwcConfig, [ + 'jsc.target', 'env.targets', 'module.type', ]); @@ -1120,14 +1136,16 @@ function logTranspilation({ packageName, inputFilePath, usedSwc, + usedRspack, cacheHit, isNodeModulesCode, arch, errorMessage = '', tip = '', }) { - const transpiler = usedSwc ? 'SWC' : 'Babel'; - const transpilerColor = usedSwc ? 32 : 33; + let transpiler = usedSwc ? 'SWC' : 'Babel'; + transpiler = usedRspack ? 'Rspack' : transpiler; + const transpilerColor = usedSwc || usedRspack ? 32 : 33; const label = color('[Transpiler]', 36); const transpilerPart = `${label} Used ${color( transpiler, @@ -1150,7 +1168,7 @@ function logTranspilation({ : color(originPaddedRaw, 35); const cacheStatus = errorMessage ? color('⚠️ Fallback', 33) - : usedSwc + : usedSwc || usedRspack ? cacheHit ? color('🟢 Cache hit', 32) : color('🔴 Cache miss', 31) diff --git a/packages/babel-compiler/package.js b/packages/babel-compiler/package.js index 2e69cdc028..d528724189 100644 --- a/packages/babel-compiler/package.js +++ b/packages/babel-compiler/package.js @@ -1,14 +1,14 @@ Package.describe({ name: "babel-compiler", summary: "Parser/transpiler for ECMAScript 2015+ syntax", - version: '7.12.2', + version: '7.13.0-beta340.11', }); Npm.depends({ '@meteorjs/babel': '7.20.1', 'json5': '2.2.3', 'semver': '7.6.3', - "@meteorjs/swc-core": "1.12.14", + "@meteorjs/swc-core": "1.13.5", }); Package.onUse(function (api) { diff --git a/packages/boilerplate-generator/package.js b/packages/boilerplate-generator/package.js index de1d8f8e40..3fa23d1ec0 100644 --- a/packages/boilerplate-generator/package.js +++ b/packages/boilerplate-generator/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "Generates the boilerplate html from program's manifest", - version: '2.0.2', + version: '2.1.0-beta340.11', }); Npm.depends({ diff --git a/packages/ecmascript/package.js b/packages/ecmascript/package.js index dcc86c7f0b..35c78bd132 100644 --- a/packages/ecmascript/package.js +++ b/packages/ecmascript/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'ecmascript', - version: '0.16.13', + version: '0.17.0-beta340.11', summary: 'Compiler plugin that supports ES2015+ in all .js files', documentation: 'README.md', }); diff --git a/packages/meteor-tool/package.js b/packages/meteor-tool/package.js index 00beae7bc0..856ba17cb7 100644 --- a/packages/meteor-tool/package.js +++ b/packages/meteor-tool/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "The Meteor command-line tool", - version: "3.3.1", + version: "3.4.0-beta.11", }); Package.includeTool(); diff --git a/packages/minifier-js/package.js b/packages/minifier-js/package.js index 788cf783b6..3533f9ba77 100644 --- a/packages/minifier-js/package.js +++ b/packages/minifier-js/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "JavaScript minifier", - version: '3.0.4', + version: '3.1.0-beta340.11', }); Npm.depends({ diff --git a/packages/minimongo/local_collection.js b/packages/minimongo/local_collection.js index 7d564ca564..a345d4938c 100644 --- a/packages/minimongo/local_collection.js +++ b/packages/minimongo/local_collection.js @@ -743,7 +743,7 @@ export default class LocalCollection { for (const id of specificIds) { const doc = this._docs.get(id); - if (doc && !fn(doc, id)) { + if (doc && fn(doc, id) === false) { break } } diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 9801384815..65953b5f91 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -59,6 +59,36 @@ Tinytest.add('minimongo - wrapTransform', test => { handle.stop(); }); +Tinytest.add('minimongo - bulk remove with $in operator removes all matching documents', function(test) { + const coll = new LocalCollection(); + + // Insert multiple documents + const ids = ['id1', 'id2', 'id3', 'id4']; + ids.forEach(id => { + coll.insert({ _id: id, value: `item-${id}` }); + }); + + // Verify we have 4 documents + test.equal(coll.find().count(), 4); + + // Remove 2 documents using $in operator + const removedCount = coll.remove({ _id: { $in: ['id1', 'id2'] } }); + + // This should remove 2 documents, not just 1 + test.equal(removedCount, 2); + + // Verify only 2 documents remain + test.equal(coll.find().count(), 2); + + // Verify the correct documents were removed + test.isUndefined(coll.findOne('id1')); + test.isUndefined(coll.findOne('id2')); + + // Verify the other documents still exist + test.isNotUndefined(coll.findOne('id3')); + test.isNotUndefined(coll.findOne('id4')); +}); + if (Meteor.isClient) { Tinytest.add('minimongo - $geoIntersects should throw error', function(test) { const collection = new LocalCollection(); diff --git a/packages/minimongo/minimongo_tests_client.js b/packages/minimongo/minimongo_tests_client.js index 074705f33c..c1405552a5 100644 --- a/packages/minimongo/minimongo_tests_client.js +++ b/packages/minimongo/minimongo_tests_client.js @@ -4061,4 +4061,4 @@ Tinytest.addAsync('minimongo - operation result fields (async)', async test => { // Test remove const removeResult = await c.removeAsync({name: 'doc1'}); test.equal(removeResult, 1, 'remove should return removed count'); -}); +}); \ No newline at end of file diff --git a/packages/mongo/collection/collection.js b/packages/mongo/collection/collection.js index f41f97a68c..23cb81f7f0 100644 --- a/packages/mongo/collection/collection.js +++ b/packages/mongo/collection/collection.js @@ -62,8 +62,15 @@ Mongo.Collection = function Collection(name, options) { setupAutopublish(this, name, options); Mongo._collections.set(name, this); + + // Apply collection extensions + CollectionExtensions._applyExtensions(this, name, options); }; +// Apply static methods to the Collection constructor +CollectionExtensions._applyStaticMethods(Mongo.Collection); + + Object.assign(Mongo.Collection.prototype, { _getFindSelector(args) { if (args.length == 0) return {}; @@ -153,6 +160,118 @@ Object.assign(Mongo.Collection, { return selector; }, + + // Collection Extensions API - delegate to CollectionExtensions + /** + * @summary Add a constructor extension function that runs when collections are created. + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @param {Function} extension Extension function called with (name, options) and 'this' bound to collection instance + */ + addExtension(extension) { + return CollectionExtensions.addExtension(extension); + }, + + /** + * @summary Add a prototype method to all collection instances. + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @param {String} name The name of the method to add + * @param {Function} method The method function, bound to the collection instance + */ + addPrototypeMethod(name, method) { + return CollectionExtensions.addPrototypeMethod(name, method); + }, + + /** + * @summary Add a static method to the Mongo.Collection constructor. + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @param {String} name The name of the static method to add + * @param {Function} method The static method function + */ + addStaticMethod(name, method) { + return CollectionExtensions.addStaticMethod(name, method); + }, + + /** + * @summary Remove a constructor extension (useful for testing). + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @param {Function} extension The extension function to remove + */ + removeExtension(extension) { + return CollectionExtensions.removeExtension(extension); + }, + + /** + * @summary Remove a prototype method from all collection instances. + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @param {String} name The name of the method to remove + */ + removePrototypeMethod(name) { + return CollectionExtensions.removePrototypeMethod(name); + }, + + /** + * @summary Remove a static method from the Mongo.Collection constructor. + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @param {String} name The name of the static method to remove + */ + removeStaticMethod(name) { + return CollectionExtensions.removeStaticMethod(name); + }, + + /** + * @summary Clear all extensions, prototype methods, and static methods (useful for testing). + * @locus Anywhere + * @memberof Mongo.Collection + * @static + */ + clearExtensions() { + return CollectionExtensions.clearExtensions(); + }, + + /** + * @summary Get all registered constructor extensions (useful for debugging). + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @returns {Array} Array of registered extension functions + */ + getExtensions() { + return CollectionExtensions.getExtensions(); + }, + + /** + * @summary Get all registered prototype methods (useful for debugging). + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @returns {Map} Map of method names to functions + */ + getPrototypeMethods() { + return CollectionExtensions.getPrototypeMethods(); + }, + + /** + * @summary Get all registered static methods (useful for debugging). + * @locus Anywhere + * @memberof Mongo.Collection + * @static + * @returns {Map} Map of method names to functions + */ + getStaticMethods() { + return CollectionExtensions.getStaticMethods(); + } }); Object.assign(Mongo.Collection.prototype, ReplicationMethods, SyncMethods, AsyncMethods, IndexMethods); @@ -230,6 +349,13 @@ Object.assign(Mongo, { * @protected */ _collections: new Map(), + + /** + * @summary Collection Extensions API + * @memberof Mongo + * @static + */ + CollectionExtensions: CollectionExtensions }) diff --git a/packages/mongo/collection/collection_extensions.js b/packages/mongo/collection/collection_extensions.js new file mode 100644 index 0000000000..c259f9a4f2 --- /dev/null +++ b/packages/mongo/collection/collection_extensions.js @@ -0,0 +1,146 @@ +/** + * Collection Extensions System + * + * Provides a clean way to extend Mongo.Collection functionality + * without monkey patching. Supports constructor extensions, + * prototype methods, and static methods. + */ + +if (Package['lai:collection-extensions']) { + console.warn('lai:collection-extensions is not deprecated. Use Mongo.Collection.addExtension instead.'); +} + +CollectionExtensions = { + _extensions: [], + _prototypeMethods: new Map(), + _staticMethods: new Map(), + + /** + * Add a constructor extension function + * Extension function is called with (name, options) and 'this' bound to collection instance + */ + addExtension(extension) { + if (typeof extension !== 'function') { + throw new Error('Extension must be a function'); + } + this._extensions.push(extension); + }, + + /** + * Add a prototype method to all collection instances + * Method is bound to the collection instance + */ + addPrototypeMethod(name, method) { + if (typeof name !== 'string' || !name) { + throw new Error('Prototype method name must be a non-empty string'); + } + if (typeof method !== 'function') { + throw new Error('Prototype method must be a function'); + } + + this._prototypeMethods.set(name, method); + }, + + /** + * Add a static method to the Mongo.Collection constructor + */ + addStaticMethod(name, method) { + if (typeof name !== 'string' || !name) { + throw new Error('Static method name must be a non-empty string'); + } + if (typeof method !== 'function') { + throw new Error('Static method must be a function'); + } + + this._staticMethods.set(name, method); + }, + + /** + * Remove an extension (useful for testing) + */ + removeExtension(extension) { + const index = this._extensions.indexOf(extension); + if (index > -1) { + this._extensions.splice(index, 1); + } + }, + + /** + * Remove a prototype method + */ + removePrototypeMethod(name) { + this._prototypeMethods.delete(name); + }, + + /** + * Remove a static method + */ + removeStaticMethod(name) { + this._staticMethods.delete(name); + }, + + /** + * Clear all extensions (useful for testing) + */ + clearExtensions() { + this._extensions.length = 0; + this._prototypeMethods.clear(); + this._staticMethods.clear(); + }, + + /** + * Get all registered extensions (useful for debugging) + */ + getExtensions() { + return [...this._extensions]; + }, + + /** + * Get all registered prototype methods (useful for debugging) + */ + getPrototypeMethods() { + return new Map(this._prototypeMethods); + }, + + /** + * Get all registered static methods (useful for debugging) + */ + getStaticMethods() { + return new Map(this._staticMethods); + }, + + + + /** + * Apply all extensions to a collection instance + * Called during collection construction + */ + _applyExtensions(instance, name, options) { + // Apply constructor extensions + for (const extension of this._extensions) { + try { + extension.call(instance, name, options); + } catch (error) { + // Provide helpful error context + throw new Error(`Extension failed for collection '${name}': ${error.message}`); + } + } + + // Apply prototype methods + for (const [methodName, method] of this._prototypeMethods) { + instance[methodName] = method.bind(instance); + } + }, + + /** + * Apply static methods to the Mongo.Collection constructor + * Called during package initialization + */ + _applyStaticMethods(CollectionConstructor) { + for (const [methodName, method] of this._staticMethods) { + CollectionConstructor[methodName] = method; + } + }, + + +}; \ No newline at end of file diff --git a/packages/mongo/mongo.d.ts b/packages/mongo/mongo.d.ts index 2f3c941732..0d499ee588 100644 --- a/packages/mongo/mongo.d.ts +++ b/packages/mongo/mongo.d.ts @@ -53,6 +53,50 @@ export namespace Mongo { ? T : U; + /** + * Configuration options for Mongo Collection constructor + */ + interface CollectionOptions { + /** + * The server connection that will manage this collection. Uses the default connection if not specified. + * Pass the return value of calling `DDP.connect` to specify a different server. Pass `null` to specify + * no connection. Unmanaged (`name` is null) collections cannot specify a connection. + */ + connection?: DDP.DDPStatic | null | undefined; + + /** + * The method of generating the `_id` fields of new documents in this collection. Possible values: + * - **`'STRING'`**: random strings + * - **`'MONGO'`**: random [`Mongo.ObjectID`](#mongo_object_id) values + * + * The default id generation technique is `'STRING'`. + */ + idGeneration?: string | undefined; + + /** + * An optional transformation function. Documents will be passed through this function before being + * returned from `fetch` or `findOne`, and before being passed to callbacks of `observe`, `map`, + * `forEach`, `allow`, and `deny`. Transforms are *not* applied for the callbacks of `observeChanges` + * or to cursors returned from publish functions. + */ + transform?: (doc: T) => U; + + /** + * Set to `false` to skip setting up the mutation methods that enable insert/update/remove from client code. + * Default `true`. + */ + defineMutationMethods?: boolean | undefined; + + // Internal options (from normalizeOptions function) + /** @internal */ + _driver?: any; + /** @internal */ + _preventAutopublish?: boolean; + + // Allow additional properties for extensibility + [key: string]: any; + } + var Collection: CollectionStatic; interface CollectionStatic { /** @@ -61,27 +105,7 @@ export namespace Mongo { */ new ( name: string | null, - options?: { - /** - * The server connection that will manage this collection. Uses the default connection if not specified. Pass the return value of calling `DDP.connect` to specify a different - * server. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection. - */ - connection?: DDP.DDPStatic | null | undefined; - /** The method of generating the `_id` fields of new documents in this collection. Possible values: - * - **`'STRING'`**: random strings - * - **`'MONGO'`**: random [`Mongo.ObjectID`](#mongo_object_id) values - * - * The default id generation technique is `'STRING'`. - */ - idGeneration?: string | undefined; - /** - * An optional transformation function. Documents will be passed through this function before being returned from `fetch` or `findOne`, and before being passed to callbacks of - * `observe`, `map`, `forEach`, `allow`, and `deny`. Transforms are *not* applied for the callbacks of `observeChanges` or to cursors returned from publish functions. - */ - transform?: (doc: T) => U; - /** Set to `false` to skip setting up the mutation methods that enable insert/update/remove from client code. Default `true`. */ - defineMutationMethods?: boolean | undefined; - } + options?: CollectionOptions ): Collection; /** @@ -92,6 +116,68 @@ export namespace Mongo { getCollection< TCollection extends Collection | undefined = Collection | undefined >(name: string): TCollection; + + // Collection Extensions API + /** + * Add a constructor extension function that runs when collections are created. + * @param extension Extension function called with (name, options) and 'this' bound to collection instance + */ + addExtension(extension: (this: Collection, name: string | null, options?: CollectionOptions) => void): void; + + /** + * Add a prototype method to all collection instances. + * @param name The name of the method to add + * @param method The method function, bound to the collection instance + */ + addPrototypeMethod(name: string, method: (this: Collection, ...args: any[]) => any): void; + + /** + * Add a static method to the Mongo.Collection constructor. + * @param name The name of the static method to add + * @param method The static method function + */ + addStaticMethod(name: string, method: Function): void; + + /** + * Remove a constructor extension (useful for testing). + * @param extension The extension function to remove + */ + removeExtension(extension: Function): void; + + /** + * Remove a prototype method from all collection instances. + * @param name The name of the method to remove + */ + removePrototypeMethod(name: string): void; + + /** + * Remove a static method from the Mongo.Collection constructor. + * @param name The name of the static method to remove + */ + removeStaticMethod(name: string): void; + + /** + * Clear all extensions, prototype methods, and static methods (useful for testing). + */ + clearExtensions(): void; + + /** + * Get all registered constructor extensions (useful for debugging). + * @returns Array of registered extension functions + */ + getExtensions(): Array; + + /** + * Get all registered prototype methods (useful for debugging). + * @returns Map of method names to functions + */ + getPrototypeMethods(): Map; + + /** + * Get all registered static methods (useful for debugging). + * @returns Map of method names to functions + */ + getStaticMethods(): Map; } interface Collection { allow = undefined>(options: { @@ -479,6 +565,87 @@ export namespace Mongo { equals(otherID: ObjectID): boolean; } + /** + * Collection Extensions API + */ + interface CollectionExtensions { + /** + * Add a constructor extension function that runs when collections are created. + * @param extension Extension function called with (name, options) and 'this' bound to collection instance + */ + addExtension(extension: (this: Collection, name: string | null, options?: CollectionOptions) => void): void; + + /** + * Add a prototype method to all collection instances. + * @param name The name of the method to add + * @param method The method function, bound to the collection instance + */ + addPrototypeMethod(name: string, method: (this: Collection, ...args: any[]) => any): void; + + /** + * Add a static method to the Mongo.Collection constructor. + * @param name The name of the static method to add + * @param method The static method function + */ + addStaticMethod(name: string, method: Function): void; + + /** + * Remove a constructor extension (useful for testing). + * @param extension The extension function to remove + */ + removeExtension(extension: Function): void; + + /** + * Remove a prototype method from all collection instances. + * @param name The name of the method to remove + */ + removePrototypeMethod(name: string): void; + + /** + * Remove a static method from the Mongo.Collection constructor. + * @param name The name of the static method to remove + */ + removeStaticMethod(name: string): void; + + /** + * Clear all extensions, prototype methods, and static methods (useful for testing). + */ + clearExtensions(): void; + + /** + * Get all registered constructor extensions (useful for debugging). + * @returns Array of registered extension functions + */ + getExtensions(): Array; + + /** + * Get all registered prototype methods (useful for debugging). + * @returns Map of method names to functions + */ + getPrototypeMethods(): Map; + + /** + * Get all registered static methods (useful for debugging). + * @returns Map of method names to functions + */ + getStaticMethods(): Map; + } + + var CollectionExtensions: CollectionExtensions; + + /** + * Retrieve a Meteor collection instance by name. Only collections defined with `new Mongo.Collection(...)` are available with this method. + * @param name Name of your collection as it was defined with `new Mongo.Collection()`. + * @returns The collection instance or undefined if not found + */ + function getCollection | undefined = Collection | undefined>(name: string): T; + + /** + * A record of all defined Mongo.Collection instances, indexed by collection name. + * @internal + */ + var _collections: Map>; + function setConnectionOptions(options: any): void; } diff --git a/packages/mongo/oplog_tailing.ts b/packages/mongo/oplog_tailing.ts index 2df4bf7aaa..1a84fd2c16 100644 --- a/packages/mongo/oplog_tailing.ts +++ b/packages/mongo/oplog_tailing.ts @@ -41,6 +41,8 @@ export class OplogHandle { excludeCollections?: string[]; includeCollections?: string[]; }; + private _includeNSRegex?: RegExp; + private _excludeNSRegex?: RegExp; private _stopped: boolean; private _tailHandle: any; private _readyPromiseResolver: (() => void) | null; @@ -82,6 +84,18 @@ export class OplogHandle { } this._oplogOptions = { includeCollections, excludeCollections }; + if (includeCollections?.length) { + const incAlt = includeCollections.map((c) => Meteor._escapeRegExp(c)).join('|'); + + this._includeNSRegex = new RegExp(`^${Meteor._escapeRegExp(this._dbName)}\\.(?:${incAlt})$`); + } + + if (excludeCollections?.length) { + const excAlt = excludeCollections.map((c) => Meteor._escapeRegExp(c)).join('|'); + + this._excludeNSRegex = new RegExp(`^${Meteor._escapeRegExp(this._dbName)}\\.(?:${excAlt})$`); + } + this._catchingUpResolvers = []; this._lastProcessedTS = null; @@ -92,6 +106,15 @@ export class OplogHandle { this._startTrailingPromise = this._startTailing(); } + private _nsAllowed(ns: string | undefined): boolean { + if (!ns) return false; + if (ns === 'admin.$cmd') return true; + if (this._includeNSRegex && !this._includeNSRegex.test(ns)) return false; + if (this._excludeNSRegex && this._excludeNSRegex.test(ns)) return false; + + return true; + } + private _getOplogSelector(lastProcessedTS?: any): any { const oplogCriteria: any = [ { @@ -104,40 +127,55 @@ export class OplogHandle { }, ]; - const nsRegex = new RegExp( - "^(?:" + - [ - // @ts-ignore - Meteor._escapeRegExp(this._dbName + "."), - // @ts-ignore - Meteor._escapeRegExp("admin.$cmd"), - ].join("|") + - ")" - ); - if (this._oplogOptions.excludeCollections?.length) { - oplogCriteria.push({ - ns: { - $regex: nsRegex, - $nin: this._oplogOptions.excludeCollections.map( - (collName: string) => `${this._dbName}.${collName}` - ), - }, - }); - } else if (this._oplogOptions.includeCollections?.length) { + const nsRegex = new RegExp( + '^(?:' + + [ + // @ts-ignore + Meteor._escapeRegExp(this._dbName + '.'), + ].join('|') + + ')' + ); + const excludeNs = { + $regex: nsRegex, + $nin: this._oplogOptions.excludeCollections.map( + (collName: string) => `${this._dbName}.${collName}` + ), + }; oplogCriteria.push({ $or: [ - { ns: /^admin\.\$cmd/ }, + { ns: excludeNs }, { - ns: { - $in: this._oplogOptions.includeCollections.map( - (collName: string) => `${this._dbName}.${collName}` - ), - }, + ns: /^admin\.\$cmd/, + 'o.applyOps': { $elemMatch: { ns: excludeNs } }, }, ], }); + } else if (this._oplogOptions.includeCollections?.length) { + const includeNs = { + $in: this._oplogOptions.includeCollections.map( + (collName: string) => `${this._dbName}.${collName}` + ), + }; + oplogCriteria.push({ + $or: [ + { + ns: includeNs, + }, + { ns: /^admin\.\$cmd/, 'o.applyOps.ns': includeNs }, + ], + }); } else { + const nsRegex = new RegExp( + "^(?:" + + [ + // @ts-ignore + Meteor._escapeRegExp(this._dbName + "."), + // @ts-ignore + Meteor._escapeRegExp("admin.$cmd"), + ].join("|") + + ")" + ); oplogCriteria.push({ ns: nsRegex, }); @@ -411,6 +449,11 @@ async function handleDoc(handle: OplogHandle, doc: OplogEntry): Promise { op.ts = nextTimestamp; nextTimestamp = nextTimestamp.add(Long.ONE); } + // Only forward sub-ops whose ns is allowed + // See https://github.com/meteor/meteor/issues/13945 + if (!handle['_nsAllowed'](op.ns)) { + continue; + } await handleDoc(handle, op); } return; diff --git a/packages/mongo/package.js b/packages/mongo/package.js index c0ff30242f..749fa259e1 100644 --- a/packages/mongo/package.js +++ b/packages/mongo/package.js @@ -79,6 +79,7 @@ Package.onUse(function (api) { api.export("MongoInternals", "server"); api.export("Mongo"); + api.export("CollectionExtensions"); api.export("ObserveMultiplexer", "server", { testOnly: true }); api.addFiles( @@ -100,6 +101,7 @@ Package.onUse(function (api) { ); api.addFiles("local_collection_driver.js", ["client", "server"]); api.addFiles("remote_collection_driver.ts", "server"); + api.addFiles("collection/collection_extensions.js", ["client", "server"]); api.addFiles("collection/collection.js", ["client", "server"]); api.addFiles("connection_options.ts", "server"); // For zodern:types to pick up our published types. @@ -130,6 +132,7 @@ Package.onTest(function (api) { api.addFiles("tests/collection_tests.js", ["client", "server"]); api.addFiles("tests/collection_async_tests.js", ["client", "server"]); api.addFiles("tests/observe_changes_tests.js", ["client", "server"]); + api.addFiles("tests/collection_extensions_tests.js", ["client", "server"]); api.addFiles("tests/oplog_tests.js", "server"); api.addFiles("tests/oplog_v2_converter_tests.js", "server"); api.addFiles("tests/doc_fetcher_tests.js", "server"); diff --git a/packages/mongo/tests/collection_extensions_tests.js b/packages/mongo/tests/collection_extensions_tests.js new file mode 100644 index 0000000000..0a6e9dc3c5 --- /dev/null +++ b/packages/mongo/tests/collection_extensions_tests.js @@ -0,0 +1,233 @@ +import { Tinytest } from "meteor/tinytest"; +import { Mongo } from "meteor/mongo"; +import { CollectionExtensions } from "meteor/mongo"; +import { Random } from "meteor/random"; + +// Test setup and teardown +function setupTest() { + CollectionExtensions.clearExtensions(); +} + +function teardownTest() { + CollectionExtensions.clearExtensions(); +} + +Tinytest.add("CollectionExtensions - constructor extension", function (test) { + setupTest(); + + let extensionCallCount = 0; + let extensionData = null; + + CollectionExtensions.addExtension(function(name, options) { + extensionCallCount++; + extensionData = { name, options, instance: this }; + }); + + const testCollection = new Mongo.Collection(Random.id()); + + test.equal(extensionCallCount, 1); + test.equal(extensionData.name, testCollection._name); + test.equal(extensionData.instance, testCollection); + test.isTrue(extensionData.options && typeof extensionData.options === 'object'); + + teardownTest(); +}); + +Tinytest.add("CollectionExtensions - multiple extensions", function (test) { + setupTest(); + + let callOrder = []; + + CollectionExtensions.addExtension(function(name, options) { + callOrder.push('extension1'); + }); + + CollectionExtensions.addExtension(function(name, options) { + callOrder.push('extension2'); + }); + + CollectionExtensions.addExtension(function(name, options) { + callOrder.push('extension3'); + }); + + const testCollection = new Mongo.Collection(Random.id()); + + test.equal(callOrder, ['extension1', 'extension2', 'extension3']); + + teardownTest(); +}); + +Tinytest.add("CollectionExtensions - prototype methods", function (test) { + setupTest(); + + CollectionExtensions.addPrototypeMethod('testMethod', function() { + return 'testResult'; + }); + + const testCollection = new Mongo.Collection(Random.id()); + + test.isTrue(typeof testCollection.testMethod === 'function'); + test.equal(testCollection.testMethod(), 'testResult'); + + teardownTest(); +}); + +// Test prototype method with collection context +Tinytest.add("CollectionExtensions - prototype method context", function (test) { + setupTest(); + + // Add prototype method that uses collection context + CollectionExtensions.addPrototypeMethod('getCollectionName', function() { + return this._name; + }); + + // Create collection + const testCollection = new Mongo.Collection(Random.id()); + + // Verify method has correct context + test.equal(testCollection.getCollectionName(), testCollection._name); + + teardownTest(); +}); + +// Test static methods +Tinytest.add("CollectionExtensions - static methods", function (test) { + setupTest(); + + // Add static method + CollectionExtensions.addStaticMethod('testStaticMethod', function() { + return 'staticResult'; + }); + + // Apply static methods (this happens automatically in real usage) + CollectionExtensions._applyStaticMethods(Mongo.Collection); + + // Verify static method was added + test.isTrue(typeof Mongo.Collection.testStaticMethod === 'function'); + test.equal(Mongo.Collection.testStaticMethod(), 'staticResult'); + + // Clean up + delete Mongo.Collection.testStaticMethod; + teardownTest(); +}); + +// Test error handling in extensions +Tinytest.add("CollectionExtensions - extension error handling", function (test) { + setupTest(); + + // Add extension that throws error + CollectionExtensions.addExtension(function(name, options) { + throw new Error('Test extension error'); + }); + + // Creating collection should throw with helpful error message + test.throws(() => { + new Mongo.Collection(Random.id()); + }, /Extension failed for collection/); + + teardownTest(); +}); + +// Test extension removal +Tinytest.add("CollectionExtensions - extension removal", function (test) { + setupTest(); + + let callCount = 0; + + const extension = function(name, options) { + callCount++; + }; + + CollectionExtensions.addExtension(extension); + + const testCollection1 = new Mongo.Collection(Random.id()); + test.equal(callCount, 1); + + CollectionExtensions.removeExtension(extension); + + // Create another collection - should not call extension + const testCollection2 = new Mongo.Collection(Random.id()); + test.equal(callCount, 1); // Still 1, not 2 + + teardownTest(); +}); + +Tinytest.add("CollectionExtensions - prototype method removal", function (test) { + setupTest(); + + CollectionExtensions.addPrototypeMethod('testMethod', function() { + return 'test'; + }); + + const testCollection1 = new Mongo.Collection(Random.id()); + test.isTrue(typeof testCollection1.testMethod === 'function'); + + CollectionExtensions.removePrototypeMethod('testMethod'); + + const testCollection2 = new Mongo.Collection(Random.id()); + test.isUndefined(testCollection2.testMethod); + + teardownTest(); +}); + +Tinytest.add("CollectionExtensions - input validation", function (test) { + setupTest(); + + test.throws(() => { + CollectionExtensions.addExtension("not a function"); + }, /Extension must be a function/); + + test.throws(() => { + CollectionExtensions.addPrototypeMethod("", function() {}); + }, /Prototype method name must be a non-empty string/); + + test.throws(() => { + CollectionExtensions.addPrototypeMethod(123, function() {}); + }, /Prototype method name must be a non-empty string/); + + test.throws(() => { + CollectionExtensions.addPrototypeMethod("test", "not a function"); + }, /Prototype method must be a function/); + + test.throws(() => { + CollectionExtensions.addStaticMethod("", function() {}); + }, /Static method name must be a non-empty string/); + + test.throws(() => { + CollectionExtensions.addStaticMethod("test", "not a function"); + }, /Static method must be a function/); + + teardownTest(); +}); + +Tinytest.add("CollectionExtensions - introspection", function (test) { + setupTest(); + + const extension1 = function() {}; + const extension2 = function() {}; + + test.equal(CollectionExtensions.getExtensions(), []); + test.equal(CollectionExtensions.getPrototypeMethods().size, 0); + test.equal(CollectionExtensions.getStaticMethods().size, 0); + + CollectionExtensions.addExtension(extension1); + CollectionExtensions.addExtension(extension2); + CollectionExtensions.addPrototypeMethod('test1', function() {}); + CollectionExtensions.addStaticMethod('test2', function() {}); + + // Test introspection + const extensions = CollectionExtensions.getExtensions(); + test.equal(extensions.length, 2); + test.equal(extensions[0], extension1); + test.equal(extensions[1], extension2); + + const prototypeMethods = CollectionExtensions.getPrototypeMethods(); + test.equal(prototypeMethods.size, 1); + test.isTrue(prototypeMethods.has('test1')); + + const staticMethods = CollectionExtensions.getStaticMethods(); + test.equal(staticMethods.size, 1); + test.isTrue(staticMethods.has('test2')); + + teardownTest(); +}); \ No newline at end of file diff --git a/packages/mongo/tests/mongo_livedata_tests.js b/packages/mongo/tests/mongo_livedata_tests.js index af69aee7b4..f3e32106a5 100644 --- a/packages/mongo/tests/mongo_livedata_tests.js +++ b/packages/mongo/tests/mongo_livedata_tests.js @@ -2776,6 +2776,53 @@ const setsEqual = function (a, b) { }); }); + // Test operation result fields with allow/deny rules (similar to issue #12159) + if (Meteor.isServer) { + testAsyncMulti('mongo-livedata - operation result fields with allow/deny, ' + idGeneration, [ + async function(test, expect) { + var collectionName = 'test_operation_results_' + Random.id(); + var coll = new Mongo.Collection(collectionName, { idGeneration: idGeneration }); + + // Set up allow rules for all operations + coll.allow({ + insert: function() { return true; }, + update: function() { return true; }, + remove: function() { return true; } + }); + + // Test insert + var insertedId = await coll.insertAsync({name: 'doc1'}); + test.isTrue(insertedId !== undefined, 'insert should return an ID'); + + // Test update + var updateResult = await coll.updateAsync({name: 'doc1'}, {$set: {value: 1}}); + test.equal(updateResult, 1, 'update should return affected count'); + + // Test upsert (update case) + var upsertUpdateResult = await coll.upsertAsync({name: 'doc1'}, {$set: {value: 2}}); + test.equal(upsertUpdateResult.numberAffected, 1); + test.isFalse(upsertUpdateResult.hasOwnProperty('insertedId')); + + // Test upsert (insert case) + var upsertInsertResult = await coll.upsertAsync({name: 'doc2'}, {$set: {value: 3}}); + test.equal(upsertInsertResult.numberAffected, 1); + test.isTrue(upsertInsertResult.hasOwnProperty('insertedId')); + + // Test remove + var removeResult = await coll.removeAsync({name: 'doc1'}); + test.equal(removeResult, 1, 'remove should return removed count'); + + // Test insert with explicit ID + var explicitId = idGeneration === 'MONGO' ? new Mongo.ObjectID() : 'explicit-test-id'; + var insertExplicitResult = await coll.insertAsync({_id: explicitId, name: 'explicit-doc'}); + test.equal(insertExplicitResult, explicitId, 'insert with explicit ID should return that ID'); + + // Clean up + await coll.dropCollectionAsync(); + } + ]); + } + }); // end idGeneration parametrization Tinytest.add('mongo-livedata - rewrite selector', function(test) { diff --git a/packages/mongo/tests/oplog_tests.js b/packages/mongo/tests/oplog_tests.js index 2ffb0c32eb..0ef4bf8089 100644 --- a/packages/mongo/tests/oplog_tests.js +++ b/packages/mongo/tests/oplog_tests.js @@ -181,11 +181,46 @@ process.env.MONGO_OPLOG_URL && const defaultOplogHandle = MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle; let previousMongoPackageSettings = {}; +async function oplogSimpleInsertion(IncludeCollection, ExcludeCollection) { + await IncludeCollection.rawCollection().insertOne({ include: 'yes', foo: 'bar' }); + await ExcludeCollection.rawCollection().insertOne({ include: 'no', foo: 'bar' }); +} + +async function oplogInsertionTransaction(IncludeCollection, ExcludeCollection) { + const client = MongoInternals.defaultRemoteCollectionDriver().mongo.client; + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + await IncludeCollection.rawCollection().insertOne({ include: 'yes', foo: 'bar' }, { session }); + await ExcludeCollection.rawCollection().insertOne({ include: 'no', foo: 'bar' }, { session }); + }); + } finally { + await session.endSession(); + } +} + +async function oplogMassiveInsertion(IncludeCollection, ExcludeCollection) { + const totalDocuments = 10000; + const documentInclude = Array.from( + { length: totalDocuments }, + (_, index) => ({ include: "yes", foo: "bar" + index }) + ); + const documentExclude = Array.from( + { length: totalDocuments }, + (_, index) => ({ include: "no", foo: "bar" + index }) + ); + + await IncludeCollection.rawCollection().insertMany(documentInclude); + await ExcludeCollection.rawCollection().insertMany(documentExclude); +} + async function oplogOptionsTest({ test, includeCollectionName, excludeCollectionName, - mongoPackageSettings = {} + mongoPackageSettings = {}, + functionToRun }) { try { previousMongoPackageSettings = { ...(Meteor.settings?.packages?.mongo || {}) }; @@ -199,9 +234,11 @@ async function oplogOptionsTest({ const IncludeCollection = new Mongo.Collection(includeCollectionName); const ExcludeCollection = new Mongo.Collection(excludeCollectionName); - const shouldBeTracked = new Promise((resolve) => { - IncludeCollection.find({ include: 'yes' }).observeChanges({ - added(id, fields) { resolve(true) } + const shouldBeTracked = new Promise((resolve, reject) => { + IncludeCollection.find({ include: "yes" }).observeChanges({ + added(id, fields) { + resolve(true); + }, }); }); const shouldBeIgnored = new Promise((resolve, reject) => { @@ -218,8 +255,7 @@ async function oplogOptionsTest({ }); // do the inserts: - await IncludeCollection.rawCollection().insertOne({ include: 'yes', foo: 'bar' }); - await ExcludeCollection.rawCollection().insertOne({ include: 'no', foo: 'bar' }); + await functionToRun(IncludeCollection, ExcludeCollection); test.equal(await shouldBeTracked, true); test.equal(await shouldBeIgnored, true); @@ -229,6 +265,73 @@ async function oplogOptionsTest({ MongoInternals.defaultRemoteCollectionDriver().mongo._setOplogHandle(defaultOplogHandle); } } +async function oplogTailingOptionsTest({ + test, + includeCollectionName, + excludeCollectionName, + mongoPackageSettings = {}, + functionToRun +}) { + let stopRaw; + try { + previousMongoPackageSettings = { ...(Meteor.settings?.packages?.mongo || {}) }; + if (!Meteor.settings.packages) Meteor.settings.packages = {}; + Meteor.settings.packages.mongo = mongoPackageSettings; + + const myOplogHandle = new MongoInternals.OplogHandle(process.env.MONGO_OPLOG_URL, 'meteor'); + await myOplogHandle._startTrailingPromise; + + const IncludeCollection = new Mongo.Collection(includeCollectionName); + const ExcludeCollection = new Mongo.Collection(excludeCollectionName); + + // Listen for INCLUDE collection oplog entries + const includeSeen = new Promise(async (resolve, reject) => { + const includeStop = await myOplogHandle.onOplogEntry( + { dropCollection: false, dropDatabase: false, collection: includeCollectionName }, + ({ op, collection, id }) => { + try { + // Only accept actual inserts for the include collection + if (op?.op === 'i' && collection === includeCollectionName && op?.o?.include === 'yes') { + includeStop.stop(); + resolve(true); + } + } catch (e) { + includeStop.stop(); + reject(e); + } + } + ); + }); + + // Ensure EXCLUDE collection does NOT get processed + const excludeNotSeen = new Promise(async (resolve, reject) => { + const excludeStop = await myOplogHandle.onOplogEntry( + { dropCollection: false, dropDatabase: false, collection: excludeCollectionName }, + ({ op, collection, id }) => { + // If anything for excluded collection arrives, fail + excludeStop.stop(); + reject("Recieved a document in a excluded collection"); + } + ); + // Resolve after 2s if nothing arrived + setTimeout(() => { + excludeStop.stop(); + resolve(true); + }, 2000); + }); + + // Do the inserts (e.g., oplogInsertionTransaction or your chosen function) + await functionToRun(IncludeCollection, ExcludeCollection); + + // Await raw-oplog assertions + test.equal(await includeSeen, true); + test.equal(await excludeNotSeen, true); + } finally { + if (stopRaw?.stop) await stopRaw.stop(); + // Reset: + Meteor.settings.packages.mongo = { ...previousMongoPackageSettings }; + } +} process.env.MONGO_OPLOG_URL && Tinytest.addAsync( 'mongo-livedata - oplog - oplogSettings - oplogExcludeCollections', @@ -242,7 +345,8 @@ process.env.MONGO_OPLOG_URL && Tinytest.addAsync( test, includeCollectionName: collectionNameA, excludeCollectionName: collectionNameB, - mongoPackageSettings + mongoPackageSettings, + functionToRun: oplogSimpleInsertion }); } ); @@ -259,7 +363,8 @@ process.env.MONGO_OPLOG_URL && Tinytest.addAsync( test, includeCollectionName: collectionNameB, excludeCollectionName: collectionNameA, - mongoPackageSettings + mongoPackageSettings, + functionToRun: oplogSimpleInsertion }); } ); @@ -279,7 +384,8 @@ process.env.MONGO_OPLOG_URL && Tinytest.addAsync( test, includeCollectionName: collectionNameA, excludeCollectionName: collectionNameB, - mongoPackageSettings + mongoPackageSettings, + functionToRun: oplogSimpleInsertion }); test.fail(); } catch (err) { @@ -350,6 +456,78 @@ process.env.MONGO_OPLOG_URL && } ); +process.env.MONGO_OPLOG_URL && Tinytest.addAsync( + 'mongo-livedata - oplog - oplogSettings - massiveInsertion - oplogIncludeCollections', + async test => { + const collectionNameA = "oplog-a-massive-" + Random.id(); + const collectionNameB = "oplog-b-massive-" + Random.id(); + const mongoPackageSettings = { + oplogIncludeCollections: [collectionNameA] + }; + await oplogTailingOptionsTest({ + test, + includeCollectionName: collectionNameA, + excludeCollectionName: collectionNameB, + mongoPackageSettings, + functionToRun: oplogMassiveInsertion + }); + } +); + +process.env.MONGO_OPLOG_URL && Tinytest.addAsync( + 'mongo-livedata - oplog - oplogSettings - massiveInsertion - oplogExcludeCollections', + async test => { + const collectionNameA = "oplog-a-massive-" + Random.id(); + const collectionNameB = "oplog-b-massive-" + Random.id(); + const mongoPackageSettings = { + oplogExcludeCollections: [collectionNameA] + }; + await oplogTailingOptionsTest({ + test, + includeCollectionName: collectionNameB, + excludeCollectionName: collectionNameA, + mongoPackageSettings, + functionToRun: oplogMassiveInsertion + }); + } +); + +process.env.MONGO_OPLOG_URL && Tinytest.addAsync( + 'mongo-livedata - oplog - oplogSettings - transaction - oplogExcludeCollections', + async test => { + const collectionNameA = "oplog-a-transaction-" + Random.id(); + const collectionNameB = "oplog-b-transaction-" + Random.id(); + const mongoPackageSettings = { + oplogExcludeCollections: [collectionNameA] + }; + await oplogTailingOptionsTest({ + test, + includeCollectionName: collectionNameB, + excludeCollectionName: collectionNameA, + mongoPackageSettings, + functionToRun: oplogInsertionTransaction + }); + } +); + +process.env.MONGO_OPLOG_URL && Tinytest.addAsync( + 'mongo-livedata - oplog - oplogSettings - transaction - oplogIncludeCollections', + async test => { + const collectionNameA = "oplog-a-transaction-" + Random.id(); + const collectionNameB = "oplog-b-transaction-" + Random.id(); + const mongoPackageSettings = { + oplogIncludeCollections: [collectionNameA] + }; + await oplogTailingOptionsTest({ + test, + includeCollectionName: collectionNameA, + excludeCollectionName: collectionNameB, + mongoPackageSettings, + functionToRun: oplogInsertionTransaction + }); + } +); + // TODO this is commented for now, but we need to find out the cause // PR: https://github.com/meteor/meteor/pull/12057 // Meteor.isServer && Tinytest.addAsync( diff --git a/packages/rspack/lib/build-context.js b/packages/rspack/lib/build-context.js index 03b13c444b..76365b0f34 100644 --- a/packages/rspack/lib/build-context.js +++ b/packages/rspack/lib/build-context.js @@ -7,6 +7,8 @@ import { RSPACK_DOCTOR_CONTEXT } from "./constants"; const fs = require('fs'); const path = require('path'); +const { getCustomConfigFilePath } = require('./processes'); + const { logError } = require('meteor/tools-core/lib/log'); const { capitalizeFirstLetter } = require('meteor/tools-core/lib/string'); @@ -577,13 +579,22 @@ export function cleanBuildContextFiles() { /** * Ensures the rspack.config.js file exists at the project level * Creates the file if it doesn't exist with the required template - * @returns {string} Path to the rspack.config.js file + * Will not create a new file if rspack.config.mjs or rspack.config.cjs exists + * @returns {string} Path to the rspack.config file (.js, .mjs, or .cjs) */ export function ensureRspackConfigExists() { const appDir = getMeteorAppDir(); - const configPath = path.join(appDir, 'rspack.config.js'); - const configTemplate = `import { defineConfig } from '@meteorjs/rspack'; + // Check if any config file already exists using the helper function + const existingConfigPath = getCustomConfigFilePath(appDir); + if (existingConfigPath) { + return existingConfigPath; + } + + // If no config file exists, we'll create a .js one + const jsConfigPath = path.join(appDir, 'rspack.config.js'); + + const configTemplate = `const { defineConfig } = require('@meteorjs/rspack'); /** * Rspack configuration for Meteor projects. @@ -595,19 +606,19 @@ export function ensureRspackConfigExists() { * * Use these flags to adjust your build settings based on environment. */ -export default defineConfig(Meteor => { +module.exports = defineConfig(Meteor => { return {}; }); `; - if (!fs.existsSync(configPath)) { + if (!fs.existsSync(jsConfigPath)) { try { - fs.writeFileSync(configPath, configTemplate, 'utf8'); + fs.writeFileSync(jsConfigPath, configTemplate, 'utf8'); } catch (error) { logError(`Failed to create rspack.config.js file: ${error.message}`); throw error; } } - return configPath; + return jsConfigPath; } diff --git a/packages/rspack/lib/config.js b/packages/rspack/lib/config.js index 24fc73a6d9..6d65da243c 100644 --- a/packages/rspack/lib/config.js +++ b/packages/rspack/lib/config.js @@ -4,6 +4,7 @@ */ import { glob } from 'glob'; import path from 'path'; +import fs from 'fs'; const { logInfo } = require('meteor/tools-core/lib/log'); const { @@ -21,13 +22,63 @@ const { isMeteorLessProject, isMeteorScssProject, getMeteorEnvPackageDirs, + getMeteorAppConfig, + getMeteorAppDir, } = require('meteor/tools-core/lib/meteor'); +const { buildUnignorePatterns } = require('meteor/tools-core/lib/ignore'); import { getInitialEntrypoints } from './build-context'; const { ensureModuleFilesExist, getBuildFilePath } = require('./build-context'); const { RSPACK_BUILD_CONTEXT, FILE_ROLE } = require('./constants'); +/** + * Checks if entries exist in .meteorignore file + * @param {string[]} entries - Entries to check + * @returns {Object} Results with entry keys and boolean values + */ +function checkMeteorIgnoreExactEntries(entries) { + const meteorIgnorePath = path.join(getMeteorAppDir(), '.meteorignore'); + const results = {}; + + // Initialize results object with false for each entry + entries.forEach(entry => { + results[entry] = false; + }); + + // Check if .meteorignore file exists + if (!fs.existsSync(meteorIgnorePath)) { + return results; + } + + // Read the .meteorignore file + try { + const content = fs.readFileSync(meteorIgnorePath, 'utf8'); + const lines = content.split('\n'); + + // Check each line against all entries + lines.forEach(line => { + // Skip empty lines and comments + if (!line.trim() || line.trim().startsWith('#')) { + return; + } + + const trimmedLine = line.trim(); + + // Check for exact matches + entries.forEach(entry => { + if (trimmedLine === entry) { + results[entry] = true; + } + }); + }); + } catch (error) { + // If there's an error reading the file, return the initialized results + } + + return results; +} + /** * Gets the list of file extensions to ignore based on project type * For Blaze projects, it excludes .html as used by Blaze @@ -92,6 +143,7 @@ function getFileExtensionsToIgnore() { * @returns {void} */ export function configureMeteorForRspack() { + const meteorAppConfig = getMeteorAppConfig(); const initialEntrypoints = getInitialEntrypoints(); // Ignore node_modules to prevent Meteor from processing them @@ -138,13 +190,59 @@ export function configureMeteorForRspack() { extraFoldersToIgnore = []; } - // Skip immediate html and css children from intial entrypoint contexts + // Skip CSS/HTML files in entrypoint contexts extraFilesToIgnore = [ ...extraFilesToIgnore, - ...initialEntrypointContexts.flatMap(entrypoint => [ - `!${entrypoint}/*.html`, - `!${entrypoint}/*.css`, - ]), + ...initialEntrypointContexts.flatMap(entrypoint => { + const cssPattern = `${entrypoint}/*.css`; + const htmlPattern = `${entrypoint}/*.html`; + + const cssFiles = glob.sync(cssPattern); + const htmlFiles = glob.sync(htmlPattern); + + const entriesToCheck = [ + cssPattern, + htmlPattern, + ...cssFiles, + ...htmlFiles + ]; + + const entryResults = checkMeteorIgnoreExactEntries(entriesToCheck); + const hasMatchingCssPattern = entryResults[cssPattern]; + const hasMatchingHtmlPattern = entryResults[htmlPattern]; + const hasAnyCssFileInMeteorIgnore = cssFiles.some(file => entryResults[file]); + const hasAnyHtmlFileInMeteorIgnore = htmlFiles.some(file => entryResults[file]); + + const result = []; + + // Handle HTML files + if (hasAnyHtmlFileInMeteorIgnore) { + // Add individual HTML files that are not in meteorignore + htmlFiles.forEach(file => { + if (!entryResults[file]) { + result.push(`!${file}`); + } + }); + } else if (!hasMatchingHtmlPattern) { + // Skip HTML pattern if not in meteorignore + result.push(`!${htmlPattern}`); + } + + // Handle CSS files + if (hasAnyCssFileInMeteorIgnore) { + // Add individual CSS files that are not in meteorignore + cssFiles.forEach(file => { + if (!entryResults[file]) { + result.push(`!${file}`); + } + }); + } else if (!hasMatchingCssPattern) { + // Skip CSS pattern if not in meteorignore + result.push(`!${cssPattern}`); + } + + return result; + }), ]; const testIgnorePath = `${RSPACK_BUILD_CONTEXT}/${path.dirname( @@ -176,13 +274,24 @@ export function configureMeteorForRspack() { ].filter(Boolean); const rootFilesToIgnore = [ ...projectRootFilesAndFolders.files.filter( - file => !['package.json', '.meteorignore', 'tsconfig.json'].includes(file), + file => + ![ + 'package.json', + '.meteorignore', + 'tsconfig.json', + 'postcss.config.js', + 'scss-config.json', + ].includes(file), ), ]; const filesToIgnore = [...rootFilesToIgnore, ...extraFilesToIgnore]; + const unignoredFilesAndFolders = buildUnignorePatterns( + meteorAppConfig?.modules || [], + { skipLevel: 1 }, + ); const meteorAppIgnores = `${foldersToIgnore.join(' ')} ${filesToIgnore.join( ' ', - )}`; + )} ${unignoredFilesAndFolders.join(' ')}`.trim(); setMeteorAppIgnore(meteorAppIgnores); if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) { diff --git a/packages/rspack/lib/constants.js b/packages/rspack/lib/constants.js index be6612be89..dbc252014b 100644 --- a/packages/rspack/lib/constants.js +++ b/packages/rspack/lib/constants.js @@ -3,9 +3,9 @@ * @description Constants and global state keys for Rspack plugin */ -export const DEFAULT_RSPACK_VERSION = '1.5.0'; +export const DEFAULT_RSPACK_VERSION = '1.5.3'; -export const DEFAULT_METEOR_RSPACK_VERSION = '0.0.36'; +export const DEFAULT_METEOR_RSPACK_VERSION = '0.0.60'; export const DEFAULT_METEOR_RSPACK_REACT_HMR_VERSION = '1.4.3'; @@ -36,38 +36,50 @@ export const GLOBAL_STATE_KEYS = { RSPACK_REACT_INSTALLATION_CHECKED: 'rspack.rspackReactInstallationChecked', RSPACK_DOCTOR_INSTALLATION_CHECKED: 'rspack.rspackDoctorInstallationChecked', REACT_CHECKED: 'rspack.reactChecked', + TYPESCRIPT_CHECKED: 'rspack.typescriptChecked', INITIAL_ENTRYPONTS: 'meteor.initialEntrypoints', CLIENT_FIRST_COMPILE: 'rspack.clientFirstCompile', SERVER_FIRST_COMPILE: 'rspack.serverFirstCompile', BUILD_CONTEXT_FILES_CLEANED: 'rspack.buildContextFilesCleaned', }; -/** - * Default port for Rspack dev server - * @type {string|any|number} - */ -export const RSPACK_DEVSERVER_PORT = process.env.RSPACK_DEVSERVER_PORT || 8080; +const meteorConfig = typeof Plugin !== 'undefined' ? Plugin?.getMeteorConfig() : null; /** * Directory name for Rspack build context * Can be overridden with RSPACK_BUILD_CONTEXT environment variable * @constant {string} */ -export const RSPACK_BUILD_CONTEXT = process.env.RSPACK_BUILD_CONTEXT || '_build'; +export const RSPACK_BUILD_CONTEXT = + meteorConfig?.buildContext || + process.env.RSPACK_BUILD_CONTEXT || + '_build'; + +process.env.RSPACK_BUILD_CONTEXT = RSPACK_BUILD_CONTEXT; /** * Directory name for Rspack assets context * Can be overridden with RSPACK_ASSETS_CONTEXT environment variable * @constant {string} */ -export const RSPACK_ASSETS_CONTEXT = process.env.RSPACK_ASSETS_CONTEXT || 'build-assets'; +export const RSPACK_ASSETS_CONTEXT = + meteorConfig?.assetsContext || + process.env.RSPACK_ASSETS_CONTEXT || + 'build-assets'; + +process.env.RSPACK_ASSETS_CONTEXT = RSPACK_ASSETS_CONTEXT; /** * Directory name for Rspack bundles context * Can be overridden with RSPACK_ASSETS_CONTEXT environment variable * @constant {string} */ -export const RSPACK_CHUNKS_CONTEXT = process.env.RSPACK_CHUNKS_CONTEXT || 'build-chunks'; +export const RSPACK_CHUNKS_CONTEXT = + meteorConfig?.chunksContext || + process.env.RSPACK_CHUNKS_CONTEXT || + 'build-chunks'; + +process.env.RSPACK_CHUNKS_CONTEXT = RSPACK_CHUNKS_CONTEXT; /** * Directory name for Rspack doctor context @@ -81,18 +93,6 @@ export const RSPACK_DOCTOR_CONTEXT = '.rsdoctor'; */ export const RSPACK_HOT_UPDATE_REGEX = /^\/(.+\.hot-update\.(?:json|js))$/; -/** - * Regex pattern for rspack bundles - * @constant {RegExp} - */ -export const RSPACK_BUNDLES_REGEX = new RegExp(`^\/${RSPACK_CHUNKS_CONTEXT}\/(.+)$`); - -/** - * Regex pattern for rspack assets - * @constant {RegExp} - */ -export const RSPACK_ASSETS_REGEX = new RegExp(`^\/${RSPACK_ASSETS_CONTEXT}\/(.+)$`); - export const FILE_ROLE = { build: 'build', entry: 'entry', diff --git a/packages/rspack/lib/dependencies.js b/packages/rspack/lib/dependencies.js index f458457a5c..814f002bf3 100644 --- a/packages/rspack/lib/dependencies.js +++ b/packages/rspack/lib/dependencies.js @@ -81,13 +81,25 @@ async function ensureDependenciesInstalled(dependencies, globalStateKey, package logInfo(` • ${dep}`); }); + // Check if this is a Yarn project + const isYarnProj = process.env.YARN_ENABLED === 'true'; + // Install dev dependencies const devDepsToInstall = allDepsToInstall.filter(dep => dep.dev === true || dep.dev == null); if (devDepsToInstall.length > 0) { const devDepsStrings = devDepsToInstall.map(dep => `${dep.name}@${dep.version}`); + + // Log progress for dev dependencies + logProgress( + `🔧 Installing ${devDepsToInstall.length} dev dependenc${ + devDepsToInstall.length === 1 ? "y" : "ies" + }...` + ); + success = await installNpmDependency(devDepsStrings, { cwd: appDir, dev: true, + yarn: isYarnProj, }); } @@ -95,22 +107,37 @@ async function ensureDependenciesInstalled(dependencies, globalStateKey, package const depsToInstall = allDepsToInstall.filter(dep => dep.dev === false); if (depsToInstall.length > 0) { const depsStrings = depsToInstall.map(dep => `${dep.name}@${dep.version}`); - const depsSuccess = await installNpmDependency(depsStrings, { + + // Log progress for regular dependencies + logProgress( + `🔧 Installing ${depsToInstall.length} dependenc${ + devDepsToInstall.length === 1 ? "y" : "ies" + }...` + ); + + let depsSuccess; + depsSuccess = await installNpmDependency(depsStrings, { cwd: appDir, dev: false, + yarn: isYarnProj, }); success = success && depsSuccess; } if (!success) { + const isYarnProj = process.env.YARN_ENABLED === 'true'; + const installCommand = isYarnProj + ? `yarn add --dev ${dependencyStrings.join(' ').trim()}` + : `meteor npm install -D ${dependencyStrings.join(' ').trim()}`; + logError(`\n┌─────────────────────────────────────────────────`); logError(`│ ❌ ${packageName} Installation Failed`); logError(`└─────────────────────────────────────────────────`); - logError(`Run: meteor npm install -D ${joinWithAnd(dependencyStrings)}`); + logError(`Run: ${installCommand}`); throw new Error( - `Failed to install ${packageName} dependencies. Please install them manually with: meteor npm install -D ${joinWithAnd(dependencyStrings)}` + `Failed to install ${packageName} dependencies. Please install them manually with: ${installCommand}` ); } @@ -199,3 +226,31 @@ export async function ensureRspackDoctorInstalled() { 'Rspack Doctor' ); } + +/** + * Checks if TypeScript is installed and sets global state accordingly + * Sets global state and environment variables based on TypeScript detection + * @returns {boolean} Whether TypeScript is installed + */ +export function checkTypescriptInstalled() { + // Skip if already checked + if (getGlobalState(GLOBAL_STATE_KEYS.TYPESCRIPT_CHECKED, false)) { + return; + } + + const appDir = getMeteorAppDir(); + // Check if TypeScript is a dependency in the project + const isTypescriptInstalled = checkNpmDependencyExists('typescript', { cwd: appDir }); + + if (isTypescriptInstalled) { + // Set environment variable to indicate TypeScript is enabled + process.env.METEOR_TYPESCRIPT_ENABLED = 'true'; + } else { + process.env.METEOR_TYPESCRIPT_ENABLED = 'false'; + } + + // Mark as checked + setGlobalState(GLOBAL_STATE_KEYS.TYPESCRIPT_CHECKED, true); + + return isTypescriptInstalled; +} diff --git a/packages/rspack/lib/processes.js b/packages/rspack/lib/processes.js index 8e3d3c3e25..ed35e57fb4 100644 --- a/packages/rspack/lib/processes.js +++ b/packages/rspack/lib/processes.js @@ -2,8 +2,9 @@ * @module processes * @description Functions for managing Rspack processes */ -import { checkNpmDependencyExists, getNpxCommand } from 'meteor/tools-core/lib/npm'; -import { RSPACK_DEVSERVER_PORT } from "./constants"; + +import fs from 'fs'; +import path from 'path'; const { spawnProcess, @@ -31,8 +32,15 @@ const { getMeteorInitialAppEntrypoints, isMeteorAppConfigModernVerbose, isMeteorBundleVisualizerProject, + getMeteorAppPort, } = require('meteor/tools-core/lib/meteor'); +const { + checkNpmDependencyExists, + getNpxCommand, + getMonorepoPath, +} = require('meteor/tools-core/lib/npm'); + const { getGlobalState, setGlobalState @@ -51,19 +59,114 @@ const { } = require('./build-context'); /** - * Gets the appropriate config file name based on environment - * @returns {string} The name of the Rspack config file + * Calculates the devServerPort based on process.env.PORT + * Base port is 8077, and we add the sum of the digits of process.env.PORT + * @returns {number} The calculated devServerPort */ -export function getConfigFileName() { - return `${process.cwd()}/node_modules/@meteorjs/rspack/rspack.config.js`; +export function calculateDevServerPort() { + const port = getMeteorAppPort(); + const basePort = 8077; + + // Sum the digits of the port + const digitSum = port.split('').reduce((sum, digit) => sum + parseInt(digit, 10), 0); + + return basePort + digitSum; } /** - * Gets the appropriate Rspack environment variables + * Calculates the Rsdoctor client port based on process.env.PORT + * Base port is 8885, and we add the sum of the digits of process.env.PORT + * @returns {number} The calculated Rsdoctor client port + */ +export function calculateRsdoctorClientPort() { + const port = getMeteorAppPort(); + const basePort = 8885; + + // Sum the digits of the port + const digitSum = port.split('').reduce((sum, digit) => sum + parseInt(digit, 10), 0); + + return basePort + digitSum; +} + +/** + * Calculates the Rsdoctor server port based on process.env.PORT + * Base port is 8885, and we add the sum of the digits of process.env.PORT + 1 + * @returns {number} The calculated Rsdoctor server port + */ +export function calculateRsdoctorServerPort() { + const port = getMeteorAppPort(); + const basePort = 8885; + + // Sum the digits of the port + const digitSum = port.split('').reduce((sum, digit) => sum + parseInt(digit, 10), 0); + + // Add 1 to differentiate from client port + return basePort + digitSum + 1; +} + +/** + * Helper function to check for a file with different extensions in order of priority + * @param {string} basePath - The base directory path (without 'rspack.config' and extension) + * @returns {string|null} The full path with extension if found, null otherwise + */ +export function getCustomConfigFilePath(basePath = getMeteorAppDir()) { + const configBasePath = path.join(basePath, 'rspack.config'); + + // Check for .js extension first (highest priority) + const jsPath = `${configBasePath}.js`; + if (fs.existsSync(jsPath)) { + return jsPath; + } + + // Check for .mjs extension next + const mjsPath = `${configBasePath}.mjs`; + if (fs.existsSync(mjsPath)) { + return mjsPath; + } + + // Check for .cjs extension last + const cjsPath = `${configBasePath}.cjs`; + if (fs.existsSync(cjsPath)) { + return cjsPath; + } + + // No valid config file found with any extension + return null; +} + +/** + * Gets the appropriate config file name based on environment + * @returns {string} The name of the Rspack config file + * @throws {Error} If no valid config file is found + */ +export function getConfigFilePath() { + // Check if the config file exists at the current path with any of the supported extensions + const defaultConfigBasePath = path.join(process.cwd(), 'node_modules/@meteorjs/rspack'); + const defaultConfigPath = getCustomConfigFilePath(defaultConfigBasePath); + if (defaultConfigPath) { + return defaultConfigPath; + } + + // If not found, check if we're in a monorepo and look for alternative config + const monorepoPath = getMonorepoPath(); + if (monorepoPath) { + const alternativeConfigBasePath = path.join(monorepoPath, 'node_modules/@meteorjs/rspack'); + const alternativeConfigPath = getCustomConfigFilePath(alternativeConfigBasePath); + if (alternativeConfigPath) { + return alternativeConfigPath; + } + } + + // If no config file is found, throw an error + throw new Error('Could not find rspack.config.js, rspack.config.mjs, or rspack.config.cjs. Make sure @meteorjs/rspack is installed correctly.'); +} + +/** + * Gets the appropriate Rspack environment variables and command line arguments * @param {Object} options - Options for environment variables * @param {boolean} options.isClient - Whether this is for client-side build * @param {boolean} options.isServer - Whether this is for server-side build - * @returns {string[]} Array of command line arguments for Rspack + * @returns {Object} Object containing params (command line arguments) and envs (environment variables) */ export function getRspackEnv({ isClient, isServer, isTest: inIsTest }) { const RSPACK_BUILD_CONTEXT = require('./constants').RSPACK_BUILD_CONTEXT; @@ -90,17 +193,23 @@ export function getRspackEnv({ isClient, isServer, isTest: inIsTest }) { const entryKey = `${isTest && isTestModule ? 'test' : 'main'}${isClient ? 'Client' : 'Server'}`; const inputFilePath = isTest && isTestModule ? initialEntrypoints.testModule : initialEntrypoints[entryKey]; - const isTypescriptEnabled = inputFilePath?.endsWith('.ts') || inputFilePath?.endsWith('.tsx'); - const isTsxEnabled = inputFilePath?.endsWith('.tsx'); - const isJsxEnabled = inputFilePath?.endsWith('.jsx'); + const isTypescriptEnabled = process.env.METEOR_TYPESCRIPT_ENABLED === 'true' || + inputFilePath?.endsWith('.ts') || + inputFilePath?.endsWith('.tsx'); + + const isReactEnabled = process.env.METEOR_REACT_ENABLED === 'true'; + const isTsxEnabled = isTypescriptEnabled && (inputFilePath?.endsWith('.tsx') || isReactEnabled); + const isJsxEnabled = !isTypescriptEnabled && (inputFilePath?.endsWith('.jsx') || isReactEnabled); - const isReactEnabled = !!process.env.METEOR_REACT_ENABLED; const isBlazeEnabled = isMeteorBlazeProject(); const isBlazeHotEnabled = isMeteorBlazeHotProject(); const isBundleVisualizerEnabled = isMeteorBundleVisualizerProject(); const swcExternalHelpers = checkNpmDependencyExists('@swc/helpers'); + const configPath = getConfigFilePath(); + const projectConfigPath = getCustomConfigFilePath(); + const pairs = [ ['isDevelopment', isMeteorAppDevelopment()], ['isProduction', isMeteorAppProduction()], @@ -127,11 +236,12 @@ export function getRspackEnv({ isClient, isServer, isTest: inIsTest }) { }), ], ['runPath', getBuildFilePath({ ...module, ...env, ...side, ...commandRole }) ], - ['bannerOutput', JSON.stringify(getBuildFileContent({ ...module, ...env, ...side, role: FILE_ROLE.output }))], ['buildContext', RSPACK_BUILD_CONTEXT], ['chunksContext', RSPACK_CHUNKS_CONTEXT], ['assetsContext', RSPACK_ASSETS_CONTEXT], - ['devServerPort', RSPACK_DEVSERVER_PORT], + ['devServerPort', process.env.RSPACK_DEVSERVER_PORT], + ['projectConfigPath', projectConfigPath], + ['configPath', configPath], ...(swcExternalHelpers && [['swcExternalHelpers', swcExternalHelpers]] || []), ...(isReactEnabled && [['isReactEnabled', isReactEnabled]] || []), ...(isBlazeEnabled && [['isBlazeEnabled', isBlazeEnabled]] || []), @@ -139,13 +249,26 @@ export function getRspackEnv({ isClient, isServer, isTest: inIsTest }) { ...(isTypescriptEnabled && [['isTypescriptEnabled', isTypescriptEnabled]] || []), ...(isTsxEnabled && [['isTsxEnabled', isTsxEnabled]] || []), ...(isJsxEnabled && [['isJsxEnabled', isJsxEnabled]] || []), - ...(isBundleVisualizerEnabled && [['isBundleVisualizerEnabled', isBundleVisualizerEnabled]] || []), + ...(isBundleVisualizerEnabled && [ + ['isBundleVisualizerEnabled', isBundleVisualizerEnabled], + ['rsdoctorClientPort', process.env.RSDOCTOR_CLIENT_PORT], + ['rsdoctorServerPort', process.env.RSDOCTOR_SERVER_PORT], + ] || []), ].filter(Boolean); - return pairs.flatMap(([key, val]) => [ + + // Create environment variables object with bannerOutput + const envs = { + RSPACK_BANNER: JSON.stringify(getBuildFileContent({ ...module, ...env, ...side, role: FILE_ROLE.output })) + }; + + // Create params from pairs + const params = pairs.flatMap(([key, val]) => [ '--env', `${key}=${val}` ]); + + return { params, envs }; } /** @@ -165,12 +288,14 @@ export function startRspackClientServe(options = {}) { } const appDir = getMeteorAppDir(); - const configFile = getConfigFileName(); - const { command, args } = getNpxCommand(['rspack', 'serve', '--config', configFile, ...getRspackEnv({ isClient: true, isServer: false })]); + const configFile = getConfigFilePath(); + const { params, envs } = getRspackEnv({ isClient: true, isServer: false }); + const { command, args } = getNpxCommand(['rspack', 'serve', '--config', configFile, ...params]); const newClientProcess = spawnProcess( command, args, { cwd: appDir, + env: { ...process.env, ...envs }, onStdout: (data) => { logInfo(`[Rspack Client] ${data}`); if (onCompile && data.trim().includes("compiled")) { @@ -187,11 +312,19 @@ export function startRspackClientServe(options = {}) { if (data.includes('Loopback:') || data.includes('Project is running at:')) { logInfo(`[Rspack Client] ${data}`); } else { + // Check if this is the "npm error could not determine executable to run" error + if (data.includes('npm error could not determine executable to run')) { + const errorMsg = '[Rspack Client Error] Try running "meteor npm install" to ensure rspack is available'; + logError(errorMsg); + throw new Error(errorMsg); + } logError(`[Rspack Client Error] ${data}`); } }, onError: (err) => { - logError(`Rspack Error: ${err.message}`); + const errorMsg = `Rspack Error: ${err.message}`; + logError(errorMsg); + throw new Error(errorMsg); } }); @@ -218,12 +351,14 @@ export function startRspackServerWatch(options = {}) { } const appDir = getMeteorAppDir(); - const configFile = getConfigFileName(); - const { command, args } = getNpxCommand(['rspack', 'build', '--watch', '--config', configFile, ...getRspackEnv({ isClient: false, isServer: true })]); + const configFile = getConfigFilePath(); + const { params, envs } = getRspackEnv({ isClient: false, isServer: true }); + const { command, args } = getNpxCommand(['rspack', 'build', '--watch', '--config', configFile, ...params]); const newServerProcess = spawnProcess( command, args, { cwd: appDir, + env: { ...process.env, ...envs }, onStdout: (data) => { logInfo(`[Rspack Server] ${data}`); if (onCompile && data.trim().includes("compiled")) { @@ -235,11 +370,19 @@ export function startRspackServerWatch(options = {}) { if (data.includes('Project is running at:')) { logInfo(`[Rspack Server] ${data}`); } else { + // Check if this is the "npm error could not determine executable to run" error + if (data.includes('npm error could not determine executable to run')) { + const errorMsg = '[Rspack Server Error] Try running "meteor npm install" to ensure rspack is available'; + logError(errorMsg); + throw new Error(errorMsg); + } logError(`[Rspack Server Error] ${data}`); } }, onError: (err) => { - logError(`Rspack Error: ${err.message}`); + const errorMsg = `Rspack Error: ${err.message}`; + logError(errorMsg); + throw new Error(errorMsg); } }); @@ -262,18 +405,19 @@ export function startRspackServerWatch(options = {}) { */ export async function runRspackBuild({ isClient, isServer, isTest, isTestModule, onCompile, watch, label = 'Build' } = {}) { const appDir = getMeteorAppDir(); - const configFile = getConfigFileName(); + const configFile = getConfigFilePath(); const endpoint = isTestModule ? 'Module' : isClient ? 'Client' : 'Server'; // Use a promise to ensure Meteor waits until Rspack finishes return new Promise((resolve, reject) => { + const { params, envs } = getRspackEnv({ isClient, isServer, isTest, isTestModule }); const rspackArgs = [ 'rspack', 'build', '--config', configFile, ...(watch && ['--watch']) || [], - ...getRspackEnv({ isClient, isServer, isTest, isTestModule }), + ...params, ].filter(Boolean); const { command, args } = getNpxCommand(rspackArgs); spawnProcess( @@ -281,6 +425,7 @@ export async function runRspackBuild({ isClient, isServer, isTest, isTestModule, args, { cwd: appDir, + env: { ...process.env, ...envs }, onStdout: (data) => { logInfo(`[Rspack ${label} ${endpoint}] ${data}`); if (onCompile && data.trim().includes("compiled")) { @@ -292,6 +437,12 @@ export async function runRspackBuild({ isClient, isServer, isTest, isTestModule, if (data.includes('Project is running at:')) { logInfo(`[Rspack ${label} ${endpoint}] ${data}`); } else { + // Check if this is the "npm error could not determine executable to run" error + if (data.includes('npm error could not determine executable to run')) { + const errorMsg = `[Rspack ${label} Error ${endpoint}] Try running "meteor npm install" to ensure rspack is available`; + logError(errorMsg); + throw new Error(errorMsg); + } logError(`[Rspack ${label} Error ${endpoint}] ${data}`); } }, diff --git a/packages/rspack/package.js b/packages/rspack/package.js index 0638a7c4d6..52765a5880 100644 --- a/packages/rspack/package.js +++ b/packages/rspack/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "Integrate rspack into the Meteor lifecycle to run the bundler independently", - version: '1.0.0-beta340.0', + version: '1.0.0-beta340.11', }); Package.registerBuildPlugin({ diff --git a/packages/rspack/rspack_plugin.js b/packages/rspack/rspack_plugin.js index 4d961ed24a..6962ce900b 100644 --- a/packages/rspack/rspack_plugin.js +++ b/packages/rspack/rspack_plugin.js @@ -22,8 +22,8 @@ const { const { ensureRspackInstalled, checkReactInstalled, + checkTypescriptInstalled, ensureRspackReactInstalled, - ensureRspackDoctorInstalled, } = require('./lib/dependencies'); const { @@ -37,6 +37,11 @@ const { startRspackServerWatch, runRspackBuild, cleanup, + calculateDevServerPort, + calculateRsdoctorClientPort, + calculateRsdoctorServerPort, + getConfigFilePath, + getCustomConfigFilePath, } = require('./lib/processes'); const { @@ -66,6 +71,7 @@ const { isMeteorAppDebug, isMeteorAppConfigModernVerbose, isMeteorAppNative, + isMeteorBundleVisualizerProject, } = require('meteor/tools-core/lib/meteor'); const { @@ -76,17 +82,29 @@ const { const { getNpxCommand, getNpmCommand, + getYarnCommand, + isYarnProject, } = require('meteor/tools-core/lib/npm'); +const { getMeteorAppConfig, hasMeteorAppConfigAutoInstallDeps } = require("../tools-core/lib/meteor"); if (isMeteorAppRun() || isMeteorAppBuild() || isMeteorAppTest()) { // Get entry points from Meteor configuration setGlobalState(GLOBAL_STATE_KEYS.INITIAL_ENTRYPONTS, getMeteorAppEntrypoints()); + let isYarnProj = process.env.YARN_ENABLED === 'true'; // Main entry point - using top-level await try { + // Check if the project is a Yarn project and store the result in environment variable if not already set + if (process.env.YARN_ENABLED === undefined) { + isYarnProj = isYarnProject(); + process.env.YARN_ENABLED = isYarnProj ? 'true' : 'false'; + } if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) { logInfo(`[i] Meteor Npx prefix: ${getNpxCommand([])?.prefix}`); logInfo(`[i] Meteor Npm prefix: ${getNpmCommand([])?.prefix}`); + if (isYarnProj) { + logInfo(`[i] Meteor Yarn prefix: ${getYarnCommand([])?.prefix}`); + } } // Clean build context files only if they haven't been cleaned yet @@ -95,14 +113,23 @@ if (isMeteorAppRun() || isMeteorAppBuild() || isMeteorAppTest()) { setGlobalState(GLOBAL_STATE_KEYS.BUILD_CONTEXT_FILES_CLEANED, true); } - // Ensure Rspack is installed - await ensureRspackInstalled(); + // Auto install deps (by default enabled) + if (hasMeteorAppConfigAutoInstallDeps()) { + // Ensure Rspack is installed + await ensureRspackInstalled(); + } // Check if Rspack React is installed if (checkReactInstalled()) { - await ensureRspackReactInstalled(); + // Auto install deps (by default enabled) + if (hasMeteorAppConfigAutoInstallDeps()) { + await ensureRspackReactInstalled(); + } } + // Check if TypeScript is installed + checkTypescriptInstalled(); + // Ensure the Rspack build context directory exists ensureRspackBuildContextExists(); @@ -112,6 +139,38 @@ if (isMeteorAppRun() || isMeteorAppBuild() || isMeteorAppTest()) { // Configure Meteor settings for Rspack configureMeteorForRspack(); + // Calculate and set the devServerPort at boot + if (!process.env.RSPACK_DEVSERVER_PORT) { + process.env.RSPACK_DEVSERVER_PORT = calculateDevServerPort(); + if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) { + logInfo(`[i] Rspack DevServer Port: ${process.env.RSPACK_DEVSERVER_PORT}`); + } + } + + if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) { + const configFile = getConfigFilePath(); + logInfo(`[i] Rspack default config: ${configFile}`); + const projectConfigFile = getCustomConfigFilePath(); + logInfo(`[i] Rspack custom config: ${projectConfigFile}`); + } + + // Calculate and set the Rsdoctor client and server ports at boot only if bundle visualizer is enabled + if (isMeteorBundleVisualizerProject()) { + if (!process.env.RSDOCTOR_CLIENT_PORT) { + process.env.RSDOCTOR_CLIENT_PORT = calculateRsdoctorClientPort(); + if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) { + logInfo(`[i] Rsdoctor Client Port: ${process.env.RSDOCTOR_CLIENT_PORT}`); + } + } + + if (!process.env.RSDOCTOR_SERVER_PORT) { + process.env.RSDOCTOR_SERVER_PORT = calculateRsdoctorServerPort(); + if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) { + logInfo(`[i] Rsdoctor Server Port: ${process.env.RSDOCTOR_SERVER_PORT}`); + } + } + } + // Register cleanup handler process.on('exit', cleanup); process.on('SIGINT', () => { diff --git a/packages/rspack/rspack_server.js b/packages/rspack/rspack_server.js index 1dc9829777..477ecfbcc3 100644 --- a/packages/rspack/rspack_server.js +++ b/packages/rspack/rspack_server.js @@ -1,19 +1,38 @@ import { Meteor } from 'meteor/meteor'; -import { WebApp } from 'meteor/webapp'; +import { WebApp, WebAppInternals } from 'meteor/webapp'; import { shuffleString } from 'meteor/tools-core/lib/string'; import { createProxyMiddleware } from 'http-proxy-middleware'; +import path from 'path'; +import { parse as parseUrl } from 'url'; import { RSPACK_CHUNKS_CONTEXT, RSPACK_ASSETS_CONTEXT, RSPACK_HOT_UPDATE_REGEX, - RSPACK_BUNDLES_REGEX, - RSPACK_ASSETS_REGEX, - RSPACK_DEVSERVER_PORT, } from "./lib/constants"; +// Define constants for both development and production +const rspackChunksContext = process.env.RSPACK_CHUNKS_CONTEXT || RSPACK_CHUNKS_CONTEXT; +const rspackAssetsContext = process.env.RSPACK_ASSETS_CONTEXT || RSPACK_ASSETS_CONTEXT; + +/** + * Regex pattern for rspack bundles + * @constant {RegExp} + */ +const RSPACK_CHUNKS_REGEX = new RegExp( + `^\/${rspackChunksContext}\/(.+)$`, +); + +/** + * Regex pattern for rspack assets + * @constant {RegExp} + */ +const RSPACK_ASSETS_REGEX = new RegExp( + `^\/${rspackAssetsContext}\/(.+)$`, +); + if (Meteor.isDevelopment) { // Target URL for the Rspack dev server - const target = `http://localhost:${RSPACK_DEVSERVER_PORT}`; + const target = `http://localhost:${process.env.RSPACK_DEVSERVER_PORT}`; // Proxy HMR websocket upgrade requests WebApp.connectHandlers.use('/ws', @@ -53,10 +72,10 @@ if (Meteor.isDevelopment) { } // 2) match "/build-chunks/" - const bundlesMatch = req.url.match(RSPACK_BUNDLES_REGEX); + const bundlesMatch = req.url.match(RSPACK_CHUNKS_REGEX); if (bundlesMatch) { // Redirect "/bundles/foo.js" → "/__rspack__/build-chunks/foo.js" - const target = `/__rspack__/${RSPACK_CHUNKS_CONTEXT}/${bundlesMatch[1]}`; + const target = `/__rspack__/${rspackChunksContext}/${bundlesMatch[1]}`; res.writeHead(307, { Location: target }); return res.end(); } @@ -65,7 +84,7 @@ if (Meteor.isDevelopment) { const assetsMatch = req.url.match(RSPACK_ASSETS_REGEX); if (assetsMatch) { // Redirect "/build-assets/foo.js" → "/__rspack__/build-assets/foo.js" - const target = `/__rspack__/${RSPACK_ASSETS_CONTEXT}/${assetsMatch[1]}`; + const target = `/__rspack__/${rspackAssetsContext}/${assetsMatch[1]}`; res.writeHead(307, { Location: target }); return res.end(); } @@ -105,3 +124,80 @@ if (Meteor.isDevelopment) { // Enable client reload on server startup enableClientReloadOnServerStart(); } + +/** + * Register a single rspack static asset with WebAppInternals.staticFilesByArch + * @param {string} arch - The architecture to register the asset for + * @param {string} pathname - The pathname of the asset + * @param {string} filePath - The absolute path to the asset on disk + * @returns {Object} The static file info object + */ +function registerRspackStaticAsset(arch, pathname, filePath) { + // Ensure the architecture exists in staticFilesByArch + if (!WebAppInternals.staticFilesByArch[arch]) { + WebAppInternals.staticFilesByArch[arch] = Object.create(null); + } + + // Get the static files object for this architecture + const staticFiles = WebAppInternals.staticFilesByArch[arch]; + + // Skip if already registered + if (staticFiles[pathname]) { + // Ensure the entry is marked as cacheable + staticFiles[pathname].cacheable = true; + return staticFiles[pathname]; + } + + // Determine file type based on extension + const type = pathname.endsWith(".js") ? "js" : + pathname.endsWith(".css") ? "css" : + pathname.endsWith(".json") ? "json" : undefined; + + // Extract hash from filename (assuming it's the second part after splitting by '.') + const filename = pathname.split("/").pop(); + const hash = filename.split(".")[1]; + + // Register the asset + staticFiles[pathname] = { + absolutePath: filePath, + cacheable: true, // Most rspack assets are cacheable + hash, + type + }; + + return staticFiles[pathname]; +} + +// Store the original staticFilesMiddleware +const originalStaticFilesMiddleware = WebAppInternals.staticFilesMiddleware; + +// Handle rspack assets on-demand to add Meteor's static files headers +WebAppInternals.staticFilesMiddleware = async function(staticFilesByArch, req, res, next) { + const pathname = parseUrl(req.url).pathname; + + try { + // Check if this is a rspack asset request + const chunksMatch = pathname.match(RSPACK_CHUNKS_REGEX); + const assetsMatch = pathname.match(RSPACK_ASSETS_REGEX); + + if (chunksMatch || assetsMatch) { + const cwd = process.cwd(); + const architectures = ["web.browser", "web.browser.legacy", "web.cordova"]; + WebApp.categorizeRequest(req); + + // Try to find the file on disk + const context = chunksMatch ? rspackChunksContext : rspackAssetsContext; + const filename = (chunksMatch ? chunksMatch[1] : assetsMatch[1]); + const filePath = path.join(cwd, context, filename); + + architectures.forEach(archName => { + registerRspackStaticAsset(archName, pathname, filePath); + }); + } + } catch (e) { + console.error(`Error handling rspack asset: ${e.message}`); + } + + // Call the original middleware + return originalStaticFilesMiddleware(staticFilesByArch, req, res, next); +}; diff --git a/packages/shell-server/package.js b/packages/shell-server/package.js index 1dd7551714..69987dd4d9 100644 --- a/packages/shell-server/package.js +++ b/packages/shell-server/package.js @@ -1,6 +1,6 @@ Package.describe({ name: "shell-server", - version: '0.6.2', + version: '0.7.0-beta340.11', summary: "Server-side component of the `meteor shell` command.", documentation: "README.md" }); diff --git a/packages/standard-minifier-js/package.js b/packages/standard-minifier-js/package.js index 6f23b6e939..384cad5770 100644 --- a/packages/standard-minifier-js/package.js +++ b/packages/standard-minifier-js/package.js @@ -12,7 +12,7 @@ Package.registerBuildPlugin({ 'ecmascript' ], npmDependencies: { - '@meteorjs/swc-core': '1.12.14', + '@meteorjs/swc-core': '1.13.5', 'acorn': '8.10.0', "@babel/runtime": "7.18.9", '@babel/parser': '7.22.7', diff --git a/packages/test-in-browser/diff_match_patch_uncompressed.js b/packages/test-in-browser/diff_match_patch_uncompressed.js deleted file mode 100644 index 4d5542c1d3..0000000000 --- a/packages/test-in-browser/diff_match_patch_uncompressed.js +++ /dev/null @@ -1,2218 +0,0 @@ -/** - * Diff Match and Patch - * Copyright 2018 The diff-match-patch Authors. - * https://github.com/google/diff-match-patch - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @fileoverview Computes the difference between two texts to create a patch. - * Applies the patch onto another text, allowing for errors. - * @author fraser@google.com (Neil Fraser) - */ - -/** - * Class containing the diff, match and patch methods. - * @constructor - */ -var diff_match_patch = function() { - - // Defaults. - // Redefine these in your program to override the defaults. - - // Number of seconds to map a diff before giving up (0 for infinity). - this.Diff_Timeout = 1.0; - // Cost of an empty edit operation in terms of edit characters. - this.Diff_EditCost = 4; - // At what point is no match declared (0.0 = perfection, 1.0 = very loose). - this.Match_Threshold = 0.5; - // How far to search for a match (0 = exact location, 1000+ = broad match). - // A match this many characters away from the expected location will add - // 1.0 to the score (0.0 is a perfect match). - this.Match_Distance = 1000; - // When deleting a large block of text (over ~64 characters), how close do - // the contents have to be to match the expected contents. (0.0 = perfection, - // 1.0 = very loose). Note that Match_Threshold controls how closely the - // end points of a delete need to match. - this.Patch_DeleteThreshold = 0.5; - // Chunk size for context length. - this.Patch_Margin = 4; - - // The number of bits in an int. - this.Match_MaxBits = 32; -}; - - -// DIFF FUNCTIONS - - -/** - * The data structure representing a diff is an array of tuples: - * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']] - * which means: delete 'Hello', add 'Goodbye' and keep ' world.' - */ -var DIFF_DELETE = -1; -var DIFF_INSERT = 1; -var DIFF_EQUAL = 0; - -/** - * Class representing one diff tuple. - * ~Attempts to look like a two-element array (which is what this used to be).~ - * Constructor returns an actual two-element array, to allow destructing @JackuB - * See https://github.com/JackuB/diff-match-patch/issues/14 for details - * @param {number} op Operation, one of: DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL. - * @param {string} text Text to be deleted, inserted, or retained. - * @constructor - */ -diff_match_patch.Diff = function(op, text) { - return [op, text]; -}; - -/** - * Find the differences between two texts. Simplifies the problem by stripping - * any common prefix or suffix off the texts before diffing. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {boolean=} opt_checklines Optional speedup flag. If present and false, - * then don't run a line-level diff first to identify the changed areas. - * Defaults to true, which does a faster, slightly less optimal diff. - * @param {number=} opt_deadline Optional time when the diff should be complete - * by. Used internally for recursive calls. Users should set DiffTimeout - * instead. - * @return {!Array.} Array of diff tuples. - */ -diff_match_patch.prototype.diff_main = function(text1, text2, opt_checklines, - opt_deadline) { - // Set a deadline by which time the diff must be complete. - if (typeof opt_deadline == 'undefined') { - if (this.Diff_Timeout <= 0) { - opt_deadline = Number.MAX_VALUE; - } else { - opt_deadline = (new Date).getTime() + this.Diff_Timeout * 1000; - } - } - var deadline = opt_deadline; - - // Check for null inputs. - if (text1 == null || text2 == null) { - throw new Error('Null input. (diff_main)'); - } - - // Check for equality (speedup). - if (text1 == text2) { - if (text1) { - return [new diff_match_patch.Diff(DIFF_EQUAL, text1)]; - } - return []; - } - - if (typeof opt_checklines == 'undefined') { - opt_checklines = true; - } - var checklines = opt_checklines; - - // Trim off common prefix (speedup). - var commonlength = this.diff_commonPrefix(text1, text2); - var commonprefix = text1.substring(0, commonlength); - text1 = text1.substring(commonlength); - text2 = text2.substring(commonlength); - - // Trim off common suffix (speedup). - commonlength = this.diff_commonSuffix(text1, text2); - var commonsuffix = text1.substring(text1.length - commonlength); - text1 = text1.substring(0, text1.length - commonlength); - text2 = text2.substring(0, text2.length - commonlength); - - // Compute the diff on the middle block. - var diffs = this.diff_compute_(text1, text2, checklines, deadline); - - // Restore the prefix and suffix. - if (commonprefix) { - diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, commonprefix)); - } - if (commonsuffix) { - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, commonsuffix)); - } - this.diff_cleanupMerge(diffs); - return diffs; -}; - - -/** - * Find the differences between two texts. Assumes that the texts do not - * have any common prefix or suffix. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {boolean} checklines Speedup flag. If false, then don't run a - * line-level diff first to identify the changed areas. - * If true, then run a faster, slightly less optimal diff. - * @param {number} deadline Time when the diff should be complete by. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_compute_ = function(text1, text2, checklines, - deadline) { - var diffs; - - if (!text1) { - // Just add some text (speedup). - return [new diff_match_patch.Diff(DIFF_INSERT, text2)]; - } - - if (!text2) { - // Just delete some text (speedup). - return [new diff_match_patch.Diff(DIFF_DELETE, text1)]; - } - - var longtext = text1.length > text2.length ? text1 : text2; - var shorttext = text1.length > text2.length ? text2 : text1; - var i = longtext.indexOf(shorttext); - if (i != -1) { - // Shorter text is inside the longer text (speedup). - diffs = [new diff_match_patch.Diff(DIFF_INSERT, longtext.substring(0, i)), - new diff_match_patch.Diff(DIFF_EQUAL, shorttext), - new diff_match_patch.Diff(DIFF_INSERT, - longtext.substring(i + shorttext.length))]; - // Swap insertions for deletions if diff is reversed. - if (text1.length > text2.length) { - diffs[0][0] = diffs[2][0] = DIFF_DELETE; - } - return diffs; - } - - if (shorttext.length == 1) { - // Single character string. - // After the previous speedup, the character can't be an equality. - return [new diff_match_patch.Diff(DIFF_DELETE, text1), - new diff_match_patch.Diff(DIFF_INSERT, text2)]; - } - - // Check to see if the problem can be split in two. - var hm = this.diff_halfMatch_(text1, text2); - if (hm) { - // A half-match was found, sort out the return data. - var text1_a = hm[0]; - var text1_b = hm[1]; - var text2_a = hm[2]; - var text2_b = hm[3]; - var mid_common = hm[4]; - // Send both pairs off for separate processing. - var diffs_a = this.diff_main(text1_a, text2_a, checklines, deadline); - var diffs_b = this.diff_main(text1_b, text2_b, checklines, deadline); - // Merge the results. - return diffs_a.concat([new diff_match_patch.Diff(DIFF_EQUAL, mid_common)], - diffs_b); - } - - if (checklines && text1.length > 100 && text2.length > 100) { - return this.diff_lineMode_(text1, text2, deadline); - } - - return this.diff_bisect_(text1, text2, deadline); -}; - - -/** - * Do a quick line-level diff on both strings, then rediff the parts for - * greater accuracy. - * This speedup can produce non-minimal diffs. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} deadline Time when the diff should be complete by. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_lineMode_ = function(text1, text2, deadline) { - // Scan the text on a line-by-line basis first. - var a = this.diff_linesToChars_(text1, text2); - text1 = a.chars1; - text2 = a.chars2; - var linearray = a.lineArray; - - var diffs = this.diff_main(text1, text2, false, deadline); - - // Convert the diff back to original text. - this.diff_charsToLines_(diffs, linearray); - // Eliminate freak matches (e.g. blank lines) - this.diff_cleanupSemantic(diffs); - - // Rediff any replacement blocks, this time character-by-character. - // Add a dummy entry at the end. - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); - var pointer = 0; - var count_delete = 0; - var count_insert = 0; - var text_delete = ''; - var text_insert = ''; - while (pointer < diffs.length) { - switch (diffs[pointer][0]) { - case DIFF_INSERT: - count_insert++; - text_insert += diffs[pointer][1]; - break; - case DIFF_DELETE: - count_delete++; - text_delete += diffs[pointer][1]; - break; - case DIFF_EQUAL: - // Upon reaching an equality, check for prior redundancies. - if (count_delete >= 1 && count_insert >= 1) { - // Delete the offending records and add the merged ones. - diffs.splice(pointer - count_delete - count_insert, - count_delete + count_insert); - pointer = pointer - count_delete - count_insert; - var subDiff = - this.diff_main(text_delete, text_insert, false, deadline); - for (var j = subDiff.length - 1; j >= 0; j--) { - diffs.splice(pointer, 0, subDiff[j]); - } - pointer = pointer + subDiff.length; - } - count_insert = 0; - count_delete = 0; - text_delete = ''; - text_insert = ''; - break; - } - pointer++; - } - diffs.pop(); // Remove the dummy entry at the end. - - return diffs; -}; - - -/** - * Find the 'middle snake' of a diff, split the problem in two - * and return the recursively constructed diff. - * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} deadline Time at which to bail if not yet complete. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_bisect_ = function(text1, text2, deadline) { - // Cache the text lengths to prevent multiple calls. - var text1_length = text1.length; - var text2_length = text2.length; - var max_d = Math.ceil((text1_length + text2_length) / 2); - var v_offset = max_d; - var v_length = 2 * max_d; - var v1 = new Array(v_length); - var v2 = new Array(v_length); - // Setting all elements to -1 is faster in Chrome & Firefox than mixing - // integers and undefined. - for (var x = 0; x < v_length; x++) { - v1[x] = -1; - v2[x] = -1; - } - v1[v_offset + 1] = 0; - v2[v_offset + 1] = 0; - var delta = text1_length - text2_length; - // If the total number of characters is odd, then the front path will collide - // with the reverse path. - var front = (delta % 2 != 0); - // Offsets for start and end of k loop. - // Prevents mapping of space beyond the grid. - var k1start = 0; - var k1end = 0; - var k2start = 0; - var k2end = 0; - for (var d = 0; d < max_d; d++) { - // Bail out if deadline is reached. - if ((new Date()).getTime() > deadline) { - break; - } - - // Walk the front path one step. - for (var k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { - var k1_offset = v_offset + k1; - var x1; - if (k1 == -d || (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1])) { - x1 = v1[k1_offset + 1]; - } else { - x1 = v1[k1_offset - 1] + 1; - } - var y1 = x1 - k1; - while (x1 < text1_length && y1 < text2_length && - text1.charAt(x1) == text2.charAt(y1)) { - x1++; - y1++; - } - v1[k1_offset] = x1; - if (x1 > text1_length) { - // Ran off the right of the graph. - k1end += 2; - } else if (y1 > text2_length) { - // Ran off the bottom of the graph. - k1start += 2; - } else if (front) { - var k2_offset = v_offset + delta - k1; - if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) { - // Mirror x2 onto top-left coordinate system. - var x2 = text1_length - v2[k2_offset]; - if (x1 >= x2) { - // Overlap detected. - return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); - } - } - } - } - - // Walk the reverse path one step. - for (var k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { - var k2_offset = v_offset + k2; - var x2; - if (k2 == -d || (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1])) { - x2 = v2[k2_offset + 1]; - } else { - x2 = v2[k2_offset - 1] + 1; - } - var y2 = x2 - k2; - while (x2 < text1_length && y2 < text2_length && - text1.charAt(text1_length - x2 - 1) == - text2.charAt(text2_length - y2 - 1)) { - x2++; - y2++; - } - v2[k2_offset] = x2; - if (x2 > text1_length) { - // Ran off the left of the graph. - k2end += 2; - } else if (y2 > text2_length) { - // Ran off the top of the graph. - k2start += 2; - } else if (!front) { - var k1_offset = v_offset + delta - k2; - if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) { - var x1 = v1[k1_offset]; - var y1 = v_offset + x1 - k1_offset; - // Mirror x2 onto top-left coordinate system. - x2 = text1_length - x2; - if (x1 >= x2) { - // Overlap detected. - return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); - } - } - } - } - } - // Diff took too long and hit the deadline or - // number of diffs equals number of characters, no commonality at all. - return [new diff_match_patch.Diff(DIFF_DELETE, text1), - new diff_match_patch.Diff(DIFF_INSERT, text2)]; -}; - - -/** - * Given the location of the 'middle snake', split the diff in two parts - * and recurse. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} x Index of split point in text1. - * @param {number} y Index of split point in text2. - * @param {number} deadline Time at which to bail if not yet complete. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_bisectSplit_ = function(text1, text2, x, y, - deadline) { - var text1a = text1.substring(0, x); - var text2a = text2.substring(0, y); - var text1b = text1.substring(x); - var text2b = text2.substring(y); - - // Compute both diffs serially. - var diffs = this.diff_main(text1a, text2a, false, deadline); - var diffsb = this.diff_main(text1b, text2b, false, deadline); - - return diffs.concat(diffsb); -}; - - -/** - * Split two texts into an array of strings. Reduce the texts to a string of - * hashes where each Unicode character represents one line. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {{chars1: string, chars2: string, lineArray: !Array.}} - * An object containing the encoded text1, the encoded text2 and - * the array of unique strings. - * The zeroth element of the array of unique strings is intentionally blank. - * @private - */ -diff_match_patch.prototype.diff_linesToChars_ = function(text1, text2) { - var lineArray = []; // e.g. lineArray[4] == 'Hello\n' - var lineHash = {}; // e.g. lineHash['Hello\n'] == 4 - - // '\x00' is a valid character, but various debuggers don't like it. - // So we'll insert a junk entry to avoid generating a null character. - lineArray[0] = ''; - - /** - * Split a text into an array of strings. Reduce the texts to a string of - * hashes where each Unicode character represents one line. - * Modifies linearray and linehash through being a closure. - * @param {string} text String to encode. - * @return {string} Encoded string. - * @private - */ - function diff_linesToCharsMunge_(text) { - var chars = ''; - // Walk the text, pulling out a substring for each line. - // text.split('\n') would would temporarily double our memory footprint. - // Modifying text would create many large strings to garbage collect. - var lineStart = 0; - var lineEnd = -1; - // Keeping our own length variable is faster than looking it up. - var lineArrayLength = lineArray.length; - while (lineEnd < text.length - 1) { - lineEnd = text.indexOf('\n', lineStart); - if (lineEnd == -1) { - lineEnd = text.length - 1; - } - var line = text.substring(lineStart, lineEnd + 1); - - if (lineHash.hasOwnProperty ? lineHash.hasOwnProperty(line) : - (lineHash[line] !== undefined)) { - chars += String.fromCharCode(lineHash[line]); - } else { - if (lineArrayLength == maxLines) { - // Bail out at 65535 because - // String.fromCharCode(65536) == String.fromCharCode(0) - line = text.substring(lineStart); - lineEnd = text.length; - } - chars += String.fromCharCode(lineArrayLength); - lineHash[line] = lineArrayLength; - lineArray[lineArrayLength++] = line; - } - lineStart = lineEnd + 1; - } - return chars; - } - // Allocate 2/3rds of the space for text1, the rest for text2. - var maxLines = 40000; - var chars1 = diff_linesToCharsMunge_(text1); - maxLines = 65535; - var chars2 = diff_linesToCharsMunge_(text2); - return {chars1: chars1, chars2: chars2, lineArray: lineArray}; -}; - - -/** - * Rehydrate the text in a diff from a string of line hashes to real lines of - * text. - * @param {!Array.} diffs Array of diff tuples. - * @param {!Array.} lineArray Array of unique strings. - * @private - */ -diff_match_patch.prototype.diff_charsToLines_ = function(diffs, lineArray) { - for (var i = 0; i < diffs.length; i++) { - var chars = diffs[i][1]; - var text = []; - for (var j = 0; j < chars.length; j++) { - text[j] = lineArray[chars.charCodeAt(j)]; - } - diffs[i][1] = text.join(''); - } -}; - - -/** - * Determine the common prefix of two strings. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {number} The number of characters common to the start of each - * string. - */ -diff_match_patch.prototype.diff_commonPrefix = function(text1, text2) { - // Quick check for common null cases. - if (!text1 || !text2 || text1.charAt(0) != text2.charAt(0)) { - return 0; - } - // Binary search. - // Performance analysis: https://neil.fraser.name/news/2007/10/09/ - var pointermin = 0; - var pointermax = Math.min(text1.length, text2.length); - var pointermid = pointermax; - var pointerstart = 0; - while (pointermin < pointermid) { - if (text1.substring(pointerstart, pointermid) == - text2.substring(pointerstart, pointermid)) { - pointermin = pointermid; - pointerstart = pointermin; - } else { - pointermax = pointermid; - } - pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); - } - return pointermid; -}; - - -/** - * Determine the common suffix of two strings. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {number} The number of characters common to the end of each string. - */ -diff_match_patch.prototype.diff_commonSuffix = function(text1, text2) { - // Quick check for common null cases. - if (!text1 || !text2 || - text1.charAt(text1.length - 1) != text2.charAt(text2.length - 1)) { - return 0; - } - // Binary search. - // Performance analysis: https://neil.fraser.name/news/2007/10/09/ - var pointermin = 0; - var pointermax = Math.min(text1.length, text2.length); - var pointermid = pointermax; - var pointerend = 0; - while (pointermin < pointermid) { - if (text1.substring(text1.length - pointermid, text1.length - pointerend) == - text2.substring(text2.length - pointermid, text2.length - pointerend)) { - pointermin = pointermid; - pointerend = pointermin; - } else { - pointermax = pointermid; - } - pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); - } - return pointermid; -}; - - -/** - * Determine if the suffix of one string is the prefix of another. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {number} The number of characters common to the end of the first - * string and the start of the second string. - * @private - */ -diff_match_patch.prototype.diff_commonOverlap_ = function(text1, text2) { - // Cache the text lengths to prevent multiple calls. - var text1_length = text1.length; - var text2_length = text2.length; - // Eliminate the null case. - if (text1_length == 0 || text2_length == 0) { - return 0; - } - // Truncate the longer string. - if (text1_length > text2_length) { - text1 = text1.substring(text1_length - text2_length); - } else if (text1_length < text2_length) { - text2 = text2.substring(0, text1_length); - } - var text_length = Math.min(text1_length, text2_length); - // Quick check for the worst case. - if (text1 == text2) { - return text_length; - } - - // Start by looking for a single character match - // and increase length until no match is found. - // Performance analysis: https://neil.fraser.name/news/2010/11/04/ - var best = 0; - var length = 1; - while (true) { - var pattern = text1.substring(text_length - length); - var found = text2.indexOf(pattern); - if (found == -1) { - return best; - } - length += found; - if (found == 0 || text1.substring(text_length - length) == - text2.substring(0, length)) { - best = length; - length++; - } - } -}; - - -/** - * Do the two texts share a substring which is at least half the length of the - * longer text? - * This speedup can produce non-minimal diffs. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {Array.} Five element Array, containing the prefix of - * text1, the suffix of text1, the prefix of text2, the suffix of - * text2 and the common middle. Or null if there was no match. - * @private - */ -diff_match_patch.prototype.diff_halfMatch_ = function(text1, text2) { - if (this.Diff_Timeout <= 0) { - // Don't risk returning a non-optimal diff if we have unlimited time. - return null; - } - var longtext = text1.length > text2.length ? text1 : text2; - var shorttext = text1.length > text2.length ? text2 : text1; - if (longtext.length < 4 || shorttext.length * 2 < longtext.length) { - return null; // Pointless. - } - var dmp = this; // 'this' becomes 'window' in a closure. - - /** - * Does a substring of shorttext exist within longtext such that the substring - * is at least half the length of longtext? - * Closure, but does not reference any external variables. - * @param {string} longtext Longer string. - * @param {string} shorttext Shorter string. - * @param {number} i Start index of quarter length substring within longtext. - * @return {Array.} Five element Array, containing the prefix of - * longtext, the suffix of longtext, the prefix of shorttext, the suffix - * of shorttext and the common middle. Or null if there was no match. - * @private - */ - function diff_halfMatchI_(longtext, shorttext, i) { - // Start with a 1/4 length substring at position i as a seed. - var seed = longtext.substring(i, i + Math.floor(longtext.length / 4)); - var j = -1; - var best_common = ''; - var best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b; - while ((j = shorttext.indexOf(seed, j + 1)) != -1) { - var prefixLength = dmp.diff_commonPrefix(longtext.substring(i), - shorttext.substring(j)); - var suffixLength = dmp.diff_commonSuffix(longtext.substring(0, i), - shorttext.substring(0, j)); - if (best_common.length < suffixLength + prefixLength) { - best_common = shorttext.substring(j - suffixLength, j) + - shorttext.substring(j, j + prefixLength); - best_longtext_a = longtext.substring(0, i - suffixLength); - best_longtext_b = longtext.substring(i + prefixLength); - best_shorttext_a = shorttext.substring(0, j - suffixLength); - best_shorttext_b = shorttext.substring(j + prefixLength); - } - } - if (best_common.length * 2 >= longtext.length) { - return [best_longtext_a, best_longtext_b, - best_shorttext_a, best_shorttext_b, best_common]; - } else { - return null; - } - } - - // First check if the second quarter is the seed for a half-match. - var hm1 = diff_halfMatchI_(longtext, shorttext, - Math.ceil(longtext.length / 4)); - // Check again based on the third quarter. - var hm2 = diff_halfMatchI_(longtext, shorttext, - Math.ceil(longtext.length / 2)); - var hm; - if (!hm1 && !hm2) { - return null; - } else if (!hm2) { - hm = hm1; - } else if (!hm1) { - hm = hm2; - } else { - // Both matched. Select the longest. - hm = hm1[4].length > hm2[4].length ? hm1 : hm2; - } - - // A half-match was found, sort out the return data. - var text1_a, text1_b, text2_a, text2_b; - if (text1.length > text2.length) { - text1_a = hm[0]; - text1_b = hm[1]; - text2_a = hm[2]; - text2_b = hm[3]; - } else { - text2_a = hm[0]; - text2_b = hm[1]; - text1_a = hm[2]; - text1_b = hm[3]; - } - var mid_common = hm[4]; - return [text1_a, text1_b, text2_a, text2_b, mid_common]; -}; - - -/** - * Reduce the number of edits by eliminating semantically trivial equalities. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupSemantic = function(diffs) { - var changes = false; - var equalities = []; // Stack of indices where equalities are found. - var equalitiesLength = 0; // Keeping our own length var is faster in JS. - /** @type {?string} */ - var lastEquality = null; - // Always equal to diffs[equalities[equalitiesLength - 1]][1] - var pointer = 0; // Index of current position. - // Number of characters that changed prior to the equality. - var length_insertions1 = 0; - var length_deletions1 = 0; - // Number of characters that changed after the equality. - var length_insertions2 = 0; - var length_deletions2 = 0; - while (pointer < diffs.length) { - if (diffs[pointer][0] == DIFF_EQUAL) { // Equality found. - equalities[equalitiesLength++] = pointer; - length_insertions1 = length_insertions2; - length_deletions1 = length_deletions2; - length_insertions2 = 0; - length_deletions2 = 0; - lastEquality = diffs[pointer][1]; - } else { // An insertion or deletion. - if (diffs[pointer][0] == DIFF_INSERT) { - length_insertions2 += diffs[pointer][1].length; - } else { - length_deletions2 += diffs[pointer][1].length; - } - // Eliminate an equality that is smaller or equal to the edits on both - // sides of it. - if (lastEquality && (lastEquality.length <= - Math.max(length_insertions1, length_deletions1)) && - (lastEquality.length <= Math.max(length_insertions2, - length_deletions2))) { - // Duplicate record. - diffs.splice(equalities[equalitiesLength - 1], 0, - new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); - // Change second copy to insert. - diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; - // Throw away the equality we just deleted. - equalitiesLength--; - // Throw away the previous equality (it needs to be reevaluated). - equalitiesLength--; - pointer = equalitiesLength > 0 ? equalities[equalitiesLength - 1] : -1; - length_insertions1 = 0; // Reset the counters. - length_deletions1 = 0; - length_insertions2 = 0; - length_deletions2 = 0; - lastEquality = null; - changes = true; - } - } - pointer++; - } - - // Normalize the diff. - if (changes) { - this.diff_cleanupMerge(diffs); - } - this.diff_cleanupSemanticLossless(diffs); - - // Find any overlaps between deletions and insertions. - // e.g: abcxxxxxxdef - // -> abcxxxdef - // e.g: xxxabcdefxxx - // -> defxxxabc - // Only extract an overlap if it is as big as the edit ahead or behind it. - pointer = 1; - while (pointer < diffs.length) { - if (diffs[pointer - 1][0] == DIFF_DELETE && - diffs[pointer][0] == DIFF_INSERT) { - var deletion = diffs[pointer - 1][1]; - var insertion = diffs[pointer][1]; - var overlap_length1 = this.diff_commonOverlap_(deletion, insertion); - var overlap_length2 = this.diff_commonOverlap_(insertion, deletion); - if (overlap_length1 >= overlap_length2) { - if (overlap_length1 >= deletion.length / 2 || - overlap_length1 >= insertion.length / 2) { - // Overlap found. Insert an equality and trim the surrounding edits. - diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, - insertion.substring(0, overlap_length1))); - diffs[pointer - 1][1] = - deletion.substring(0, deletion.length - overlap_length1); - diffs[pointer + 1][1] = insertion.substring(overlap_length1); - pointer++; - } - } else { - if (overlap_length2 >= deletion.length / 2 || - overlap_length2 >= insertion.length / 2) { - // Reverse overlap found. - // Insert an equality and swap and trim the surrounding edits. - diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, - deletion.substring(0, overlap_length2))); - diffs[pointer - 1][0] = DIFF_INSERT; - diffs[pointer - 1][1] = - insertion.substring(0, insertion.length - overlap_length2); - diffs[pointer + 1][0] = DIFF_DELETE; - diffs[pointer + 1][1] = - deletion.substring(overlap_length2); - pointer++; - } - } - pointer++; - } - pointer++; - } -}; - - -/** - * Look for single edits surrounded on both sides by equalities - * which can be shifted sideways to align the edit to a word boundary. - * e.g: The cat came. -> The cat came. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupSemanticLossless = function(diffs) { - /** - * Given two strings, compute a score representing whether the internal - * boundary falls on logical boundaries. - * Scores range from 6 (best) to 0 (worst). - * Closure, but does not reference any external variables. - * @param {string} one First string. - * @param {string} two Second string. - * @return {number} The score. - * @private - */ - function diff_cleanupSemanticScore_(one, two) { - if (!one || !two) { - // Edges are the best. - return 6; - } - - // Each port of this function behaves slightly differently due to - // subtle differences in each language's definition of things like - // 'whitespace'. Since this function's purpose is largely cosmetic, - // the choice has been made to use each language's native features - // rather than force total conformity. - var char1 = one.charAt(one.length - 1); - var char2 = two.charAt(0); - var nonAlphaNumeric1 = char1.match(diff_match_patch.nonAlphaNumericRegex_); - var nonAlphaNumeric2 = char2.match(diff_match_patch.nonAlphaNumericRegex_); - var whitespace1 = nonAlphaNumeric1 && - char1.match(diff_match_patch.whitespaceRegex_); - var whitespace2 = nonAlphaNumeric2 && - char2.match(diff_match_patch.whitespaceRegex_); - var lineBreak1 = whitespace1 && - char1.match(diff_match_patch.linebreakRegex_); - var lineBreak2 = whitespace2 && - char2.match(diff_match_patch.linebreakRegex_); - var blankLine1 = lineBreak1 && - one.match(diff_match_patch.blanklineEndRegex_); - var blankLine2 = lineBreak2 && - two.match(diff_match_patch.blanklineStartRegex_); - - if (blankLine1 || blankLine2) { - // Five points for blank lines. - return 5; - } else if (lineBreak1 || lineBreak2) { - // Four points for line breaks. - return 4; - } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { - // Three points for end of sentences. - return 3; - } else if (whitespace1 || whitespace2) { - // Two points for whitespace. - return 2; - } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { - // One point for non-alphanumeric. - return 1; - } - return 0; - } - - var pointer = 1; - // Intentionally ignore the first and last element (don't need checking). - while (pointer < diffs.length - 1) { - if (diffs[pointer - 1][0] == DIFF_EQUAL && - diffs[pointer + 1][0] == DIFF_EQUAL) { - // This is a single edit surrounded by equalities. - var equality1 = diffs[pointer - 1][1]; - var edit = diffs[pointer][1]; - var equality2 = diffs[pointer + 1][1]; - - // First, shift the edit as far left as possible. - var commonOffset = this.diff_commonSuffix(equality1, edit); - if (commonOffset) { - var commonString = edit.substring(edit.length - commonOffset); - equality1 = equality1.substring(0, equality1.length - commonOffset); - edit = commonString + edit.substring(0, edit.length - commonOffset); - equality2 = commonString + equality2; - } - - // Second, step character by character right, looking for the best fit. - var bestEquality1 = equality1; - var bestEdit = edit; - var bestEquality2 = equality2; - var bestScore = diff_cleanupSemanticScore_(equality1, edit) + - diff_cleanupSemanticScore_(edit, equality2); - while (edit.charAt(0) === equality2.charAt(0)) { - equality1 += edit.charAt(0); - edit = edit.substring(1) + equality2.charAt(0); - equality2 = equality2.substring(1); - var score = diff_cleanupSemanticScore_(equality1, edit) + - diff_cleanupSemanticScore_(edit, equality2); - // The >= encourages trailing rather than leading whitespace on edits. - if (score >= bestScore) { - bestScore = score; - bestEquality1 = equality1; - bestEdit = edit; - bestEquality2 = equality2; - } - } - - if (diffs[pointer - 1][1] != bestEquality1) { - // We have an improvement, save it back to the diff. - if (bestEquality1) { - diffs[pointer - 1][1] = bestEquality1; - } else { - diffs.splice(pointer - 1, 1); - pointer--; - } - diffs[pointer][1] = bestEdit; - if (bestEquality2) { - diffs[pointer + 1][1] = bestEquality2; - } else { - diffs.splice(pointer + 1, 1); - pointer--; - } - } - } - pointer++; - } -}; - -// Define some regex patterns for matching boundaries. -diff_match_patch.nonAlphaNumericRegex_ = /[^a-zA-Z0-9]/; -diff_match_patch.whitespaceRegex_ = /\s/; -diff_match_patch.linebreakRegex_ = /[\r\n]/; -diff_match_patch.blanklineEndRegex_ = /\n\r?\n$/; -diff_match_patch.blanklineStartRegex_ = /^\r?\n\r?\n/; - -/** - * Reduce the number of edits by eliminating operationally trivial equalities. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupEfficiency = function(diffs) { - var changes = false; - var equalities = []; // Stack of indices where equalities are found. - var equalitiesLength = 0; // Keeping our own length var is faster in JS. - /** @type {?string} */ - var lastEquality = null; - // Always equal to diffs[equalities[equalitiesLength - 1]][1] - var pointer = 0; // Index of current position. - // Is there an insertion operation before the last equality. - var pre_ins = false; - // Is there a deletion operation before the last equality. - var pre_del = false; - // Is there an insertion operation after the last equality. - var post_ins = false; - // Is there a deletion operation after the last equality. - var post_del = false; - while (pointer < diffs.length) { - if (diffs[pointer][0] == DIFF_EQUAL) { // Equality found. - if (diffs[pointer][1].length < this.Diff_EditCost && - (post_ins || post_del)) { - // Candidate found. - equalities[equalitiesLength++] = pointer; - pre_ins = post_ins; - pre_del = post_del; - lastEquality = diffs[pointer][1]; - } else { - // Not a candidate, and can never become one. - equalitiesLength = 0; - lastEquality = null; - } - post_ins = post_del = false; - } else { // An insertion or deletion. - if (diffs[pointer][0] == DIFF_DELETE) { - post_del = true; - } else { - post_ins = true; - } - /* - * Five types to be split: - * ABXYCD - * AXCD - * ABXC - * AXCD - * ABXC - */ - if (lastEquality && ((pre_ins && pre_del && post_ins && post_del) || - ((lastEquality.length < this.Diff_EditCost / 2) && - (pre_ins + pre_del + post_ins + post_del) == 3))) { - // Duplicate record. - diffs.splice(equalities[equalitiesLength - 1], 0, - new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); - // Change second copy to insert. - diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; - equalitiesLength--; // Throw away the equality we just deleted; - lastEquality = null; - if (pre_ins && pre_del) { - // No changes made which could affect previous entry, keep going. - post_ins = post_del = true; - equalitiesLength = 0; - } else { - equalitiesLength--; // Throw away the previous equality. - pointer = equalitiesLength > 0 ? - equalities[equalitiesLength - 1] : -1; - post_ins = post_del = false; - } - changes = true; - } - } - pointer++; - } - - if (changes) { - this.diff_cleanupMerge(diffs); - } -}; - - -/** - * Reorder and merge like edit sections. Merge equalities. - * Any edit section can move as long as it doesn't cross an equality. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupMerge = function(diffs) { - // Add a dummy entry at the end. - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); - var pointer = 0; - var count_delete = 0; - var count_insert = 0; - var text_delete = ''; - var text_insert = ''; - var commonlength; - while (pointer < diffs.length) { - switch (diffs[pointer][0]) { - case DIFF_INSERT: - count_insert++; - text_insert += diffs[pointer][1]; - pointer++; - break; - case DIFF_DELETE: - count_delete++; - text_delete += diffs[pointer][1]; - pointer++; - break; - case DIFF_EQUAL: - // Upon reaching an equality, check for prior redundancies. - if (count_delete + count_insert > 1) { - if (count_delete !== 0 && count_insert !== 0) { - // Factor out any common prefixies. - commonlength = this.diff_commonPrefix(text_insert, text_delete); - if (commonlength !== 0) { - if ((pointer - count_delete - count_insert) > 0 && - diffs[pointer - count_delete - count_insert - 1][0] == - DIFF_EQUAL) { - diffs[pointer - count_delete - count_insert - 1][1] += - text_insert.substring(0, commonlength); - } else { - diffs.splice(0, 0, new diff_match_patch.Diff(DIFF_EQUAL, - text_insert.substring(0, commonlength))); - pointer++; - } - text_insert = text_insert.substring(commonlength); - text_delete = text_delete.substring(commonlength); - } - // Factor out any common suffixies. - commonlength = this.diff_commonSuffix(text_insert, text_delete); - if (commonlength !== 0) { - diffs[pointer][1] = text_insert.substring(text_insert.length - - commonlength) + diffs[pointer][1]; - text_insert = text_insert.substring(0, text_insert.length - - commonlength); - text_delete = text_delete.substring(0, text_delete.length - - commonlength); - } - } - // Delete the offending records and add the merged ones. - pointer -= count_delete + count_insert; - diffs.splice(pointer, count_delete + count_insert); - if (text_delete.length) { - diffs.splice(pointer, 0, - new diff_match_patch.Diff(DIFF_DELETE, text_delete)); - pointer++; - } - if (text_insert.length) { - diffs.splice(pointer, 0, - new diff_match_patch.Diff(DIFF_INSERT, text_insert)); - pointer++; - } - pointer++; - } else if (pointer !== 0 && diffs[pointer - 1][0] == DIFF_EQUAL) { - // Merge this equality with the previous one. - diffs[pointer - 1][1] += diffs[pointer][1]; - diffs.splice(pointer, 1); - } else { - pointer++; - } - count_insert = 0; - count_delete = 0; - text_delete = ''; - text_insert = ''; - break; - } - } - if (diffs[diffs.length - 1][1] === '') { - diffs.pop(); // Remove the dummy entry at the end. - } - - // Second pass: look for single edits surrounded on both sides by equalities - // which can be shifted sideways to eliminate an equality. - // e.g: ABAC -> ABAC - var changes = false; - pointer = 1; - // Intentionally ignore the first and last element (don't need checking). - while (pointer < diffs.length - 1) { - if (diffs[pointer - 1][0] == DIFF_EQUAL && - diffs[pointer + 1][0] == DIFF_EQUAL) { - // This is a single edit surrounded by equalities. - if (diffs[pointer][1].substring(diffs[pointer][1].length - - diffs[pointer - 1][1].length) == diffs[pointer - 1][1]) { - // Shift the edit over the previous equality. - diffs[pointer][1] = diffs[pointer - 1][1] + - diffs[pointer][1].substring(0, diffs[pointer][1].length - - diffs[pointer - 1][1].length); - diffs[pointer + 1][1] = diffs[pointer - 1][1] + diffs[pointer + 1][1]; - diffs.splice(pointer - 1, 1); - changes = true; - } else if (diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) == - diffs[pointer + 1][1]) { - // Shift the edit over the next equality. - diffs[pointer - 1][1] += diffs[pointer + 1][1]; - diffs[pointer][1] = - diffs[pointer][1].substring(diffs[pointer + 1][1].length) + - diffs[pointer + 1][1]; - diffs.splice(pointer + 1, 1); - changes = true; - } - } - pointer++; - } - // If shifts were made, the diff needs reordering and another shift sweep. - if (changes) { - this.diff_cleanupMerge(diffs); - } -}; - - -/** - * loc is a location in text1, compute and return the equivalent location in - * text2. - * e.g. 'The cat' vs 'The big cat', 1->1, 5->8 - * @param {!Array.} diffs Array of diff tuples. - * @param {number} loc Location within text1. - * @return {number} Location within text2. - */ -diff_match_patch.prototype.diff_xIndex = function(diffs, loc) { - var chars1 = 0; - var chars2 = 0; - var last_chars1 = 0; - var last_chars2 = 0; - var x; - for (x = 0; x < diffs.length; x++) { - if (diffs[x][0] !== DIFF_INSERT) { // Equality or deletion. - chars1 += diffs[x][1].length; - } - if (diffs[x][0] !== DIFF_DELETE) { // Equality or insertion. - chars2 += diffs[x][1].length; - } - if (chars1 > loc) { // Overshot the location. - break; - } - last_chars1 = chars1; - last_chars2 = chars2; - } - // Was the location was deleted? - if (diffs.length != x && diffs[x][0] === DIFF_DELETE) { - return last_chars2; - } - // Add the remaining character length. - return last_chars2 + (loc - last_chars1); -}; - - -/** - * Convert a diff array into a pretty HTML report. - * @param {!Array.} diffs Array of diff tuples. - * @return {string} HTML representation. - */ -diff_match_patch.prototype.diff_prettyHtml = function(diffs) { - var html = []; - var pattern_amp = /&/g; - var pattern_lt = //g; - var pattern_para = /\n/g; - for (var x = 0; x < diffs.length; x++) { - var op = diffs[x][0]; // Operation (insert, delete, equal) - var data = diffs[x][1]; // Text of change. - var text = data.replace(pattern_amp, '&').replace(pattern_lt, '<') - .replace(pattern_gt, '>').replace(pattern_para, '¶
'); - switch (op) { - case DIFF_INSERT: - html[x] = '' + text + ''; - break; - case DIFF_DELETE: - html[x] = '' + text + ''; - break; - case DIFF_EQUAL: - html[x] = '' + text + ''; - break; - } - } - return html.join(''); -}; - - -/** - * Compute and return the source text (all equalities and deletions). - * @param {!Array.} diffs Array of diff tuples. - * @return {string} Source text. - */ -diff_match_patch.prototype.diff_text1 = function(diffs) { - var text = []; - for (var x = 0; x < diffs.length; x++) { - if (diffs[x][0] !== DIFF_INSERT) { - text[x] = diffs[x][1]; - } - } - return text.join(''); -}; - - -/** - * Compute and return the destination text (all equalities and insertions). - * @param {!Array.} diffs Array of diff tuples. - * @return {string} Destination text. - */ -diff_match_patch.prototype.diff_text2 = function(diffs) { - var text = []; - for (var x = 0; x < diffs.length; x++) { - if (diffs[x][0] !== DIFF_DELETE) { - text[x] = diffs[x][1]; - } - } - return text.join(''); -}; - - -/** - * Compute the Levenshtein distance; the number of inserted, deleted or - * substituted characters. - * @param {!Array.} diffs Array of diff tuples. - * @return {number} Number of changes. - */ -diff_match_patch.prototype.diff_levenshtein = function(diffs) { - var levenshtein = 0; - var insertions = 0; - var deletions = 0; - for (var x = 0; x < diffs.length; x++) { - var op = diffs[x][0]; - var data = diffs[x][1]; - switch (op) { - case DIFF_INSERT: - insertions += data.length; - break; - case DIFF_DELETE: - deletions += data.length; - break; - case DIFF_EQUAL: - // A deletion and an insertion is one substitution. - levenshtein += Math.max(insertions, deletions); - insertions = 0; - deletions = 0; - break; - } - } - levenshtein += Math.max(insertions, deletions); - return levenshtein; -}; - - -/** - * Crush the diff into an encoded string which describes the operations - * required to transform text1 into text2. - * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. - * Operations are tab-separated. Inserted text is escaped using %xx notation. - * @param {!Array.} diffs Array of diff tuples. - * @return {string} Delta text. - */ -diff_match_patch.prototype.diff_toDelta = function(diffs) { - var text = []; - for (var x = 0; x < diffs.length; x++) { - switch (diffs[x][0]) { - case DIFF_INSERT: - text[x] = '+' + encodeURI(diffs[x][1]); - break; - case DIFF_DELETE: - text[x] = '-' + diffs[x][1].length; - break; - case DIFF_EQUAL: - text[x] = '=' + diffs[x][1].length; - break; - } - } - return text.join('\t').replace(/%20/g, ' '); -}; - - -/** - * Given the original text1, and an encoded string which describes the - * operations required to transform text1 into text2, compute the full diff. - * @param {string} text1 Source string for the diff. - * @param {string} delta Delta text. - * @return {!Array.} Array of diff tuples. - * @throws {!Error} If invalid input. - */ -diff_match_patch.prototype.diff_fromDelta = function(text1, delta) { - var diffs = []; - var diffsLength = 0; // Keeping our own length var is faster in JS. - var pointer = 0; // Cursor in text1 - var tokens = delta.split(/\t/g); - for (var x = 0; x < tokens.length; x++) { - // Each token begins with a one character parameter which specifies the - // operation of this token (delete, insert, equality). - var param = tokens[x].substring(1); - switch (tokens[x].charAt(0)) { - case '+': - try { - diffs[diffsLength++] = - new diff_match_patch.Diff(DIFF_INSERT, decodeURI(param)); - } catch (ex) { - // Malformed URI sequence. - throw new Error('Illegal escape in diff_fromDelta: ' + param); - } - break; - case '-': - // Fall through. - case '=': - var n = parseInt(param, 10); - if (isNaN(n) || n < 0) { - throw new Error('Invalid number in diff_fromDelta: ' + param); - } - var text = text1.substring(pointer, pointer += n); - if (tokens[x].charAt(0) == '=') { - diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_EQUAL, text); - } else { - diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_DELETE, text); - } - break; - default: - // Blank tokens are ok (from a trailing \t). - // Anything else is an error. - if (tokens[x]) { - throw new Error('Invalid diff operation in diff_fromDelta: ' + - tokens[x]); - } - } - } - if (pointer != text1.length) { - throw new Error('Delta length (' + pointer + - ') does not equal source text length (' + text1.length + ').'); - } - return diffs; -}; - - -// MATCH FUNCTIONS - - -/** - * Locate the best instance of 'pattern' in 'text' near 'loc'. - * @param {string} text The text to search. - * @param {string} pattern The pattern to search for. - * @param {number} loc The location to search around. - * @return {number} Best match index or -1. - */ -diff_match_patch.prototype.match_main = function(text, pattern, loc) { - // Check for null inputs. - if (text == null || pattern == null || loc == null) { - throw new Error('Null input. (match_main)'); - } - - loc = Math.max(0, Math.min(loc, text.length)); - if (text == pattern) { - // Shortcut (potentially not guaranteed by the algorithm) - return 0; - } else if (!text.length) { - // Nothing to match. - return -1; - } else if (text.substring(loc, loc + pattern.length) == pattern) { - // Perfect match at the perfect spot! (Includes case of null pattern) - return loc; - } else { - // Do a fuzzy compare. - return this.match_bitap_(text, pattern, loc); - } -}; - - -/** - * Locate the best instance of 'pattern' in 'text' near 'loc' using the - * Bitap algorithm. - * @param {string} text The text to search. - * @param {string} pattern The pattern to search for. - * @param {number} loc The location to search around. - * @return {number} Best match index or -1. - * @private - */ -diff_match_patch.prototype.match_bitap_ = function(text, pattern, loc) { - if (pattern.length > this.Match_MaxBits) { - throw new Error('Pattern too long for this browser.'); - } - - // Initialise the alphabet. - var s = this.match_alphabet_(pattern); - - var dmp = this; // 'this' becomes 'window' in a closure. - - /** - * Compute and return the score for a match with e errors and x location. - * Accesses loc and pattern through being a closure. - * @param {number} e Number of errors in match. - * @param {number} x Location of match. - * @return {number} Overall score for match (0.0 = good, 1.0 = bad). - * @private - */ - function match_bitapScore_(e, x) { - var accuracy = e / pattern.length; - var proximity = Math.abs(loc - x); - if (!dmp.Match_Distance) { - // Dodge divide by zero error. - return proximity ? 1.0 : accuracy; - } - return accuracy + (proximity / dmp.Match_Distance); - } - - // Highest score beyond which we give up. - var score_threshold = this.Match_Threshold; - // Is there a nearby exact match? (speedup) - var best_loc = text.indexOf(pattern, loc); - if (best_loc != -1) { - score_threshold = Math.min(match_bitapScore_(0, best_loc), score_threshold); - // What about in the other direction? (speedup) - best_loc = text.lastIndexOf(pattern, loc + pattern.length); - if (best_loc != -1) { - score_threshold = - Math.min(match_bitapScore_(0, best_loc), score_threshold); - } - } - - // Initialise the bit arrays. - var matchmask = 1 << (pattern.length - 1); - best_loc = -1; - - var bin_min, bin_mid; - var bin_max = pattern.length + text.length; - var last_rd; - for (var d = 0; d < pattern.length; d++) { - // Scan for the best match; each iteration allows for one more error. - // Run a binary search to determine how far from 'loc' we can stray at this - // error level. - bin_min = 0; - bin_mid = bin_max; - while (bin_min < bin_mid) { - if (match_bitapScore_(d, loc + bin_mid) <= score_threshold) { - bin_min = bin_mid; - } else { - bin_max = bin_mid; - } - bin_mid = Math.floor((bin_max - bin_min) / 2 + bin_min); - } - // Use the result from this iteration as the maximum for the next. - bin_max = bin_mid; - var start = Math.max(1, loc - bin_mid + 1); - var finish = Math.min(loc + bin_mid, text.length) + pattern.length; - - var rd = Array(finish + 2); - rd[finish + 1] = (1 << d) - 1; - for (var j = finish; j >= start; j--) { - // The alphabet (s) is a sparse hash, so the following line generates - // warnings. - var charMatch = s[text.charAt(j - 1)]; - if (d === 0) { // First pass: exact match. - rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; - } else { // Subsequent passes: fuzzy match. - rd[j] = (((rd[j + 1] << 1) | 1) & charMatch) | - (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | - last_rd[j + 1]; - } - if (rd[j] & matchmask) { - var score = match_bitapScore_(d, j - 1); - // This match will almost certainly be better than any existing match. - // But check anyway. - if (score <= score_threshold) { - // Told you so. - score_threshold = score; - best_loc = j - 1; - if (best_loc > loc) { - // When passing loc, don't exceed our current distance from loc. - start = Math.max(1, 2 * loc - best_loc); - } else { - // Already passed loc, downhill from here on in. - break; - } - } - } - } - // No hope for a (better) match at greater error levels. - if (match_bitapScore_(d + 1, loc) > score_threshold) { - break; - } - last_rd = rd; - } - return best_loc; -}; - - -/** - * Initialise the alphabet for the Bitap algorithm. - * @param {string} pattern The text to encode. - * @return {!Object} Hash of character locations. - * @private - */ -diff_match_patch.prototype.match_alphabet_ = function(pattern) { - var s = {}; - for (var i = 0; i < pattern.length; i++) { - s[pattern.charAt(i)] = 0; - } - for (var i = 0; i < pattern.length; i++) { - s[pattern.charAt(i)] |= 1 << (pattern.length - i - 1); - } - return s; -}; - - -// PATCH FUNCTIONS - - -/** - * Increase the context until it is unique, - * but don't let the pattern expand beyond Match_MaxBits. - * @param {!diff_match_patch.patch_obj} patch The patch to grow. - * @param {string} text Source text. - * @private - */ -diff_match_patch.prototype.patch_addContext_ = function(patch, text) { - if (text.length == 0) { - return; - } - if (patch.start2 === null) { - throw Error('patch not initialized'); - } - var pattern = text.substring(patch.start2, patch.start2 + patch.length1); - var padding = 0; - - // Look for the first and last matches of pattern in text. If two different - // matches are found, increase the pattern length. - while (text.indexOf(pattern) != text.lastIndexOf(pattern) && - pattern.length < this.Match_MaxBits - this.Patch_Margin - - this.Patch_Margin) { - padding += this.Patch_Margin; - pattern = text.substring(patch.start2 - padding, - patch.start2 + patch.length1 + padding); - } - // Add one chunk for good luck. - padding += this.Patch_Margin; - - // Add the prefix. - var prefix = text.substring(patch.start2 - padding, patch.start2); - if (prefix) { - patch.diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, prefix)); - } - // Add the suffix. - var suffix = text.substring(patch.start2 + patch.length1, - patch.start2 + patch.length1 + padding); - if (suffix) { - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, suffix)); - } - - // Roll back the start points. - patch.start1 -= prefix.length; - patch.start2 -= prefix.length; - // Extend the lengths. - patch.length1 += prefix.length + suffix.length; - patch.length2 += prefix.length + suffix.length; -}; - - -/** - * Compute a list of patches to turn text1 into text2. - * Use diffs if provided, otherwise compute it ourselves. - * There are four ways to call this function, depending on what data is - * available to the caller: - * Method 1: - * a = text1, b = text2 - * Method 2: - * a = diffs - * Method 3 (optimal): - * a = text1, b = diffs - * Method 4 (deprecated, use method 3): - * a = text1, b = text2, c = diffs - * - * @param {string|!Array.} a text1 (methods 1,3,4) or - * Array of diff tuples for text1 to text2 (method 2). - * @param {string|!Array.=} opt_b text2 (methods 1,4) or - * Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). - * @param {string|!Array.=} opt_c Array of diff tuples - * for text1 to text2 (method 4) or undefined (methods 1,2,3). - * @return {!Array.} Array of Patch objects. - */ -diff_match_patch.prototype.patch_make = function(a, opt_b, opt_c) { - var text1, diffs; - if (typeof a == 'string' && typeof opt_b == 'string' && - typeof opt_c == 'undefined') { - // Method 1: text1, text2 - // Compute diffs from text1 and text2. - text1 = /** @type {string} */(a); - diffs = this.diff_main(text1, /** @type {string} */(opt_b), true); - if (diffs.length > 2) { - this.diff_cleanupSemantic(diffs); - this.diff_cleanupEfficiency(diffs); - } - } else if (a && typeof a == 'object' && typeof opt_b == 'undefined' && - typeof opt_c == 'undefined') { - // Method 2: diffs - // Compute text1 from diffs. - diffs = /** @type {!Array.} */(a); - text1 = this.diff_text1(diffs); - } else if (typeof a == 'string' && opt_b && typeof opt_b == 'object' && - typeof opt_c == 'undefined') { - // Method 3: text1, diffs - text1 = /** @type {string} */(a); - diffs = /** @type {!Array.} */(opt_b); - } else if (typeof a == 'string' && typeof opt_b == 'string' && - opt_c && typeof opt_c == 'object') { - // Method 4: text1, text2, diffs - // text2 is not used. - text1 = /** @type {string} */(a); - diffs = /** @type {!Array.} */(opt_c); - } else { - throw new Error('Unknown call format to patch_make.'); - } - - if (diffs.length === 0) { - return []; // Get rid of the null case. - } - var patches = []; - var patch = new diff_match_patch.patch_obj(); - var patchDiffLength = 0; // Keeping our own length var is faster in JS. - var char_count1 = 0; // Number of characters into the text1 string. - var char_count2 = 0; // Number of characters into the text2 string. - // Start with text1 (prepatch_text) and apply the diffs until we arrive at - // text2 (postpatch_text). We recreate the patches one by one to determine - // context info. - var prepatch_text = text1; - var postpatch_text = text1; - for (var x = 0; x < diffs.length; x++) { - var diff_type = diffs[x][0]; - var diff_text = diffs[x][1]; - - if (!patchDiffLength && diff_type !== DIFF_EQUAL) { - // A new patch starts here. - patch.start1 = char_count1; - patch.start2 = char_count2; - } - - switch (diff_type) { - case DIFF_INSERT: - patch.diffs[patchDiffLength++] = diffs[x]; - patch.length2 += diff_text.length; - postpatch_text = postpatch_text.substring(0, char_count2) + diff_text + - postpatch_text.substring(char_count2); - break; - case DIFF_DELETE: - patch.length1 += diff_text.length; - patch.diffs[patchDiffLength++] = diffs[x]; - postpatch_text = postpatch_text.substring(0, char_count2) + - postpatch_text.substring(char_count2 + - diff_text.length); - break; - case DIFF_EQUAL: - if (diff_text.length <= 2 * this.Patch_Margin && - patchDiffLength && diffs.length != x + 1) { - // Small equality inside a patch. - patch.diffs[patchDiffLength++] = diffs[x]; - patch.length1 += diff_text.length; - patch.length2 += diff_text.length; - } else if (diff_text.length >= 2 * this.Patch_Margin) { - // Time for a new patch. - if (patchDiffLength) { - this.patch_addContext_(patch, prepatch_text); - patches.push(patch); - patch = new diff_match_patch.patch_obj(); - patchDiffLength = 0; - // Unlike Unidiff, our patch lists have a rolling context. - // https://github.com/google/diff-match-patch/wiki/Unidiff - // Update prepatch text & pos to reflect the application of the - // just completed patch. - prepatch_text = postpatch_text; - char_count1 = char_count2; - } - } - break; - } - - // Update the current character count. - if (diff_type !== DIFF_INSERT) { - char_count1 += diff_text.length; - } - if (diff_type !== DIFF_DELETE) { - char_count2 += diff_text.length; - } - } - // Pick up the leftover patch if not empty. - if (patchDiffLength) { - this.patch_addContext_(patch, prepatch_text); - patches.push(patch); - } - - return patches; -}; - - -/** - * Given an array of patches, return another array that is identical. - * @param {!Array.} patches Array of Patch objects. - * @return {!Array.} Array of Patch objects. - */ -diff_match_patch.prototype.patch_deepCopy = function(patches) { - // Making deep copies is hard in JavaScript. - var patchesCopy = []; - for (var x = 0; x < patches.length; x++) { - var patch = patches[x]; - var patchCopy = new diff_match_patch.patch_obj(); - patchCopy.diffs = []; - for (var y = 0; y < patch.diffs.length; y++) { - patchCopy.diffs[y] = - new diff_match_patch.Diff(patch.diffs[y][0], patch.diffs[y][1]); - } - patchCopy.start1 = patch.start1; - patchCopy.start2 = patch.start2; - patchCopy.length1 = patch.length1; - patchCopy.length2 = patch.length2; - patchesCopy[x] = patchCopy; - } - return patchesCopy; -}; - - -/** - * Merge a set of patches onto the text. Return a patched text, as well - * as a list of true/false values indicating which patches were applied. - * @param {!Array.} patches Array of Patch objects. - * @param {string} text Old text. - * @return {!Array.>} Two element Array, containing the - * new text and an array of boolean values. - */ -diff_match_patch.prototype.patch_apply = function(patches, text) { - if (patches.length == 0) { - return [text, []]; - } - - // Deep copy the patches so that no changes are made to originals. - patches = this.patch_deepCopy(patches); - - var nullPadding = this.patch_addPadding(patches); - text = nullPadding + text + nullPadding; - - this.patch_splitMax(patches); - // delta keeps track of the offset between the expected and actual location - // of the previous patch. If there are patches expected at positions 10 and - // 20, but the first patch was found at 12, delta is 2 and the second patch - // has an effective expected position of 22. - var delta = 0; - var results = []; - for (var x = 0; x < patches.length; x++) { - var expected_loc = patches[x].start2 + delta; - var text1 = this.diff_text1(patches[x].diffs); - var start_loc; - var end_loc = -1; - if (text1.length > this.Match_MaxBits) { - // patch_splitMax will only provide an oversized pattern in the case of - // a monster delete. - start_loc = this.match_main(text, text1.substring(0, this.Match_MaxBits), - expected_loc); - if (start_loc != -1) { - end_loc = this.match_main(text, - text1.substring(text1.length - this.Match_MaxBits), - expected_loc + text1.length - this.Match_MaxBits); - if (end_loc == -1 || start_loc >= end_loc) { - // Can't find valid trailing context. Drop this patch. - start_loc = -1; - } - } - } else { - start_loc = this.match_main(text, text1, expected_loc); - } - if (start_loc == -1) { - // No match found. :( - results[x] = false; - // Subtract the delta for this failed patch from subsequent patches. - delta -= patches[x].length2 - patches[x].length1; - } else { - // Found a match. :) - results[x] = true; - delta = start_loc - expected_loc; - var text2; - if (end_loc == -1) { - text2 = text.substring(start_loc, start_loc + text1.length); - } else { - text2 = text.substring(start_loc, end_loc + this.Match_MaxBits); - } - if (text1 == text2) { - // Perfect match, just shove the replacement text in. - text = text.substring(0, start_loc) + - this.diff_text2(patches[x].diffs) + - text.substring(start_loc + text1.length); - } else { - // Imperfect match. Run a diff to get a framework of equivalent - // indices. - var diffs = this.diff_main(text1, text2, false); - if (text1.length > this.Match_MaxBits && - this.diff_levenshtein(diffs) / text1.length > - this.Patch_DeleteThreshold) { - // The end points match, but the content is unacceptably bad. - results[x] = false; - } else { - this.diff_cleanupSemanticLossless(diffs); - var index1 = 0; - var index2; - for (var y = 0; y < patches[x].diffs.length; y++) { - var mod = patches[x].diffs[y]; - if (mod[0] !== DIFF_EQUAL) { - index2 = this.diff_xIndex(diffs, index1); - } - if (mod[0] === DIFF_INSERT) { // Insertion - text = text.substring(0, start_loc + index2) + mod[1] + - text.substring(start_loc + index2); - } else if (mod[0] === DIFF_DELETE) { // Deletion - text = text.substring(0, start_loc + index2) + - text.substring(start_loc + this.diff_xIndex(diffs, - index1 + mod[1].length)); - } - if (mod[0] !== DIFF_DELETE) { - index1 += mod[1].length; - } - } - } - } - } - } - // Strip the padding off. - text = text.substring(nullPadding.length, text.length - nullPadding.length); - return [text, results]; -}; - - -/** - * Add some padding on text start and end so that edges can match something. - * Intended to be called only from within patch_apply. - * @param {!Array.} patches Array of Patch objects. - * @return {string} The padding string added to each side. - */ -diff_match_patch.prototype.patch_addPadding = function(patches) { - var paddingLength = this.Patch_Margin; - var nullPadding = ''; - for (var x = 1; x <= paddingLength; x++) { - nullPadding += String.fromCharCode(x); - } - - // Bump all the patches forward. - for (var x = 0; x < patches.length; x++) { - patches[x].start1 += paddingLength; - patches[x].start2 += paddingLength; - } - - // Add some padding on start of first diff. - var patch = patches[0]; - var diffs = patch.diffs; - if (diffs.length == 0 || diffs[0][0] != DIFF_EQUAL) { - // Add nullPadding equality. - diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); - patch.start1 -= paddingLength; // Should be 0. - patch.start2 -= paddingLength; // Should be 0. - patch.length1 += paddingLength; - patch.length2 += paddingLength; - } else if (paddingLength > diffs[0][1].length) { - // Grow first equality. - var extraLength = paddingLength - diffs[0][1].length; - diffs[0][1] = nullPadding.substring(diffs[0][1].length) + diffs[0][1]; - patch.start1 -= extraLength; - patch.start2 -= extraLength; - patch.length1 += extraLength; - patch.length2 += extraLength; - } - - // Add some padding on end of last diff. - patch = patches[patches.length - 1]; - diffs = patch.diffs; - if (diffs.length == 0 || diffs[diffs.length - 1][0] != DIFF_EQUAL) { - // Add nullPadding equality. - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); - patch.length1 += paddingLength; - patch.length2 += paddingLength; - } else if (paddingLength > diffs[diffs.length - 1][1].length) { - // Grow last equality. - var extraLength = paddingLength - diffs[diffs.length - 1][1].length; - diffs[diffs.length - 1][1] += nullPadding.substring(0, extraLength); - patch.length1 += extraLength; - patch.length2 += extraLength; - } - - return nullPadding; -}; - - -/** - * Look through the patches and break up any which are longer than the maximum - * limit of the match algorithm. - * Intended to be called only from within patch_apply. - * @param {!Array.} patches Array of Patch objects. - */ -diff_match_patch.prototype.patch_splitMax = function(patches) { - var patch_size = this.Match_MaxBits; - for (var x = 0; x < patches.length; x++) { - if (patches[x].length1 <= patch_size) { - continue; - } - var bigpatch = patches[x]; - // Remove the big old patch. - patches.splice(x--, 1); - var start1 = bigpatch.start1; - var start2 = bigpatch.start2; - var precontext = ''; - while (bigpatch.diffs.length !== 0) { - // Create one of several smaller patches. - var patch = new diff_match_patch.patch_obj(); - var empty = true; - patch.start1 = start1 - precontext.length; - patch.start2 = start2 - precontext.length; - if (precontext !== '') { - patch.length1 = patch.length2 = precontext.length; - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, precontext)); - } - while (bigpatch.diffs.length !== 0 && - patch.length1 < patch_size - this.Patch_Margin) { - var diff_type = bigpatch.diffs[0][0]; - var diff_text = bigpatch.diffs[0][1]; - if (diff_type === DIFF_INSERT) { - // Insertions are harmless. - patch.length2 += diff_text.length; - start2 += diff_text.length; - patch.diffs.push(bigpatch.diffs.shift()); - empty = false; - } else if (diff_type === DIFF_DELETE && patch.diffs.length == 1 && - patch.diffs[0][0] == DIFF_EQUAL && - diff_text.length > 2 * patch_size) { - // This is a large deletion. Let it pass in one chunk. - patch.length1 += diff_text.length; - start1 += diff_text.length; - empty = false; - patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); - bigpatch.diffs.shift(); - } else { - // Deletion or equality. Only take as much as we can stomach. - diff_text = diff_text.substring(0, - patch_size - patch.length1 - this.Patch_Margin); - patch.length1 += diff_text.length; - start1 += diff_text.length; - if (diff_type === DIFF_EQUAL) { - patch.length2 += diff_text.length; - start2 += diff_text.length; - } else { - empty = false; - } - patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); - if (diff_text == bigpatch.diffs[0][1]) { - bigpatch.diffs.shift(); - } else { - bigpatch.diffs[0][1] = - bigpatch.diffs[0][1].substring(diff_text.length); - } - } - } - // Compute the head context for the next patch. - precontext = this.diff_text2(patch.diffs); - precontext = - precontext.substring(precontext.length - this.Patch_Margin); - // Append the end context for this patch. - var postcontext = this.diff_text1(bigpatch.diffs) - .substring(0, this.Patch_Margin); - if (postcontext !== '') { - patch.length1 += postcontext.length; - patch.length2 += postcontext.length; - if (patch.diffs.length !== 0 && - patch.diffs[patch.diffs.length - 1][0] === DIFF_EQUAL) { - patch.diffs[patch.diffs.length - 1][1] += postcontext; - } else { - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, postcontext)); - } - } - if (!empty) { - patches.splice(++x, 0, patch); - } - } - } -}; - - -/** - * Take a list of patches and return a textual representation. - * @param {!Array.} patches Array of Patch objects. - * @return {string} Text representation of patches. - */ -diff_match_patch.prototype.patch_toText = function(patches) { - var text = []; - for (var x = 0; x < patches.length; x++) { - text[x] = patches[x]; - } - return text.join(''); -}; - - -/** - * Parse a textual representation of patches and return a list of Patch objects. - * @param {string} textline Text representation of patches. - * @return {!Array.} Array of Patch objects. - * @throws {!Error} If invalid input. - */ -diff_match_patch.prototype.patch_fromText = function(textline) { - var patches = []; - if (!textline) { - return patches; - } - var text = textline.split('\n'); - var textPointer = 0; - var patchHeader = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/; - while (textPointer < text.length) { - var m = text[textPointer].match(patchHeader); - if (!m) { - throw new Error('Invalid patch string: ' + text[textPointer]); - } - var patch = new diff_match_patch.patch_obj(); - patches.push(patch); - patch.start1 = parseInt(m[1], 10); - if (m[2] === '') { - patch.start1--; - patch.length1 = 1; - } else if (m[2] == '0') { - patch.length1 = 0; - } else { - patch.start1--; - patch.length1 = parseInt(m[2], 10); - } - - patch.start2 = parseInt(m[3], 10); - if (m[4] === '') { - patch.start2--; - patch.length2 = 1; - } else if (m[4] == '0') { - patch.length2 = 0; - } else { - patch.start2--; - patch.length2 = parseInt(m[4], 10); - } - textPointer++; - - while (textPointer < text.length) { - var sign = text[textPointer].charAt(0); - try { - var line = decodeURI(text[textPointer].substring(1)); - } catch (ex) { - // Malformed URI sequence. - throw new Error('Illegal escape in patch_fromText: ' + line); - } - if (sign == '-') { - // Deletion. - patch.diffs.push(new diff_match_patch.Diff(DIFF_DELETE, line)); - } else if (sign == '+') { - // Insertion. - patch.diffs.push(new diff_match_patch.Diff(DIFF_INSERT, line)); - } else if (sign == ' ') { - // Minor equality. - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, line)); - } else if (sign == '@') { - // Start of next patch. - break; - } else if (sign === '') { - // Blank line? Whatever. - } else { - // WTF? - throw new Error('Invalid patch mode "' + sign + '" in: ' + line); - } - textPointer++; - } - } - return patches; -}; - - -/** - * Class representing one patch operation. - * @constructor - */ -diff_match_patch.patch_obj = function() { - /** @type {!Array.} */ - this.diffs = []; - /** @type {?number} */ - this.start1 = null; - /** @type {?number} */ - this.start2 = null; - /** @type {number} */ - this.length1 = 0; - /** @type {number} */ - this.length2 = 0; -}; - - -/** - * Emulate GNU diff's format. - * Header: @@ -382,8 +481,9 @@ - * Indices are printed as 1-based, not 0-based. - * @return {string} The GNU diff string. - */ -diff_match_patch.patch_obj.prototype.toString = function() { - var coords1, coords2; - if (this.length1 === 0) { - coords1 = this.start1 + ',0'; - } else if (this.length1 == 1) { - coords1 = this.start1 + 1; - } else { - coords1 = (this.start1 + 1) + ',' + this.length1; - } - if (this.length2 === 0) { - coords2 = this.start2 + ',0'; - } else if (this.length2 == 1) { - coords2 = this.start2 + 1; - } else { - coords2 = (this.start2 + 1) + ',' + this.length2; - } - var text = ['@@ -' + coords1 + ' +' + coords2 + ' @@\n']; - var op; - // Escape the body of the patch with %xx notation. - for (var x = 0; x < this.diffs.length; x++) { - switch (this.diffs[x][0]) { - case DIFF_INSERT: - op = '+'; - break; - case DIFF_DELETE: - op = '-'; - break; - case DIFF_EQUAL: - op = ' '; - break; - } - text[x + 1] = op + encodeURI(this.diffs[x][1]) + '\n'; - } - return text.join('').replace(/%20/g, ' '); -}; - - -// The following export code was added by @ForbesLindesay -module.exports = diff_match_patch; -module.exports['diff_match_patch'] = diff_match_patch; -module.exports['DIFF_DELETE'] = DIFF_DELETE; -module.exports['DIFF_INSERT'] = DIFF_INSERT; -module.exports['DIFF_EQUAL'] = DIFF_EQUAL; \ No newline at end of file diff --git a/packages/test-in-browser/driver.html b/packages/test-in-browser/driver.html index 52bd3b8aad..a8f976998b 100644 --- a/packages/test-in-browser/driver.html +++ b/packages/test-in-browser/driver.html @@ -44,12 +44,15 @@