Merge pull request #13915 from meteor/release-3.4

Release 3.4 🚀
This commit is contained in:
Nacho Codoñer
2026-01-29 17:29:13 +01:00
committed by GitHub
608 changed files with 34726 additions and 4219 deletions

86
.github/workflows/e2e-tests.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: E2E Tests
on:
pull_request:
paths:
- 'meteor'
- 'tools/modern-tests/**'
- 'packages/rspack/**'
- 'packages/tools-core/**'
- 'packages/babel-compiler/**'
- 'packages/meteor-tool/**'
- 'npm-packages/meteor-rspack/**'
- 'tools/static-assets/skel-**'
- '.github/workflows/e2e-tests.yml'
concurrency:
group: meteor-rspack-tests-${{ github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: "--max_old_space_size=12288"
jobs:
test:
name: ${{ matrix.category }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
category:
- Angular
- Babel
- Blaze
- Coffeescript
- Library
- Monorepo
- React
- R.Router
- Solid
- Svelte
- Typescript
- Vue
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
tools/modern-tests/node_modules
packages/**/.npm
.meteor
dev_bundle
.babel-cache
~/.cache/ms-playwright
key: ${{ runner.os }}-meteor-${{ hashFiles('**/package-lock.json', 'meteor') }}
restore-keys: |
${{ runner.os }}-meteor-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Install deps
run: npm install
- name: Install test deps
run: npm run install:modern
- name: Prepare Meteor
run: ./meteor --get-ready
- name: Run tests for ${{ matrix.category }}
uses: nick-fields/retry@v3
with:
max_attempts: 3
retry_on: error
timeout_minutes: 15
retry_wait_seconds: 90
command: npm run test:modern -- -t="${{ matrix.category }}"

View File

@@ -60,7 +60,7 @@ jobs:
~/.npm
node_modules/
packages/**/.npm
key: ${{ runner.os }}-meteor-${{ hashFiles('**/package-lock.json', 'meteor', 'meteor.bat') }}
key: ${{ runner.os }}-meteor-${{ hashFiles('meteor', 'meteor.bat') }}
restore-keys: |
${{ runner.os }}-meteor-

View File

@@ -56,7 +56,7 @@ How about trying a tutorial to get started with your favorite technology?
| [<img align="left" width="25" src="https://upload.wikimedia.org/wikipedia/commons/a/a7/React-icon.svg"> React](https://docs.meteor.com/tutorials/react/) |
| - |
| [<img align="left" width="25" src="https://progsoft.net/images/blaze-css-icon-3e80acb3996047afd09f1150f53fcd78e98c1e1b.png"> Blaze](https://blaze-tutorial.meteor.com/) |
| [<img align="left" width="25" src="https://vuejs.org/images/logo.png"> Vue](https://docs.meteor.com/tutorials/vue/meteorjs3-vue3-vue-meteor-tracker.html) |
| [<img align="left" width="25" src="https://vuejs.org/images/logo.png"> Vue](https://docs.meteor.com/tutorials/vue/meteorjs3-vue3.html) |
# 🚀 Quick Start

View File

@@ -87,6 +87,10 @@ Matches a primitive of the given type.
Matches a signed 32-bit integer. Doesn't match `Infinity`, `-Infinity`, or `NaN`.
{% enddtdd %}
{% dtdd name:"<code>Match.NonEmptyString</code>" %}
Matches a non-empty string.
{% enddtdd %}
{% dtdd name:"<code>[<em>pattern</em>]</code>" %}
A one-element array matches an array of elements, each of which match
*pattern*. For example, `[Number]` matches a (possibly empty) array of numbers;
@@ -160,12 +164,13 @@ from the call to `check` or `Match.test`. Examples:
{% codeblock lang:js %}
check(buffer, Match.Where(EJSON.isBinary));
const NonEmptyString = Match.Where((x) => {
check(x, String);
return x.length > 0;
// Example: creating a custom pattern for positive numbers
const PositiveNumber = Match.Where((x) => {
check(x, Number);
return x > 0;
});
check(arg, NonEmptyString);
check(arg, PositiveNumber);
{% endcodeblock %}
{% enddtdd %}
</dl>

2
meteor
View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
BUNDLE_VERSION=22.18.0.3
BUNDLE_VERSION=22.22.0.3
# OS Check. Put here because here is where we download the precompiled
# bundles that are arch specific.

View File

@@ -10,7 +10,7 @@ var packageJson = {
dependencies: {
// Explicit dependency because we are replacing it with a bundled version
// and we want to make sure there are no dependencies on a higher version
npm: "10.9.3",
npm: "10.9.4",
pacote: "https://github.com/meteor/pacote/tarball/a81b0324686e85d22c7688c47629d4009000e8b8",
"node-gyp": "9.4.0",
"@mapbox/node-pre-gyp": "1.0.11",

View File

@@ -1,7 +1,7 @@
const os = require('os');
const path = require('path');
const METEOR_LATEST_VERSION = '3.3.2';
const METEOR_LATEST_VERSION = '3.4';
const sudoUser = process.env.SUDO_USER || '';
function isRoot() {
return process.getuid && process.getuid() === 0;

View File

@@ -1,12 +1,12 @@
{
"name": "meteor",
"version": "3.3.2",
"version": "3.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meteor",
"version": "3.3.2",
"version": "3.4.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "meteor",
"version": "3.3.2",
"version": "3.4.0",
"description": "Install Meteor",
"main": "install.js",
"scripts": {

99
npm-packages/meteor-rspack/index.d.ts vendored Normal file
View File

@@ -0,0 +1,99 @@
/**
* Extend Rspacks Configuration with Meteor-specific options.
*/
import {
defineConfig as _rspackDefineConfig,
Configuration as _RspackConfig,
} from '@rspack/cli';
import { HtmlRspackPluginOptions, RuleSetConditions, SwcLoaderOptions } from '@rspack/core';
export interface MeteorRspackConfig extends _RspackConfig {
meteor?: {
packageNamespace?: string;
};
}
type MeteorEnv = Record<string, any> & {
isDevelopment: boolean;
isProduction: boolean;
isClient: boolean;
isServer: boolean;
isTest: boolean;
isDebug: boolean;
isRun: boolean;
isBuild: boolean;
isReactEnabled: boolean;
isBlazeEnabled: boolean;
isBlazeHotEnabled: boolean;
/**
* A function that creates an instance of HtmlRspackPlugin with default options.
* @param options - Optional configuration options that will be merged with defaults
* @returns An instance of HtmlRspackPlugin
*/
HtmlRspackPlugin: (options?: HtmlRspackPluginOptions) => HtmlRspackPlugin;
/**
* Wrap externals for Meteor runtime.
* @param deps - Package names or module IDs
* @returns A config object with externals configuration
*/
compileWithMeteor: (deps: RuleSetConditions) => Record<string, object>;
/**
* Add SWC transpilation rules limited to specific deps (monorepo-friendly).
* @param deps - Package names to include in SWC loader
* @param options - Optional configuration options
* @returns A config object with module rules configuration
*/
compileWithRspack: (deps: RuleSetConditions, options?: SwcLoaderOptions) => Record<string, object>;
/**
* Enable or disable Rspack cache config.
* @param enabled - Whether to enable caching
* @param cacheConfig - Optional cache configuration
* @returns A config object with cache configuration
*/
setCache: (enabled: boolean | 'memory') => Record<string, object>;
/**
* Enable Rspack split vendor chunk.
* @returns A config object with optimization configuration
*/
splitVendorChunk: () => Record<string, object>;
/**
* Extend Rspack SWC loader config.
* @returns A config object with SWC loader config
*/
extendSwcConfig: (swcConfig: SwcLoaderOptions) => Record<string, object>;
/**
* Extend Rspack configs.
* @returns A config object with merged configs
*/
extendConfig: (...configs: Record<string, object>[]) => Record<string, object>;
/**
* Remove plugins from a Rspack config by name, RegExp, predicate, or array of them.
* @param matchers - String, RegExp, function, or array of them to match plugin names
* @returns The modified config object
*/
disablePlugins: (
matchers: string | RegExp | ((plugin: any, index: number) => boolean) | Array<string | RegExp | ((plugin: any, index: number) => boolean)>
) => Record<string, any>;
}
export type ConfigFactory = (
env: MeteorEnv,
argv: Record<string, any>
) => MeteorRspackConfig;
export function defineConfig(
factory: ConfigFactory
): ReturnType<typeof _rspackDefineConfig>;
/**
* A plugin that composes the original HtmlRspackPlugin from @rspack/core
* and RspackMeteorHtmlPlugin, in that order.
*/
export class HtmlRspackPlugin {
constructor(options?: HtmlRspackPluginOptions);
apply(compiler: any): void;
}
// Re-export HtmlRspackPluginOptions from @rspack/cli
export { HtmlRspackPluginOptions };

View File

@@ -0,0 +1,28 @@
const { defineConfig: rspackDefineConfig } = require('@rspack/cli');
const HtmlRspackPlugin = require('./plugins/HtmlRspackPlugin.js');
/**
* @typedef {import('rspack').Configuration & {
* meteor?: { packageNamespace?: string }
* }} MeteorRspackConfig
*/
/**
* @typedef {(env: Record<string, any>, argv: Record<string, any>) => MeteorRspackConfig} ConfigFactory
*/
/**
* Wrap rspack.defineConfig but only accept a factory function.
* @param {ConfigFactory} factory
* @returns {ReturnType<typeof rspackDefineConfig>}
*/
function defineConfig(factory) {
return rspackDefineConfig(factory);
}
// Export our helper plus passthrough as default export
module.exports = defineConfig;
// Export the HtmlRspackPlugin and defineConfig as named exports
module.exports.defineConfig = defineConfig;
module.exports.HtmlRspackPlugin = HtmlRspackPlugin;

View File

@@ -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,
};

View File

@@ -0,0 +1,325 @@
/**
* Utilities for merging webpack/rspack configurations with special handling for
* overlapping file extensions in module rules.
*/
const { mergeWithCustomize } = require('webpack-merge');
const isEqual = require('fast-deep-equal');
/**
* File extensions to check when determining rule overlaps.
*/
const EXT_CATALOG = [
'.tsx', '.ts', '.mts', '.cts',
'.jsx', '.js', '.mjs', '.cjs',
];
/**
* Converts rule.test to predicate functions.
* @param {Object} rule - Rule object
* @returns {Function[]} Predicate functions
*/
function testsFrom(rule) {
const t = rule.test;
if (!t) return [() => true]; // no test means match all; you can tighten if you want
const arr = Array.isArray(t) ? t : [t];
return arr.map(el => {
if (el instanceof RegExp) return (s) => el.test(s);
if (typeof el === 'function') return el;
if (typeof el === 'string') {
// Webpack allows string match; treat as substring
return (s) => s.includes(el);
}
return () => false;
});
}
/**
* Checks if rule matches a file extension.
* @param {Object} rule - Rule object
* @param {string} ext - File extension
* @returns {boolean} True if matches
*/
function ruleMatchesExt(rule, ext) {
// simulate a filename to test against
const filename = `x${ext}`;
const preds = testsFrom(rule);
return preds.some(fn => {
try { return !!fn(filename); } catch { return false; }
});
}
/**
* Creates regex for matching file extensions.
* @param {string[]} exts - File extensions
* @returns {RegExp} Regex like /\.(js|jsx)$/
*/
function regexFromExts(exts) {
const body = exts.map(e => e.replace(/^\./, '')).join('|');
return new RegExp(`\\.(${body})$`, 'i');
}
/**
* Clones rule with new test property.
* @param {Object} rule - Rule to clone
* @param {RegExp|Function|string} newTest - New test value
* @returns {Object} Cloned rule
*/
function cloneWithTest(rule, newTest) {
return { ...rule, test: newTest };
}
/**
* Merges rules with special handling for overlapping extensions.
* - Replaces overlapping parts with B rules
* - Preserves non-overlapping parts from A rules
*
* @param {Array} aRules - Base rules
* @param {Array} bRules - Rules to merge in
* @returns {Array} Merged rules
*/
function splitOverlapRulesMerge(aRules, bRules) {
const result = [...aRules];
for (const bRule of bRules) {
// Try to find an A rule that overlaps B by extensions
let replaced = false;
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));
if (aExts.length === 0 || bExts.length === 0) {
continue; // nothing meaningful to compare in our catalog
}
const overlap = aExts.filter(e => bExts.includes(e));
if (overlap.length === 0) continue;
// 1) Replace the overlapping A rule with B
result[i] = bRule;
// 2) Add a "residual" A rule for the non-overlapping extensions
const residual = aExts.filter(e => !overlap.includes(e));
if (residual.length > 0) {
const residualRule = cloneWithTest(aRule, regexFromExts(residual));
result.splice(i, 0, residualRule); // keep residual before B, or after—your choice
i++; // skip over the newly inserted residual
}
replaced = true;
break;
}
// If we didnt overlap with any A rule, just add B
if (!replaced) {
result.push(bRule);
}
}
return result;
}
/**
* Creates a customizer function for unique plugins.
*
* @param {string} key - The key to check for uniqueness
* @param {string[]} pluginNames - Array of plugin constructor names to make unique
* @param {Function} getter - Function to get the identifier from the plugin
* @returns {Function} Customizer function
*/
function unique(key, pluginNames = [], getter = item => item.constructor && item.constructor.name) {
return (a, b, k) => {
if (k !== key) return undefined;
const aItems = Array.isArray(a) ? a : [];
const bItems = Array.isArray(b) ? b : [];
// If not dealing with plugins, return undefined to use default merging
if (key !== 'plugins') return undefined;
// Create a map to track plugins by their identifier
const uniquePlugins = new Map();
// Process all plugins from both arrays
[...aItems, ...bItems].forEach(plugin => {
const id = getter(plugin);
// If this is a plugin we want to make unique and we can identify it
if (id && pluginNames.includes(id)) {
uniquePlugins.set(id, plugin); // Keep only the last instance
}
});
// Create the result array with all non-unique plugins from a
const result = aItems.filter(plugin => {
const id = getter(plugin);
return !id || !pluginNames.includes(id) || uniquePlugins.get(id) === plugin;
});
// Add unique plugins from b that weren't already in the result
bItems.forEach(plugin => {
const id = getter(plugin);
if (!id || !pluginNames.includes(id)) {
result.push(plugin);
} else if (uniquePlugins.get(id) === plugin) {
result.push(plugin);
}
});
return result;
};
}
/**
* Helper function to clean fields in an object based on omit paths.
* Supports nested path strings like 'output.filename'.
*
* @param {Object} obj - The object to clean
* @param {Object} options - Configuration options
* @param {string[]} [options.omitPaths] - Paths to omit from the object (e.g., 'output.filename')
* @param {Function} [options.warningFn] - Custom warning function that receives the path string
* @returns {Object} The cleaned object with specified paths removed
*/
function cleanOmittedPaths(obj, options = {}) {
if (!obj || typeof obj !== 'object') {
return obj;
}
const { omitPaths = [], warningFn } = options;
// If no omit paths, return the original object
if (!omitPaths.length) {
return obj;
}
const result = { ...obj };
// Process each omit path
omitPaths.forEach(path => {
// Convert path to array of keys
const pathArray = Array.isArray(path) ? path : path.split('.');
const pathString = Array.isArray(path) ? path.join('.') : path;
// Start with the root object
let current = result;
let parent = null;
let lastKey = null;
// Traverse the path to find the target property
for (let i = 0; i < pathArray.length - 1; i++) {
const key = pathArray[i];
if (current && typeof current === 'object' && key in current) {
parent = current;
lastKey = key;
current = current[key];
} else {
// Path doesn't exist in the object, nothing to remove
return;
}
}
// Get the final key in the path
const finalKey = pathArray[pathArray.length - 1];
// Handle single-level paths (from root)
if (pathArray.length === 1) {
const rootKey = pathArray[0];
if (rootKey in result) {
// Log warning
if (typeof warningFn === 'function') {
warningFn(pathString);
}
delete result[rootKey];
}
return;
}
// If we found the property for nested paths, remove it
if (parent && lastKey && finalKey) {
if (current && typeof current === 'object' && finalKey in current) {
// Log warning
if (typeof warningFn === 'function') {
warningFn(pathString);
}
delete current[finalKey];
}
}
});
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
*/
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') {
const aRules = Array.isArray(a) ? a : [];
const bRules = Array.isArray(b) ? b : [];
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(
'plugins',
['HtmlRspackPlugin', 'RsdoctorRspackPlugin'],
(plugin) => plugin.constructor && plugin.constructor.name
)(a, b, key);
}
// fall through to default merging
return undefined;
}
})(...normalizedConfigs);
}
module.exports = {
EXT_CATALOG,
unique,
cleanOmittedPaths,
mergeSplitOverlap
};

View File

@@ -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<string, object>}
*/
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}<n>"`);
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}<n>` 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,
};

View File

@@ -0,0 +1,213 @@
const path = require("path");
const { prepareMeteorRspackConfig } = require("./meteorRspackConfigFactory");
const { builtinModules } = require("module");
/**
* Wrap externals for Meteor runtime (marks deps as externals).
* Usage: compileWithMeteor(["sharp", "vimeo", "fs"])
*
* @param {string[]} deps - package names or module IDs
* @returns {Record<string, object>} `{ 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<string, object>} `{ meteorRspackConfigX: { module: { rules: [...] } } }`
*/
function compileWithRspack(deps, { options = {} } = {}) {
const includeDirs = deps.flat().filter(Boolean)
.map(pkg => typeof pkg === 'string' && !pkg.includes('node_modules')
? path.join(process.cwd(), 'node_modules', pkg)
: pkg
);
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<string, object>} cacheConfig
* @returns {Record<string, object>} `{ 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 = []) {
// Node core list, normalized (strip `node:` prefix)
const core = new Set(builtinModules.map((m) => m.replace(/^node:/, "")));
// browser-safe allowlist (these we *don't* mark as false)
const allowlist = new Set([
"process",
"util",
"events",
"path",
"stream",
"assert",
"assert/strict",
]);
const names = new Set();
for (const m of core) {
// Add both 'fs' and 'node:fs' variants
names.add(m);
names.add(`node:${m}`);
}
for (const x of extras) names.add(x);
// ❌ Everything except the allowlist gets mapped to false
const entries = [...names]
.filter((m) => !allowlist.has(m.replace(/^node:/, "")))
.map((m) => [m, false]);
return Object.fromEntries(entries);
}
/**
* Enable Rspack split vendor chunk config
* Usage: splitVendorChunk()
*
* @returns {Record<string, object>} `{ meteorRspackConfigX: { optimization: { ... } } }`
*/
function splitVendorChunk() {
return prepareMeteorRspackConfig({
optimization: {
splitChunks: {
chunks: "all", // split both sync and async imports
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
enforce: true,
priority: 10,
chunks: "all",
},
},
},
},
});
}
/**
* Extend SWC loader config
* Usage: extendSwcConfig()
*
* @returns {Record<string, object>} `{ meteorRspackConfigX: { optimization: { ... } } }`
*/
function extendSwcConfig(swcConfig) {
return prepareMeteorRspackConfig({
module: {
rules: [
{
test: /\.(?:[mc]?js|jsx|[mc]?ts|tsx)$/i,
exclude: /node_modules|\.meteor\/local/,
loader: 'builtin:swc-loader',
options: swcConfig,
},
],
},
});
}
/**
* Remove plugins from a Rspack config by name, RegExp, predicate, or array of them.
* When using a function predicate, it receives both the plugin and its index in the plugins array.
*
* @param {object} config Rspack config object
* @param {string | RegExp | ((plugin: any, index: number) => boolean) | Array<string|RegExp|Function>} matchers
* @returns {object} The modified config object
*/
function disablePlugins(config, matchers) {
if (!config || typeof config !== "object") {
throw new TypeError("disablePlugins: `config` must be an object");
}
const plugins = Array.isArray(config.plugins) ? config.plugins : [];
const kept = [];
const list = Array.isArray(matchers) ? matchers : [matchers];
const getPluginName = (p) => {
if (!p) return "";
return (
(p.constructor && typeof p.constructor.name === "string" && p.constructor.name) ||
(typeof p.name === "string" && p.name) ||
(typeof p.pluginName === "string" && p.pluginName) ||
(typeof p.__pluginName === "string" && p.__pluginName) ||
""
);
};
const predicates = list.map((m) => {
if (typeof m === "function") return m;
if (m instanceof RegExp) {
return (p) => m.test(getPluginName(p));
}
if (typeof m === "string") {
return (p) => getPluginName(p) === m;
}
throw new TypeError(
"disablePlugins: matchers must be string, RegExp, function, or array of them"
);
});
config.plugins = plugins.filter((p, index) => {
const matches = predicates.some(fn => fn(p, index));
return !matches;
});
return config;
}
module.exports = {
compileWithMeteor,
compileWithRspack,
setCache,
splitVendorChunk,
extendSwcConfig,
makeWebNodeBuiltinsAlias,
disablePlugins,
};

View File

@@ -0,0 +1,67 @@
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
*/
function getMeteorAppSwcrc(file = '.swcrc') {
try {
const filePath = `${process.cwd()}/${file}`;
if (file.endsWith('.js')) {
let content = fs.readFileSync(filePath, 'utf-8');
// Check if the content uses ES module syntax (export default)
if (content.includes('export default')) {
// Transform ES module syntax to CommonJS
content = content.replace(/export\s+default\s+/, 'module.exports = ');
}
const script = new vm.Script(`
(function() {
const module = {};
module.exports = {};
(function(exports, module) {
${content}
})(module.exports, module);
return module.exports;
})()
`);
const context = vm.createContext({ process });
return script.runInContext(context);
} else {
// For .swcrc and other JSON files, parse as JSON
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
} catch (e) {
return undefined;
}
}
/**
* Checks for SWC configuration files and returns the configuration.
* 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
*/
function getMeteorAppSwcConfig() {
const hasSwcRc = fs.existsSync(`${process.cwd()}/.swcrc`);
const hasSwcJs = !hasSwcRc && fs.existsSync(`${process.cwd()}/swc.config.js`);
if (!hasSwcRc && !hasSwcJs) {
return undefined;
}
const swcFile = hasSwcJs ? 'swc.config.js' : '.swcrc';
const config = getMeteorAppSwcrc(swcFile);
// Set baseUrl to process.cwd() if it exists
if (config?.jsc && config.jsc.baseUrl) {
config.jsc.baseUrl = process.cwd();
}
return config;
}
module.exports = {
getMeteorAppSwcrc,
getMeteorAppSwcConfig
};

View File

@@ -0,0 +1,80 @@
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.ignoreEntries - Array of ignore patterns
* @param {string} options.extraEntry - Extra entry to load
* @returns {string} The path to the generated file
*/
const generateEagerTestFile = ({
isAppTest,
projectDir,
buildContext,
ignoreEntries: inIgnoreEntries = [],
prefix: inPrefix = '',
extraEntry,
}) => {
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}/**`,
...inIgnoreEntries,
];
// Create regex from ignore entries
const excludeFoldersRegex = createIgnoreRegex(
createIgnoreGlobConfig(ignoreEntries)
);
const prefix = (inPrefix && `${inPrefix}-`) || "";
const filename = isAppTest
? `${prefix}eager-app-tests.mjs`
: `${prefix}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);
${
extraEntry
? `const extra = import.meta.webpackContext('${path.dirname(
extraEntry
)}', {
recursive: false,
regExp: ${new RegExp(`${path.basename(extraEntry)}$`).toString()},
mode: 'eager',
});
extra.keys().forEach(extra);`
: ''
}
}`;
fs.writeFileSync(filePath, content);
return filePath;
};
module.exports = {
generateEagerTestFile,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
{
"name": "@meteorjs/rspack",
"version": "1.0.0",
"description": "Configuration logic for using Rspack in Meteor projects",
"main": "index.js",
"type": "commonjs",
"author": "",
"license": "ISC",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"ignore-loader": "^0.1.2",
"node-polyfill-webpack-plugin": "^4.1.0",
"webpack-merge": "^6.0.1"
},
"peerDependencies": {
"@rspack/cli": ">=1.3.0",
"@rspack/core": ">=1.3.0"
}
}

View File

@@ -0,0 +1,40 @@
// AssetExternalsPlugin.js
//
// This plugin externalizes assets within CSS/SCSS and other files.
// It prevents Rspack from bundling assets referenced in CSS url() and similar contexts,
// allowing them to be served directly from the public directory.
// Regular expression to match CSS, SCSS, and other style files
const CSS_EXT_REGEX = /\.(css|scss|sass|less|styl)$/;
class AssetExternalsPlugin {
constructor(options = {}) {
this.pluginName = 'AssetExternalsPlugin';
this.options = options;
}
apply(compiler) {
// Add the externals function to handle asset URLs in CSS files
compiler.options.externals = [
...compiler.options.externals || [],
(data, callback) => {
const req = data.request;
// Webpack provides dependencyType === "url" for CSS url() deps.
// Rspack is webpack-compatible here, but keep this tolerant.
const isUrlDep = data.dependencyType === 'url';
const issuer = data.contextInfo?.issuer || '';
const fromCss = CSS_EXT_REGEX.test(issuer);
if (req && req.startsWith('/') && (isUrlDep || fromCss)) {
// Keep the URL as-is (served by your server from /public)
return callback(null, `asset ${req}`);
}
callback();
}
];
}
}
module.exports = { AssetExternalsPlugin };

View File

@@ -0,0 +1,31 @@
const RspackMeteorHtmlPlugin = require('./RspackMeteorHtmlPlugin.js');
const { loadHtmlRspackPluginFromHost } = RspackMeteorHtmlPlugin;
/**
* A plugin that composes the original HtmlRspackPlugin from @rspack/core
* and RspackMeteorHtmlPlugin, in that order.
*/
class HtmlRspackPlugin {
constructor(options = {}) {
this.options = options;
}
apply(compiler) {
// Load the original HtmlRspackPlugin from the host project
const OriginalHtmlRspackPlugin = loadHtmlRspackPluginFromHost(compiler);
if (!OriginalHtmlRspackPlugin) {
throw new Error('Could not load HtmlRspackPlugin from host project.');
}
// Apply the original HtmlRspackPlugin
const originalPlugin = new OriginalHtmlRspackPlugin(this.options);
originalPlugin.apply(compiler);
// Apply the RspackMeteorHtmlPlugin
const meteorPlugin = new RspackMeteorHtmlPlugin();
meteorPlugin.apply(compiler);
}
}
module.exports = HtmlRspackPlugin;

View File

@@ -0,0 +1,496 @@
// RequireExternalsPlugin.js
//
// This plugin prepare the require of externals used to be lazy required by Meteor bundler.
//
// It can describe additional externals using the externals option by array, RegExp or function.
// These externals will be lazy required as well, and optionally could be resolved using
// the externalMap function if provided.
// Used for Blaze to translate require of html files to require of js files bundled by Meteor.
const fs = require('fs');
const path = require('path');
class RequireExternalsPlugin {
constructor({
filePath,
// Externals can be:
// - An array of strings: module name must be included in the array
// - A RegExp: module name must match the regex
// - A function: function(name) must return true for the module name
externals = null,
// ExternalMap is a function that receives the request object and returns the external request path
// It can be used to customize how external modules are mapped to file paths
// If not provided, the default behavior is to map the external module name.
externalMap = null,
// Enable global polyfill for module and exports
// If true, globalThis.module and globalThis.exports will be defined if they don't exist
enableGlobalPolyfill = true,
// Check function to determine if an external import should be eager
// If provided, it will be called with the package name and should return true for eager imports
// If not provided or returns false, the import will be lazy (default behavior)
isEagerImport = null,
// Array of module paths that should always be imported at the end of the file
// These will be treated as eager imports but will always be placed after all other imports
lastImports = null,
} = {}) {
this.pluginName = 'RequireExternalsPlugin';
// Prepare externals
this._externals = externals;
this._externalMap = externalMap;
this._enableGlobalPolyfill = enableGlobalPolyfill;
this._isEagerImport = isEagerImport;
this._lastImports = lastImports;
this._defaultExternalPrefix = 'external ';
// Prepare paths
this.filePath = path.resolve(process.cwd(), filePath);
this.backRoot = '../'.repeat(
filePath.replace(/^\.?\/+/, '').split('/').length - 1
);
// Initialize funcCount based on existing helpers in the file
this._funcCount = this._computeNextFuncCount();
}
// Helper method to check if a module name matches the externals or default prefix
_isExternalModule(name) {
if (typeof name !== 'string') return false;
// Check externals if provided
if (this._externals) {
// If externals is an array, use includes method
if (Array.isArray(this._externals)) {
if (this._externals.includes(name)) {
return { isExternal: true, type: 'externals', value: name };
}
}
// If externals is a RegExp, use test method
else if (this._externals instanceof RegExp) {
if (this._externals.test(name)) {
return { isExternal: true, type: 'externals', value: name };
}
}
// If externals is a function, call it with the name
else if (typeof this._externals === 'function') {
if (this._externals(name)) {
return { isExternal: true, type: 'externals', value: name };
}
}
}
if (name.startsWith(this._defaultExternalPrefix)) {
return { isExternal: true, type: 'prefix', value: name };
}
return { isExternal: false };
}
// Helper method to extract package name from module name
_extractPackageName(name) {
let pkg = name.slice(this._defaultExternalPrefix.length);
if (pkg.startsWith('"') && pkg.endsWith('"')) pkg = pkg.slice(1, -1);
const depInfo = path.parse(name);
// If the extracted package name is a path, use the path as is
if (
pkg &&
(path.isAbsolute(pkg) ||
pkg.startsWith('./') ||
pkg.startsWith('../') ||
!!depInfo.ext)
) {
const module = this.externalsMeta.get(pkg);
if (module) {
return `${this.backRoot}${module.relativeRequest}`;
}
return `${this.backRoot}${name}`;
}
return pkg;
}
apply(compiler) {
// Initialize externalsMeta if it doesn't exist
this.externalsMeta = this.externalsMeta || new Map();
// Only set compiler.options.externals if both externals and externalMap are defined
if (this._externals && this._externalMap) {
compiler.options.externals = [
...compiler.options.externals || [],
(module, callback) => {
const { request, context } = module;
const matchInfo = this._isExternalModule(request);
if (matchInfo.isExternal) {
let externalRequest;
// Use externalMap function if provided
if (this._externalMap && typeof this._externalMap === 'function') {
externalRequest = this._externalMap(module);
const relContext = path.relative(process.cwd(), context);
// Store the original request to resolve properly the lazy html require later
this.externalsMeta.set(externalRequest, {
originalRequest: request,
externalRequest,
relativeRequest: path.join(relContext, request),
});
// tell Rspack "don't bundle this, import it at runtime"
return callback(null, externalRequest);
}
}
callback(); // otherwise normal resolution
}
];
}
compiler.hooks.done.tap({ name: this.pluginName, stage: -10 }, (stats) => {
// 1) Ensure globalThis.module / exports block is present if enabled
if (this._enableGlobalPolyfill) {
this._ensureGlobalThisModule();
}
// 2) Re-load existing requires from disk on every run
const existing = this._readExistingRequires();
// 2a) Compute the *current* externals in this build
const info = stats.toJson({ modules: true });
const current = new Set();
for (const m of info.modules) {
const matchInfo = this._isExternalModule(m.name);
if (matchInfo.isExternal) {
const pkg = this._extractPackageName(m.name, matchInfo);
if (pkg) {
current.add(pkg);
}
}
}
// 2b) Remove any requires that are no longer in `current`
const toRemove = [...existing].filter(p => !current.has(p));
if (toRemove.length) {
let content = fs.readFileSync(this.filePath, 'utf-8');
// Strip stale require(...) lines
for (const pkg of toRemove) {
const re = new RegExp(`^.*require\\('${pkg}'\\);?.*(\\r?\\n)?`, 'gm');
content = content.replace(re, '');
}
// Strip out any now-empty helper functions:
// function lazyExternalImportsX() {
// }
// or new format:
// // (function eagerExternalImportsX() {
// // })
// or lastImports format:
// // (function lastImports() {
// // })
const emptyLazyFnRe = /^function\s+lazyExternalImports\d+\s*\(\)\s*{\s*}\s*(\r?\n)?/gm;
const emptyEagerFnRe = /^\/\/\s*\(function\s+eagerExternalImports\d+\s*\(\)\s*{\s*\n\/\/\s*\}\)\s*(\r?\n)?/gm;
const emptyLastFnRe = /^\/\/\s*\(function\s+lastImports(?:\d+)?\s*\(\)\s*{\s*\n\/\/\s*\}\)\s*(\r?\n)?/gm;
content = content.replace(emptyLazyFnRe, '');
content = content.replace(emptyEagerFnRe, '');
content = content.replace(emptyLastFnRe, '');
// Write the cleaned file back
fs.writeFileSync(this.filePath, content, 'utf-8');
// Re-populate `existing` so the add-diff is accurate
existing.clear();
// Check for require statements
for (const match of content.matchAll(/require\('([^']+)'\)/g)) {
existing.add(match[1]);
}
// Also check for import statements (used in the new format)
for (const match of content.matchAll(/import\s+'([^']+)'/g)) {
existing.add(match[1]);
}
}
// 3) Collect any new externals from this build and separate into eager, lazy, and last
const newLazyRequires = [];
const newEagerRequires = [];
const newLastRequires = [];
for (const module of info.modules) {
const name = module.name;
const matchInfo = this._isExternalModule(name);
if (!matchInfo.isExternal) continue;
const pkg = this._extractPackageName(name, matchInfo);
if (pkg && !existing.has(pkg)) {
existing.add(pkg);
// Check if this should be a last import
if (this._lastImports && Array.isArray(this._lastImports) && this._lastImports.includes(pkg)) {
newLastRequires.push(`require('${pkg}')`);
}
// Check if this should be an eager import
else if (this._isEagerImport && typeof this._isEagerImport === 'function' && this._isEagerImport(pkg)) {
newEagerRequires.push(`require('${pkg}')`);
} else {
// Default to lazy import
newLazyRequires.push(`require('${pkg}')`);
}
}
}
// 4) Append new lazy imports if any
if (newLazyRequires.length) {
const fnName = `lazyExternalImports${this._funcCount++}`;
const body = newLazyRequires.map(req => ` ${req};`).join('\n');
const fnCode = `\nfunction ${fnName}() {\n${body}\n}\n`;
try {
fs.appendFileSync(this.filePath, fnCode);
} catch (err) {
console.error(`Failed to append lazy imports to ${this.filePath}:`, err);
}
}
// 5) Append new eager imports if any
if (newEagerRequires.length) {
const fnName = `eagerExternalImports${this._funcCount++}`;
// Convert require statements to import statements
const body = newEagerRequires
.map(req => {
// Extract the module path from require('path')
const modulePath = req.match(/require\('([^']+)'\)/)[1];
return `import '${modulePath}';`;
})
.join('\n');
// Use comments instead of actual function
const fnCode = `\n// (function ${fnName}() {\n${body}\n// })\n`;
try {
fs.appendFileSync(this.filePath, fnCode);
} catch (err) {
console.error(`Failed to append eager imports to ${this.filePath}:`, err);
}
}
// 6) Handle lastImports - these should always be at the end of the file
// First, check if lastImports already exist in the file
let lastImportsExist = false;
let lastImportsAtEnd = false;
let content = '';
if (fs.existsSync(this.filePath)) {
content = fs.readFileSync(this.filePath, 'utf-8');
// Check if lastImports exist in the file
const lastImportsRe = /\/\/\s*\(function\s+lastImports(?:\d+)?\s*\(\)\s*{\s*\n([\s\S]*?)\/\/\s*\}\)/g;
const match = lastImportsRe.exec(content);
if (match) {
lastImportsExist = true;
// Check if lastImports are at the end of the file
// We'll consider them at the end if there's only whitespace after them
const afterLastImports = content.substring(match.index + match[0].length);
if (/^\s*$/.test(afterLastImports)) {
lastImportsAtEnd = true;
}
}
}
// If lastImports exist but are not at the end, move them to the end
if (lastImportsExist && !lastImportsAtEnd) {
// Remove the existing lastImports
const lastImportsRe = /\/\/\s*\(function\s+lastImports(?:\d+)?\s*\(\)\s*{\s*\n[\s\S]*?\/\/\s*\}\)\s*(\r?\n)?/g;
content = content.replace(lastImportsRe, '');
// Extract the imports from the existing lastImports
const importRe = /import\s+'([^']+)'/g;
const existingLastImports = [];
let match;
while ((match = importRe.exec(content)) !== null) {
if (this._lastImports && Array.isArray(this._lastImports) && this._lastImports.includes(match[1])) {
existingLastImports.push(`import '${match[1]}';`);
}
}
// Add any new lastImports
if (this._lastImports && Array.isArray(this._lastImports)) {
for (const pkg of this._lastImports) {
if (!existingLastImports.some(imp => imp === `import '${pkg}';`) && existing.has(pkg)) {
existingLastImports.push(`import '${pkg}';`);
}
}
}
// Add the lastImports to the end of the file
if (existingLastImports.length > 0) {
const body = existingLastImports.join('\n');
const fnCode = `\n// (function lastImports() {\n${body}\n// })\n`;
fs.writeFileSync(this.filePath, content + fnCode);
} else {
fs.writeFileSync(this.filePath, content);
}
}
// If lastImports don't exist, add them if needed
else if (!lastImportsExist) {
// Collect all lastImports
const allLastImports = [];
// Add any new lastImports from this build
if (newLastRequires.length) {
for (const req of newLastRequires) {
const modulePath = req.match(/require\('([^']+)'\)/)[1];
allLastImports.push(`import '${modulePath}';`);
}
}
// Add any existing lastImports from the configuration
if (this._lastImports && Array.isArray(this._lastImports)) {
for (const pkg of this._lastImports) {
if (!allLastImports.some(imp => imp === `import '${pkg}';`) && !existing.has(pkg)) {
allLastImports.push(`import '${pkg}';`);
}
}
}
// Add the lastImports to the end of the file
if (allLastImports.length > 0) {
const body = allLastImports.join('\n');
const fnCode = `\n// (function lastImports() {\n${body}\n// })\n`;
try {
fs.appendFileSync(this.filePath, fnCode);
} catch (err) {
console.error(`Failed to append last imports to ${this.filePath}:`, err);
}
}
}
// If lastImports exist and are already at the end, add any new ones
else if (lastImportsExist && lastImportsAtEnd && newLastRequires.length) {
// Extract the existing lastImports
const lastImportsRe = /\/\/\s*\(function\s+lastImports(?:\d+)?\s*\(\)\s*{\s*\n([\s\S]*?)\/\/\s*\}\)/;
const match = lastImportsRe.exec(content);
if (match) {
const existingBody = match[1];
const existingImports = new Set();
// Extract the imports from the existing lastImports
const importRe = /import\s+'([^']+)'/g;
let importMatch;
while ((importMatch = importRe.exec(existingBody)) !== null) {
existingImports.add(importMatch[1]);
}
// Add any new lastImports
let newBody = existingBody;
for (const req of newLastRequires) {
const modulePath = req.match(/require\('([^']+)'\)/)[1];
if (!existingImports.has(modulePath)) {
newBody += `import '${modulePath}';\n`;
}
}
// Replace the existing lastImports with the updated ones
const updatedContent = content.replace(
lastImportsRe,
`// (function lastImports() {\n${newBody}// })`
);
fs.writeFileSync(this.filePath, updatedContent);
}
}
});
}
_computeNextFuncCount() {
let max = 0;
if (fs.existsSync(this.filePath)) {
try {
const content = fs.readFileSync(this.filePath, 'utf-8');
// Check for lazy, eager, and last external imports functions
const lazyFnRe = /function\s+lazyExternalImports(\d+)\s*\(\)/g;
// Only match the new commented format
const eagerFnRe = /\/\/\s*\(function\s+eagerExternalImports(\d+)\s*\(\)/g;
// Match the lastImports format
const lastFnRe = /\/\/\s*\(function\s+lastImports(\d+)?\s*\(\)/g;
let match;
// Check lazy imports
while ((match = lazyFnRe.exec(content)) !== null) {
const n = parseInt(match[1], 10);
if (n > max) max = n;
}
// Check eager imports
while ((match = eagerFnRe.exec(content)) !== null) {
const n = parseInt(match[1], 10);
if (n > max) max = n;
}
// Check last imports
while ((match = lastFnRe.exec(content)) !== null) {
if (match[1]) {
const n = parseInt(match[1], 10);
if (n > max) max = n;
}
}
} catch {
// ignore read errors
}
}
// next count is max found plus one
return max + 1;
}
_ensureGlobalThisModule() {
const block = [
`/* Polyfill globalThis.module, exports & module for legacy */`,
`if (typeof globalThis !== 'undefined') {`,
` if (typeof globalThis.module === 'undefined') {`,
` globalThis.module = { exports: {} };`,
` }`,
` if (typeof globalThis.exports === 'undefined') {`,
` globalThis.exports = globalThis.module.exports;`,
` }`,
`}`,
`if (typeof window.module === 'undefined') {`,
` window.module = { exports: {} };`,
`}`,
].join('\n') + '\n';
let content = '';
if (fs.existsSync(this.filePath)) {
content = fs.readFileSync(this.filePath, 'utf-8');
if (!content.includes(`typeof globalThis.module === 'undefined'`)) {
// Prepend so it lives at the very top
fs.writeFileSync(this.filePath, content + '\n' + block, 'utf-8');
}
} else {
// File doesnt exist yet: create with just the block
fs.writeFileSync(this.filePath, block, 'utf-8');
}
}
_readExistingRequires() {
const existing = new Set();
try {
const content = fs.readFileSync(this.filePath, 'utf-8');
// Check for require statements
const requireRegex = /require\('([^']+)'\)/g;
let match;
while ((match = requireRegex.exec(content)) !== null) {
existing.add(match[1]);
}
// Also check for import statements (used in the new format)
const importRegex = /import\s+'([^']+)'/g;
while ((match = importRegex.exec(content)) !== null) {
existing.add(match[1]);
}
} catch {
// ignore if file missing or unreadable
}
return existing;
}
}
module.exports = { RequireExternalsPlugin };

View File

@@ -0,0 +1,50 @@
const path = require('node:path');
const { createRequire } = require('node:module');
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'));
const core = requireFromHost('@rspack/core'); // host's instance
// Rspack exports can be shaped a couple ways; be defensive
return core.HtmlRspackPlugin || core.rspack?.HtmlRspackPlugin || core.default?.HtmlRspackPlugin;
}
/**
* Rspack plugin to:
* 1. Remove the injected `*-rspack.js` script tags
* 2. Strip <!doctype> and <html>…</html> wrappers from the final HTML
*/
class RspackMeteorHtmlPlugin {
apply(compiler) {
const HtmlRspackPlugin = loadHtmlRspackPluginFromHost(compiler);
if (!HtmlRspackPlugin?.getCompilationHooks) {
throw new Error('Could not load HtmlRspackPlugin from host project.');
}
compiler.hooks.compilation.tap('RspackMeteorHtmlPlugin', compilation => {
const hooks = HtmlRspackPlugin.getCompilationHooks(compilation);
// remove <script src="...*-rspack.js">
hooks.alterAssetTags.tap('RspackMeteorHtmlPlugin', data => {
data.assetTags.scripts = data.assetTags.scripts.filter(t => {
const src = t.attributes?.src || t.asset || '';
return !(t.tagName === 'script' && /(?:^|\/)[^\/]*-rspack\.js$/i.test(src));
});
});
// unwrap <!doctype> and <html>…</html>
hooks.beforeEmit.tap('RspackMeteorHtmlPlugin', data => {
data.html = data.html
.replace(/<!doctype[^>]*>\s*/i, '')
.replace(/<html[^>]*>\s*/i, '')
.replace(/\s*<\/html>\s*$/i, '')
.trim();
});
});
}
}
module.exports = RspackMeteorHtmlPlugin;
module.exports.loadHtmlRspackPluginFromHost = loadHtmlRspackPluginFromHost;

View File

@@ -0,0 +1,811 @@
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');
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
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 { AssetExternalsPlugin } = require('./plugins/AssetExternalsPlugin.js');
const { generateEagerTestFile } = require("./lib/test.js");
const { getMeteorIgnoreEntries, createIgnoreGlobConfig } = require("./lib/ignore");
const { mergeMeteorRspackFragments } = require("./lib/meteorRspackConfigFactory.js");
const {
compileWithMeteor,
compileWithRspack,
setCache,
splitVendorChunk,
extendSwcConfig,
makeWebNodeBuiltinsAlias,
disablePlugins,
} = require('./lib/meteorRspackHelpers.js');
const { prepareMeteorRspackConfig } = require("./lib/meteorRspackConfigFactory");
// Safe require that doesn't throw if the module isn't found
function safeRequire(moduleName) {
try {
return require(moduleName);
} catch (error) {
if (
error.code === 'MODULE_NOT_FOUND' &&
error.message.includes(moduleName)
) {
return null;
}
throw error; // rethrow if it's a different error
}
}
// Persistent filesystem cache strategy
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: `cache-${mode}${(side && `-${side}`) || ""}`,
type: "persistent",
storage: {
type: "filesystem",
directory: `node_modules/.cache/rspack${(side && `/${side}`) || ""}`,
},
...(buildDependencies.length > 0 && {
buildDependencies: buildDependencies,
})
},
},
};
}
// SWC loader rule (JSX/JS)
function createSwcConfig({
isTypescriptEnabled,
isReactEnabled,
isJsxEnabled,
isTsxEnabled,
externalHelpers,
isDevEnvironment,
isClient,
isAngularEnabled,
}) {
const defaultConfig = {
jsc: {
baseUrl: process.cwd(),
paths: { '/*': ['*', '/*'] },
parser: {
syntax: isTypescriptEnabled ? 'typescript' : 'ecmascript',
...(isTsxEnabled && { tsx: true }),
...(isJsxEnabled && { jsx: true }),
...(isAngularEnabled && { decorators: true }),
},
target: 'es2015',
...(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 cleanedCustomConfig = cleanOmittedPaths(customConfig, { omitPaths, warningFn });
const swcConfig = merge(defaultConfig, cleanedCustomConfig);
return {
test: /\.(?:[mc]?js|jsx|[mc]?ts|tsx)$/i,
exclude: /node_modules|\.meteor\/local/,
loader: "builtin:swc-loader",
options: swcConfig,
};
}
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() {
return (p) => {
const normalized = '/' + path.normalize(p).replaceAll(path.sep, '/').replace(/^\/+/, '');
const isInBuildRoot = /\/build(\/|$)/.test(normalized);
const isInBuildStar = /\/build-[^/]+(\/|$)/.test(normalized);
return !(isInBuildRoot || isInBuildStar);
};
}
/**
* @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 {Promise<import('@rspack/cli').Configuration[]>}
*/
module.exports = async function (inMeteor = {}, argv = {}) {
// Transform Meteor env properties to proper boolean values
const Meteor = { ...inMeteor };
// Convert string boolean values to actual booleans
for (const key in Meteor) {
if (Meteor[key] === 'true' || Meteor[key] === true) {
Meteor[key] = true;
} else if (Meteor[key] === 'false' || Meteor[key] === false) {
Meteor[key] = false;
}
}
const isProd = !!Meteor.isProduction || argv.mode === 'production';
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;
const isTestFullApp = !!Meteor.isTestFullApp;
const isTestLike = !!Meteor.isTestLike;
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 testEntry = Meteor.testEntry;
const testClientEntry = Meteor.testClientEntry;
const testServerEntry = Meteor.testServerEntry;
const isTypescriptEnabled = Meteor.isTypescriptEnabled || false;
const isJsxEnabled =
Meteor.isJsxEnabled || (!isTypescriptEnabled && isReactEnabled) || false;
const isTsxEnabled =
Meteor.isTsxEnabled || (isTypescriptEnabled && isReactEnabled) || false;
const isBundleVisualizerEnabled = Meteor.isBundleVisualizerEnabled || false;
const isAngularEnabled = Meteor.isAngularEnabled || false;
// Determine entry points
const entryPath = Meteor.entryPath;
// Determine output points
const outputPath = Meteor.outputPath;
const outputDir = path.dirname(Meteor.outputPath || '');
const outputFilename = Meteor.outputFilename;
// Determine run point
const runPath = Meteor.runPath;
// Determine banner
const bannerOutput = JSON.parse(Meteor.bannerOutput || process.env.RSPACK_BANNER || '""');
// Determine output directories
const clientOutputDir = path.resolve(projectDir, 'public');
const serverOutputDir = path.resolve(projectDir, 'private');
// Determine context for bundles and assets
const buildContext = Meteor.buildContext || '_build';
const assetsContext = Meteor.assetsContext || 'build-assets';
const chunksContext = Meteor.chunksContext || 'build-chunks';
// Determine build output and pass to Meteor
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, options = {}) =>
compileWithRspack(deps, {
options: mergeSplitOverlap(Meteor.swcConfigOptions, options),
});
Meteor.setCache = enabled =>
setCache(
!!enabled,
enabled === 'memory' ? undefined : cacheStrategy
);
Meteor.splitVendorChunk = () => splitVendorChunk();
Meteor.extendSwcConfig = (customSwcConfig) => extendSwcConfig(customSwcConfig);
Meteor.extendConfig = (...configs) => mergeSplitOverlap(...configs);
Meteor.disablePlugins = matchers => prepareMeteorRspackConfig({
disablePlugins: matchers,
});
// Add HtmlRspackPlugin function to Meteor
Meteor.HtmlRspackPlugin = (options = {}) => {
return new HtmlRspackPlugin({
inject: false,
cache: true,
filename: `../${buildContext}/${outputDir}/index.html`,
templateContent: `
<head>
<% for tag in htmlRspackPlugin.tags.headTags { %>
<%= toHtml(tag) %>
<% } %>
</head>
<body>
<% for tag in htmlRspackPlugin.tags.bodyTags { %>
<%= toHtml(tag) %>
<% } %>
</body>
`,
...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 = {
ignored: [
...createIgnoreGlobConfig([
...meteorIgnoreEntries,
...additionalEntries,
]),
],
};
if (Meteor.isDebug || Meteor.isVerbose) {
console.log('[i] Rspack mode:', mode);
console.log('[i] Meteor flags:', Meteor);
}
const enableSwcExternalHelpers = !isServer && swcExternalHelpers;
const isDevEnvironment = isRun && isDev && !isTest && !isNative;
const swcConfigRule = createSwcConfig({
isTypescriptEnabled,
isReactEnabled,
isJsxEnabled,
isTsxEnabled,
externalHelpers: enableSwcExternalHelpers,
isDevEnvironment,
isClient,
isAngularEnabled,
});
// Expose swc config to use in custom configs
Meteor.swcConfigOptions = swcConfigRule.options;
const externals = [
/^meteor\/.*/,
...(isReactEnabled ? [/^react$/, /^react-dom$/] : []),
...(isServer ? [/^bcrypt$/] : []),
];
const alias = {
'/': path.resolve(process.cwd()),
};
const fallback = {
...(isClient && makeWebNodeBuiltinsAlias()),
};
const extensions = [
'.ts',
'.tsx',
'.mts',
'.cts',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.json',
'.wasm',
];
const extraRules = [];
const reactRefreshModule = isReactEnabled
? safeRequire('@rspack/plugin-react-refresh')
: null;
const requireExternalsPlugin = new RequireExternalsPlugin({
filePath: path.join(buildContext, runPath),
...(Meteor.isBlazeEnabled && {
externals: /\.html$/,
isEagerImport: module => module.endsWith('.html'),
...(isProd && {
lastImports: [`./${outputFilename}`],
}),
}),
enableGlobalPolyfill: isDevEnvironment && !isServer,
});
// Handle assets
const assetExternalsPlugin = new AssetExternalsPlugin();
const assetModuleFilename = _fileInfo => {
const filename = _fileInfo.filename;
const isPublic = filename.startsWith('/') || filename.startsWith('public');
if (isPublic) return `[name][ext][query]`;
return `${assetsContext}/[hash][ext][query]`;
};
const rsdoctorModule = isBundleVisualizerEnabled
? safeRequire('@rsdoctor/rspack-plugin')
: null;
const doctorPluginConfig = isRun && isBundleVisualizerEnabled && rsdoctorModule?.RsdoctorRspackPlugin
? [
new rsdoctorModule.RsdoctorRspackPlugin({
port: isClient
? (parseInt(Meteor.rsdoctorClientPort || '8888', 10))
: (parseInt(Meteor.rsdoctorServerPort || '8889', 10)),
}),
]
: [];
const bannerPluginConfig = !isBuild
? [
new BannerPlugin({
banner: bannerOutput,
entryOnly: true,
}),
]
: [];
// Not supported in Meteor yet (Rspack 1.7+ is enabled by default)
const lazyCompilationConfig = { lazyCompilation: false };
const clientEntry =
isTest && isTestEager && isTestFullApp
? generateEagerTestFile({
isAppTest: true,
projectDir,
buildContext,
ignoreEntries: [...meteorIgnoreEntries, "**/server/**"],
prefix: "client",
extraEntry: path.resolve(process.cwd(), Meteor.mainClientEntry),
})
: isTest && isTestEager
? generateEagerTestFile({
isAppTest: false,
isClient: true,
projectDir,
buildContext,
ignoreEntries: [...meteorIgnoreEntries, "**/server/**"],
prefix: "client",
})
: isTest && testEntry
? path.resolve(process.cwd(), testEntry)
: isTest && testClientEntry
? path.resolve(process.cwd(), testClientEntry)
: path.resolve(process.cwd(), buildContext, entryPath);
const clientNameConfig = `[${(isTest && 'test-') || ''}client-rspack]`;
// Base client config
let clientConfig = {
name: clientNameConfig,
target: 'web',
mode,
entry: clientEntry,
output: {
path: clientOutputDir,
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,
cssFilename: `${chunksContext}/[name]${
isProd ? '.[contenthash]' : ''
}.css`,
cssChunkFilename: `${chunksContext}/[id]${
isProd ? '.[contenthash]' : ''
}.css`,
...(isProd && { clean: { keep: keepOutsideBuild() } }),
},
optimization: {
usedExports: true,
splitChunks: { chunks: 'async' },
},
module: {
rules: [
swcConfigRule,
...(Meteor.isBlazeEnabled
? [
{
test: /\.html$/i,
loader: 'ignore-loader',
},
]
: []),
...extraRules,
],
},
resolve: { extensions, alias, fallback },
externals,
plugins: [
...[
...(isReactEnabled && reactRefreshModule && isDevEnvironment
? [new reactRefreshModule()]
: []),
requireExternalsPlugin,
assetExternalsPlugin,
].filter(Boolean),
new DefinePlugin({
'Meteor.isClient': JSON.stringify(true),
'Meteor.isServer': JSON.stringify(false),
'Meteor.isTest': JSON.stringify(isTestLike && !isTestFullApp),
'Meteor.isAppTest': JSON.stringify(isTestLike && isTestFullApp),
'Meteor.isDevelopment': JSON.stringify(isDev),
'Meteor.isProduction': JSON.stringify(isProd),
}),
...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,
...(Meteor.isBlazeEnabled && { hot: false }),
port: Meteor.devServerPort || 8080,
devMiddleware: {
writeToDisk: filePath =>
/\.(html)$/.test(filePath) && !filePath.includes('.hot-update.'),
},
},
}),
...merge(cacheStrategy, { experiments: { css: true } }),
...lazyCompilationConfig,
};
const serverEntry =
isTest && isTestEager && isTestFullApp
? generateEagerTestFile({
isAppTest: true,
projectDir,
buildContext,
ignoreEntries: [...meteorIgnoreEntries, "**/client/**"],
prefix: "server",
})
: isTest && isTestEager
? generateEagerTestFile({
isAppTest: false,
projectDir,
buildContext,
ignoreEntries: [...meteorIgnoreEntries, "**/client/**"],
prefix: "server",
})
: isTest && testEntry
? path.resolve(process.cwd(), testEntry)
: isTest && testServerEntry
? path.resolve(process.cwd(), testServerEntry)
: path.resolve(projectDir, buildContext, entryPath);
const serverNameConfig = `[${(isTest && 'test-') || ''}server-rspack]`;
// Base server config
let serverConfig = {
name: serverNameConfig,
target: 'node',
mode,
entry: serverEntry,
output: {
path: serverOutputDir,
filename: () => `../${buildContext}/${outputPath}`,
libraryTarget: 'commonjs2',
chunkFilename: `${chunksContext}/[id]${isProd ? '.[chunkhash]' : ''}.js`,
assetModuleFilename,
...(isProd && { clean: { keep: keepOutsideBuild() } }),
},
optimization: {
usedExports: true,
splitChunks: false,
runtimeChunk: false,
},
module: {
rules: [swcConfigRule, ...extraRules],
parser: {
javascript: {
// Dynamic imports on the server are treated as bundled in the same chunk
dynamicImportMode: 'eager',
},
},
},
resolve: {
extensions,
alias,
modules: ['node_modules', path.resolve(projectDir)],
conditionNames: ['import', 'require', 'node', 'default'],
},
externals,
externalsPresets: { node: true },
plugins: [
new DefinePlugin(
isTest && (isTestModule || isTestEager)
? {
'Meteor.isTest': JSON.stringify(isTest && !isTestFullApp),
'Meteor.isAppTest': JSON.stringify(isTest && isTestFullApp),
'Meteor.isDevelopment': JSON.stringify(isDev),
}
: {
'Meteor.isClient': JSON.stringify(false),
'Meteor.isServer': JSON.stringify(true),
'Meteor.isTest': JSON.stringify(isTestLike && !isTestFullApp),
'Meteor.isAppTest': JSON.stringify(isTestLike && isTestFullApp),
'Meteor.isDevelopment': JSON.stringify(isDev),
'Meteor.isProduction': JSON.stringify(isProd),
},
),
...bannerPluginConfig,
requireExternalsPlugin,
assetExternalsPlugin,
...doctorPluginConfig,
],
watchOptions,
devtool: isDevEnvironment || isNative || isTest ? 'source-map' : 'hidden-source-map',
...((isDevEnvironment || (isTest && !isTestEager) || isNative) &&
cacheStrategy),
...lazyCompilationConfig,
};
// Helper function to load and process config files
async function loadAndProcessConfig(configPath, configType, Meteor, argv, isAngularEnabled) {
try {
// Load the config file
let config;
if (path.extname(configPath) === '.mjs') {
// For ESM modules, we need to use dynamic import
const fileUrl = `file://${configPath}`;
const module = await import(fileUrl);
config = module.default || module;
} else {
// For CommonJS modules, we can use require
config = require(configPath)?.default || require(configPath);
}
// Process the config
const rawConfig = typeof config === 'function' ? config(Meteor, argv) : config;
const resolvedConfig = await Promise.resolve(rawConfig);
const userConfig = resolvedConfig && '0' in resolvedConfig ? resolvedConfig[0] : resolvedConfig;
// Define omitted paths and warning function
const omitPaths = [
"name",
"target",
"entry",
"output.path",
"output.filename",
...(Meteor.isServer ? ["optimization.splitChunks", "optimization.runtimeChunk"] : []),
].filter(Boolean);
const warningFn = path => {
if (isAngularEnabled) return;
console.warn(
`[${configType}] Ignored custom "${path}" — reserved for Meteor-Rspack integration.`,
);
};
// Clean omitted paths and merge Meteor Rspack fragments
let nextConfig = cleanOmittedPaths(userConfig, {
omitPaths,
warningFn,
});
nextConfig = mergeMeteorRspackFragments(nextConfig);
return nextConfig;
} catch (error) {
console.error(`Error loading ${configType} from ${configPath}:`, error);
if (configType === 'rspack.config.js') {
throw error; // Only rethrow for project config
}
return null;
}
}
// Load and apply project-level overrides for the selected build
// Check if we're in a Meteor package directory by looking at the path
const isMeteorPackageConfig = projectDir.includes('/packages/rspack');
if (fs.existsSync(projectConfigPath) && !isMeteorPackageConfig) {
// 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 projectConfigPathToUse = projectConfigPath;
if (fs.existsSync(mjsConfigPath)) {
projectConfigPathToUse = mjsConfigPath;
} else if (fs.existsSync(cjsConfigPath)) {
projectConfigPathToUse = cjsConfigPath;
}
const nextUserConfig = await loadAndProcessConfig(
projectConfigPathToUse,
'rspack.config.js',
Meteor,
argv,
isAngularEnabled
);
if (nextUserConfig) {
if (Meteor.isClient) {
clientConfig = mergeSplitOverlap(clientConfig, nextUserConfig);
}
if (Meteor.isServer) {
serverConfig = mergeSplitOverlap(serverConfig, nextUserConfig);
}
}
}
// Establish Angular overrides to ensure proper integration
const angularExpandConfig = isAngularEnabled
? {
mode: isProd ? "production" : "development",
devServer: { port: Meteor.devServerPort },
stats: { preset: "normal" },
infrastructureLogging: { level: "info" },
...(isProd && isClient && { output: { module: false } }),
}
: {};
// Establish test client overrides to ensure proper running
const testClientExpandConfig =
isTest && isClient
? {
module: {
parser: {
javascript: {
dynamicImportMode: "eager",
dynamicImportPrefetch: true,
dynamicImportPreload: true,
},
},
},
optimization: {
splitChunks: false,
},
plugins: [new NodePolyfillPlugin()],
}
: {};
let config = mergeSplitOverlap(
isClient ? clientConfig : serverConfig,
angularExpandConfig
);
config = mergeSplitOverlap(config, testClientExpandConfig);
// Check for override config file (extra file to override everything)
if (projectConfigPath) {
const configDir = path.dirname(projectConfigPath);
const configFileName = path.basename(projectConfigPath);
const configExt = path.extname(configFileName);
const configNameWithoutExt = configFileName.replace(configExt, '');
const configNameFull = `${configNameWithoutExt}.override${configExt}`;
const overrideConfigPath = path.join(configDir, configNameFull);
if (fs.existsSync(overrideConfigPath)) {
const nextOverrideConfig = await loadAndProcessConfig(
overrideConfigPath,
configNameFull,
Meteor,
argv,
isAngularEnabled
);
if (nextOverrideConfig) {
// Apply override config as the last step
config = mergeSplitOverlap(config, nextOverrideConfig);
}
}
}
const shouldDisablePlugins = config?.disablePlugins != null;
if (shouldDisablePlugins) {
config = disablePlugins(config, config.disablePlugins);
delete config.disablePlugins;
}
if (Meteor.isDebug || Meteor.isVerbose) {
console.log('Config:', inspect(config, { depth: null, colors: true }));
}
// Check if lazyCompilation is enabled and warn the user
if (config.lazyCompilation === true || typeof config.lazyCompilation === 'object') {
console.warn(
'\n⚠ Warning: lazyCompilation may not work correctly in the current Meteor-Rspack integration.\n' +
' This feature will be evaluated for support in future Meteor versions.\n' +
' If you encounter any issues, please disable it in your rspack config.\n',
);
}
return [config];
}

1100
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,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 --with-deps chromium chromium-headless-shell",
"test:idle-bot": "node --test .github/scripts/__tests__/inactive-issues.test.js",
"test:modern": "cd tools/modern-tests && npm test -- "
},
"jshintConfig": {
"esversion": 11

View File

@@ -362,11 +362,11 @@ export namespace Accounts {
* - a login method result object
**/
function registerLoginHandler(
handler: (options: any) => undefined | LoginMethodResult
handler: (options: any) => undefined | LoginMethodResult | Promise<undefined | LoginMethodResult>
): void;
function registerLoginHandler(
name: string,
handler: (options: any) => undefined | LoginMethodResult
handler: (options: any) => undefined | LoginMethodResult | Promise<undefined | LoginMethodResult>
): void;
type Password =
@@ -387,7 +387,7 @@ export namespace Accounts {
function _checkPasswordAsync(
user: Meteor.User,
password: Password
): Promise<{ userId: string; error?: any }>
): Promise<{ userId: string; error?: any }>;
}
export namespace Accounts {

View File

@@ -150,6 +150,30 @@ export class AccountsClient extends AccountsCommon {
});
}
/**
* @summary Log out all clients logged in as the current user and logs the current user out as well.
* @locus Client
* @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure.
*/
logoutAllClients(callback) {
this._loggingOut.set(true);
this.connection.applyAsync('logoutAllClients', [], {
// TODO[FIBERS]: Look this { wait: true } later.
wait: true
})
.then((result) => {
this._loggingOut.set(false);
this._loginCallbacksCalled = false;
this.makeClientLoggedOut();
callback && callback();
})
.catch((e) => {
this._loggingOut.set(false);
callback && callback(e);
});
}
/**
* @summary Log out other clients logged in as the current user, but does not log out the client that calls this function.
* @locus Client
@@ -793,6 +817,14 @@ Meteor.loggingOut = () => Accounts.loggingOut();
*/
Meteor.logout = callback => Accounts.logout(callback);
/**
* @summary Log out all clients logged in as the current user and logs the current user out as well.
* @locus Client
* @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure.
* @importFromPackage meteor
*/
Meteor.logoutAllClients = callback => Accounts.logoutAllClients(callback);
/**
* @summary Log out other clients logged in as the current user, but does not log out the client that calls this function.
* @locus Client

View File

@@ -302,48 +302,80 @@ Tinytest.addAsync(
});
});
});
}
},
);
Tinytest.addAsync('accounts - storage',
async function(test) {
const expectWhenSessionStorage = () => {
test.isNotUndefined(sessionStorage.getItem('Meteor.loginToken'));
test.isNull(localStorage.getItem('Meteor.loginToken'));
};
const expectWhenLocalStorage = () => {
test.isNotUndefined(localStorage.getItem('Meteor.loginToken'));
test.isNull(sessionStorage.getItem('Meteor.loginToken'));
};
Tinytest.addAsync('accounts - logoutAllClients', async function (test, done) {
logoutAndCreateUser(test, done, async () => {
const user = await Meteor.userAsync()._id;
test.equal(user.services.resume.loginTokens.length, 1);
await Meteor.users.updateAsync(user._id, {
$push: {
'services.resume.loginTokens': {
hashedToken: 'test-token',
when: new Date(),
},
},
});
await Meteor.users.updateAsync(user._id, {
$push: {
'services.resume.loginTokens': {
hashedToken: 'test-token2',
when: new Date(),
},
},
});
test.equal(user.services.resume.loginTokens.length, 3);
Meteor.logoutAllClients(async () => {
test.isUndefined(Meteor.user());
test.equal(
(await Meteor.users.findOneAsync(user._id)).services.resume.loginTokens?.length,
0,
);
removeTestUser(done);
});
});
});
const testCases = [{
Tinytest.addAsync('accounts - storage', async function (test) {
const expectWhenSessionStorage = () => {
test.isNotUndefined(sessionStorage.getItem('Meteor.loginToken'));
test.isNull(localStorage.getItem('Meteor.loginToken'));
};
const expectWhenLocalStorage = () => {
test.isNotUndefined(localStorage.getItem('Meteor.loginToken'));
test.isNull(sessionStorage.getItem('Meteor.loginToken'));
};
const testCases = [{
clientStorage: undefined,
expectStorage: expectWhenLocalStorage,
}, {
},
{
clientStorage: 'local',
expectStorage: expectWhenLocalStorage,
}, {
clientStorage: 'session',
expectStorage: expectWhenSessionStorage,
}];
for await (const testCase of testCases) {
await new Promise(resolve => {
sessionStorage.clear();
localStorage.clear();
}, {
clientStorage: 'session',
expectStorage: expectWhenSessionStorage,
}];
for await (const testCase of testCases) {
await new Promise(resolve => {
sessionStorage.clear();
localStorage.clear();
const { clientStorage, expectStorage } = testCase;
Accounts.config({ clientStorage });
test.equal(Accounts._options.clientStorage, clientStorage);
const { clientStorage, expectStorage } = testCase;
Accounts.config({ clientStorage });
test.equal(Accounts._options.clientStorage, clientStorage);
// Login a user and test that tokens are in expected storage
logoutAndCreateUser(test, resolve, () => {
Accounts.logout();
expectStorage();
removeTestUser(resolve);
});
// Login a user and test that tokens are in expected storage
logoutAndCreateUser(test, resolve, () => {
Accounts.logout();
expectStorage();
removeTestUser(resolve);
});
}
});
});
}
});
Tinytest.addAsync('accounts - should only start subscription when connected', async function (test) {
const { conn, messages, cleanup } = await captureConnectionMessagesClient(test);
@@ -365,4 +397,4 @@ Tinytest.addAsync('accounts - should only start subscription when connected', as
test.equal(parsedMessages, expectedMessages)
cleanup()
});
});

View File

@@ -1,5 +1,6 @@
import crypto from 'crypto';
import { Meteor } from 'meteor/meteor';
import { Meteor } from 'meteor/meteor'
import { check, Match } from 'meteor/check';
import {
AccountsCommon,
EXPIRE_TOKENS_INTERVAL_MS,
@@ -8,13 +9,6 @@ import { URL } from 'meteor/url';
const hasOwn = Object.prototype.hasOwnProperty;
// XXX maybe this belongs in the check package
const NonEmptyString = Match.Where(x => {
check(x, String);
return x.length > 0;
});
/**
* @summary Constructor for the `Accounts` namespace on the server.
* @locus Server
@@ -668,7 +662,6 @@ export class AccountsServer extends AccountsCommon {
// this variable is available in their scope.
const accounts = this;
// This object will be populated with methods and then passed to
// accounts._server.methods further below.
const methods = {};
@@ -698,6 +691,17 @@ export class AccountsServer extends AccountsCommon {
await this.setUserId(null);
};
// Logs out the current user and closes all the connections
// associated with the user.
//
methods.logoutAllClients = async function() {
const logoutUserId = this.userId;
accounts._setLoginToken(logoutUserId, this.connection, null);
accounts._clearAllLoginTokens(logoutUserId);
await accounts._successfulLogout(this.connection, logoutUserId);
await this.setUserId(null);
};
// Generates a new login token with the same expiration as the
// connection's current token and saves it to the database. Associates
// the connection with this new token and returns it. Throws an error
@@ -961,8 +965,8 @@ export class AccountsServer extends AccountsCommon {
_clearAllLoginTokens(userId) {
this.users.updateAsync(userId, {
$set: {
'services.resume.loginTokens': []
}
'services.resume.loginTokens': [],
},
});
};
@@ -1565,9 +1569,9 @@ export class AccountsServer extends AccountsCommon {
_userQueryValidator = Match.Where(user => {
check(user, {
id: Match.Optional(NonEmptyString),
username: Match.Optional(NonEmptyString),
email: Match.Optional(NonEmptyString)
id: Match.Optional(Match.NonEmptyString),
username: Match.Optional(Match.NonEmptyString),
email: Match.Optional(Match.NonEmptyString)
});
if (Object.keys(user).length !== 1)
throw new Match.Error("User property must have exactly one field");

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "A user account system",
version: "3.1.2",
version: "3.2.0",
});
Package.onUse((api) => {

View File

@@ -5,12 +5,13 @@ Package.describe({
// 2.2.x in the future. The version was also bumped to 2.0.0 temporarily
// during the Meteor 1.5.1 release process, so versions 2.0.0-beta.2
// through -beta.5 and -rc.0 have already been published.
version: "3.2.1",
version: "3.2.2",
});
Npm.depends({
bcrypt: "5.0.1",
argon2: "0.41.1",
"node-gyp-build": "4.8.4",
});
Package.onUse((api) => {

View File

@@ -1,6 +1,7 @@
import argon2 from "argon2";
import { hash as bcryptHash, compare as bcryptCompare } from "bcrypt";
import { Accounts } from "meteor/accounts-base";
import { check, Match } from 'meteor/check';
import { hash as bcryptHash, compare as bcryptCompare } from 'bcrypt';
// Utility for grabbing user
const getUserById =
@@ -288,12 +289,6 @@ Accounts._checkPasswordAsync = checkPasswordAsync;
// XXX maybe this belongs in the check package
const NonEmptyString = Match.Where(x => {
check(x, String);
return x.length > 0;
});
const passwordValidator = Match.OneOf(
Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256), {
digest: Match.Where(str => Match.test(str, String) && str.length === 64),
@@ -322,7 +317,7 @@ Accounts.registerLoginHandler("password", async options => {
check(options, {
user: Accounts._userQueryValidator,
password: passwordValidator,
code: Match.Optional(NonEmptyString),
code: Match.Optional(Match.NonEmptyString),
});
@@ -374,10 +369,9 @@ Accounts.registerLoginHandler("password", async options => {
* @param {String} newUsername A new username for the user.
* @importFromPackage accounts-base
*/
Accounts.setUsername =
async (userId, newUsername) => {
check(userId, NonEmptyString);
check(newUsername, NonEmptyString);
Accounts.setUsername = async (userId, newUsername) => {
check(userId, Match.NonEmptyString);
check(newUsername, Match.NonEmptyString);
const user = await getUserById(userId, {
fields: {
@@ -1006,9 +1000,9 @@ Meteor.methods(
* @importFromPackage accounts-base
*/
Accounts.replaceEmailAsync = async (userId, oldEmail, newEmail, verified) => {
check(userId, NonEmptyString);
check(oldEmail, NonEmptyString);
check(newEmail, NonEmptyString);
check(userId, Match.NonEmptyString);
check(oldEmail, Match.NonEmptyString);
check(newEmail, Match.NonEmptyString);
check(verified, Match.Optional(Boolean));
if (verified === void 0) {
@@ -1050,8 +1044,8 @@ Accounts.replaceEmailAsync = async (userId, oldEmail, newEmail, verified) => {
* @importFromPackage accounts-base
*/
Accounts.addEmailAsync = async (userId, newEmail, verified) => {
check(userId, NonEmptyString);
check(newEmail, NonEmptyString);
check(userId, Match.NonEmptyString);
check(newEmail, Match.NonEmptyString);
check(verified, Match.Optional(Boolean));
if (verified === void 0) {
@@ -1161,8 +1155,8 @@ Accounts.addEmailAsync = async (userId, newEmail, verified) => {
*/
Accounts.removeEmail =
async (userId, email) => {
check(userId, NonEmptyString);
check(email, NonEmptyString);
check(userId, Match.NonEmptyString);
check(email, Match.NonEmptyString);
const user = await getUserById(userId, { fields: { _id: 1 } });
if (!user)

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: 'No-password login/sign-up support for accounts',
version: '3.0.2',
version: '3.1.0',
});
Package.onUse(api => {

View File

@@ -1,12 +1,12 @@
import { Accounts } from 'meteor/accounts-base';
import { Random } from 'meteor/random';
import { check, Match } from 'meteor/check';
import {
DEFAULT_TOKEN_SEQUENCE_LENGTH,
getUserById,
NonEmptyString,
tokenValidator,
checkToken,
} from './server_utils';
import { Random } from 'meteor/random';
const findUserWithOptions = async ({ selector }) => {
if (!selector) {
@@ -33,7 +33,7 @@ Accounts.registerLoginHandler('passwordless', async options => {
check(options, {
token: tokenValidator(),
code: Match.Optional(NonEmptyString),
code: Match.Optional(Match.NonEmptyString),
selector: Accounts._userQueryValidator,
});

View File

@@ -1,5 +1,5 @@
import { Accounts } from 'meteor/accounts-base';
import { check, Match } from 'meteor/check';
import { Match } from 'meteor/check';
import { SHA256 } from 'meteor/sha';
const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
@@ -16,11 +16,6 @@ export const tokenValidator = () => {
);
};
export const NonEmptyString = Match.Where(x => {
check(x, String);
return x.length > 0;
});
export const checkToken = ({
user,
sequence,

View File

@@ -0,0 +1,551 @@
/* VARIABLES */
:root {
/* Layout & Sizing */
--login-buttons-accounts-dialog-width: 250px;
--meteor-accounts-base-padding: 8px;
--meteor-accounts-dialog-border-width: 1px;
--configure-login-service-dialog-width: 530px;
--button-border-radius: 4px;
--dialog-border-radius: 8px;
--input-border-radius: 4px;
/* Colors - Primary */
--login-buttons-color: #4e40b8;
--login-buttons-color-active: #6c5ce7;
/* Colors - Config */
--login-buttons-config-color: #cc3a1a;
--login-buttons-config-color-border: #a32e15;
--login-buttons-config-color-active: #e5532e;
--login-buttons-config-color-active-border: #cc3a1a;
/* Colors - UI */
--color-text-primary: #2d2d2d;
--color-text-secondary: #4a4a4a;
--color-text-disabled: #999;
--color-background-primary: #fff;
--color-background-secondary: #f8f9fa;
--color-background-disabled: #e0e0e0;
--color-border: #e6e6e6;
--color-input-border: #d1d1d1;
--color-input-focus-border: var(--login-buttons-color);
--color-error: #e74c3c;
--color-success: #2ecc71;
--color-overlay: rgba(0, 0, 0, 0.6);
/* Typography */
--font-family-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--font-family-monospace: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
--font-size-base: 16px;
--font-size-small: 0.875rem;
--font-size-smaller: 0.8125rem;
--font-size-smallest: 0.75rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-bold: 600;
--line-height-base: 1.5;
/* Effects */
--box-shadow-dialog: 0 10px 25px rgba(0, 0, 0, 0.1);
--box-shadow-button-active: 0 2px 4px 0 rgba(0, 0, 0, 0.1) inset;
--box-shadow-input-focus: 0 0 0 3px rgba(78, 64, 184, 0.2);
/* Transitions */
--transition-speed-fast: 0.1s;
--transition-speed-normal: 0.2s;
--transition-speed-slow: 0.3s;
--transition-timing: cubic-bezier(0.25, 0.1, 0.25, 1);
/* Dark Theme Variables - These can be overridden by users */
--login-buttons-color-dark: #8c7ae6;
--login-buttons-color-active-dark: #a29bfe;
--color-text-primary-dark: #f5f5f5;
--color-text-secondary-dark: #d1d1d1;
--color-text-disabled-dark: #777;
--color-background-primary-dark: #121212;
--color-background-secondary-dark: #1e1e1e;
--color-background-disabled-dark: #444;
--color-border-dark: #333;
--color-input-border-dark: #444;
--color-input-focus-border-dark: var(--login-buttons-color-dark);
--color-error-dark: #ff6b6b;
--color-success-dark: #55efc4;
--color-overlay-dark: rgba(0, 0, 0, 0.8);
--box-shadow-dialog-dark: 0 10px 25px rgba(0, 0, 0, 0.3);
--box-shadow-button-active-dark: 0 2px 4px 0 rgba(0, 0, 0, 0.3) inset;
--box-shadow-input-focus-dark: 0 0 0 3px rgba(140, 122, 230, 0.3);
}
/* Dark Theme */
@media (prefers-color-scheme: dark) {
:root {
/* Colors (Dark) - Use the dark theme variables with fallbacks */
--login-buttons-color: var(--login-buttons-color-dark, #7986CB);
--login-buttons-color-active: var(--login-buttons-color-active-dark, #9FA8DA);
--color-text-primary: var(--color-text-primary-dark, #eee);
--color-text-secondary: var(--color-text-secondary-dark, #bbb);
--color-text-disabled: var(--color-text-disabled-dark, #666);
--color-background-primary: var(--color-background-primary-dark, #121212);
--color-background-secondary: var(--color-background-secondary-dark, #1e1e1e);
--color-background-disabled: var(--color-background-disabled-dark, #444);
--color-border: var(--color-border-dark, #333);
--color-input-border: var(--color-input-border-dark, #444);
--color-input-focus-border: var(--color-input-focus-border-dark, var(--login-buttons-color, #7986CB));
--color-error: var(--color-error-dark, #e57373);
--color-success: var(--color-success-dark, #81c784);
--color-overlay: var(--color-overlay-dark, rgba(0, 0, 0, 0.8));
/* Effects (Dark) */
--box-shadow-dialog: var(--box-shadow-dialog-dark, 0 4px 12px rgba(0, 0, 0, 0.5));
--box-shadow-button-active: var(--box-shadow-button-active-dark, 0 2px 3px 0 rgba(0, 0, 0, 0.4) inset);
--box-shadow-input-focus: var(--box-shadow-input-focus-dark, 0 0 0 2px rgba(121, 134, 203, 0.25));
}
}
/* LOGIN BUTTONS */
#login-buttons {
display: inline-block;
line-height: 1;
}
#login-buttons .login-button {
position: relative;
}
#login-buttons button.login-button {
width: 100%;
}
#login-buttons .login-buttons-with-only-one-button {
display: inline-block;
}
#login-buttons .login-buttons-with-only-one-button .login-button {
display: inline-block;
}
#login-buttons .login-buttons-with-only-one-button .login-text-and-button {
display: inline-block;
}
#login-buttons .login-display-name {
display: inline-block;
padding-right: 2px;
line-height: var(--line-height-base);
font-family: var(--font-family-primary);
}
#login-buttons .loading {
line-height: 1;
background-image: url(data:image/gif;base64,R0lGODlhEAALAPQAAP///wAAANra2tDQ0Orq6gYGBgAAAC4uLoKCgmBgYLq6uiIiIkpKSoqKimRkZL6+viYmJgQEBE5OTubm5tjY2PT09Dg4ONzc3PLy8ra2tqCgoMrKyu7u7gAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCwAAACwAAAAAEAALAAAFLSAgjmRpnqSgCuLKAq5AEIM4zDVw03ve27ifDgfkEYe04kDIDC5zrtYKRa2WQgAh+QQJCwAAACwAAAAAEAALAAAFJGBhGAVgnqhpHIeRvsDawqns0qeN5+y967tYLyicBYE7EYkYAgAh+QQJCwAAACwAAAAAEAALAAAFNiAgjothLOOIJAkiGgxjpGKiKMkbz7SN6zIawJcDwIK9W/HISxGBzdHTuBNOmcJVCyoUlk7CEAAh+QQJCwAAACwAAAAAEAALAAAFNSAgjqQIRRFUAo3jNGIkSdHqPI8Tz3V55zuaDacDyIQ+YrBH+hWPzJFzOQQaeavWi7oqnVIhACH5BAkLAAAALAAAAAAQAAsAAAUyICCOZGme1rJY5kRRk7hI0mJSVUXJtF3iOl7tltsBZsNfUegjAY3I5sgFY55KqdX1GgIAIfkECQsAAAAsAAAAABAACwAABTcgII5kaZ4kcV2EqLJipmnZhWGXaOOitm2aXQ4g7P2Ct2ER4AMul00kj5g0Al8tADY2y6C+4FIIACH5BAkLAAAALAAAAAAQAAsAAAUvICCOZGme5ERRk6iy7qpyHCVStA3gNa/7txxwlwv2isSacYUc+l4tADQGQ1mvpBAAIfkECQsAAAAsAAAAABAACwAABS8gII5kaZ7kRFGTqLLuqnIcJVK0DeA1r/u3HHCXC/aKxJpxhRz6Xi0ANAZDWa+kEAA7AAAAAAAAAAAA);
width: 16px;
background-position: center center;
background-repeat: no-repeat;
}
#login-buttons .login-button, .accounts-dialog .login-button {
cursor: pointer;
-webkit-user-select: none; /* Safari support */
user-select: none;
padding: 0.625rem 1.25rem;
font-size: var(--font-size-small);
font-family: var(--font-family-primary);
line-height: var(--line-height-base);
font-weight: var(--font-weight-medium);
text-align: center;
color: var(--color-background-primary);
background: var(--login-buttons-color);
border: none;
border-radius: var(--button-border-radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: background-color var(--transition-speed-normal) var(--transition-timing),
box-shadow var(--transition-speed-normal) var(--transition-timing),
transform var(--transition-speed-fast) var(--transition-timing);
}
#login-buttons .login-button:hover, .accounts-dialog .login-button:hover {
background: var(--login-buttons-color-active);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
}
#login-buttons .login-button:active, .accounts-dialog .login-button:active {
background: var(--login-buttons-color-active);
transform: translateY(1px);
box-shadow: var(--box-shadow-button-active);
}
#login-buttons .login-button.login-button-disabled,
#login-buttons .login-button.login-button-disabled:active,
.accounts-dialog .login-button.login-button-disabled,
.accounts-dialog .login-button.login-button-disabled:active {
color: var(--color-text-disabled);
background: var(--color-background-disabled);
border: none;
box-shadow: none;
transform: none;
cursor: not-allowed;
opacity: 0.7;
}
/* Reset styles for dialog elements */
.accounts-dialog * {
padding: 0;
margin: 0;
line-height: inherit;
color: inherit;
font: inherit;
font-family: var(--font-family-primary);
}
.accounts-dialog .login-button {
width: auto;
margin-bottom: 4px;
}
#login-buttons .login-buttons-padding {
display: inline-block;
width: 30px;
}
#login-buttons .login-display-name {
margin-right: 4px;
}
#login-buttons .configure-button {
background: var(--login-buttons-config-color);
border-color: var(--login-buttons-config-color-border);
}
#login-buttons .configure-button:active,
#login-buttons .configure-button:hover {
background: var(--login-buttons-config-color-active);
border-color: var(--login-buttons-config-color-active-border);
}
#login-buttons .login-image {
display: inline-block;
position: absolute;
left: 6px;
top: 6px;
width: 16px;
height: 16px;
}
#login-buttons .text-besides-image {
margin-left: 18px;
}
#login-buttons .no-services {
color: red;
}
#login-buttons .login-link-and-dropdown-list {
position: relative;
}
#login-buttons .login-close-text {
float: left;
position: relative;
padding-bottom: 8px;
}
#login-buttons .login-text-and-button .loading,
#login-buttons .login-link-and-dropdown-list .loading {
display: inline-block;
}
#login-buttons.login-buttons-dropdown-align-left #login-dropdown-list .loading {
float: right;
}
#login-buttons.login-buttons-dropdown-align-right #login-dropdown-list .loading {
float: left;
}
#login-buttons .login-close-text-clear {
clear: both;
}
#login-buttons .or {
text-align: center;
}
#login-buttons .hline {
text-decoration: line-through;
color: lightgrey;
}
#login-buttons .or-text {
font-weight: bold;
}
#login-buttons #signup-link {
float: right;
}
#login-buttons #forgot-password-link,
#login-buttons #resend-passwordless-code {
float: left;
}
#login-buttons #back-to-login-link {
float: right;
}
#login-buttons a, .accounts-dialog a {
cursor: pointer;
text-decoration: none;
color: var(--login-buttons-color);
transition: color var(--transition-speed-normal) var(--transition-timing);
}
#login-buttons a:hover, .accounts-dialog a:hover {
color: var(--login-buttons-color-active);
text-decoration: underline;
}
#login-buttons.login-buttons-dropdown-align-right .login-close-text {
float: right;
}
.accounts-dialog {
border: var(--meteor-accounts-dialog-border-width) solid var(--color-border);
z-index: 1000;
background: var(--color-background-primary);
border-radius: var(--dialog-border-radius);
padding: 24px;
margin: -8px -12px 0 -12px;
width: var(--login-buttons-accounts-dialog-width);
box-shadow: var(--box-shadow-dialog);
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.accounts-dialog > * {
line-height: 1.6;
}
.accounts-dialog > .login-close-text {
line-height: inherit;
font-size: inherit;
font-family: inherit;
}
.accounts-dialog label, .accounts-dialog .title {
font-size: var(--font-size-small);
margin-top: 1rem;
margin-bottom: 0.375rem;
display: block;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
letter-spacing: 0.01em;
}
.accounts-dialog input[type=text],
.accounts-dialog input[type=email],
.accounts-dialog input[type=password] {
box-sizing: border-box;
width: 100%;
height: auto;
font-size: 1rem;
padding: 0.5rem;
border-radius: 4px;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
#login-buttons input[type=text]:focus,
#login-buttons input[type=email]:focus,
#login-buttons input[type=password]:focus,
.accounts-dialog input[type=text]:focus,
.accounts-dialog input[type=email]:focus,
.accounts-dialog input[type=password]:focus {
outline: none;
border-color: var(--color-input-focus-border);
box-shadow: var(--box-shadow-input-focus);
}
.accounts-dialog .login-button-form-submit {
margin-top: 8px;
}
.accounts-dialog .message {
font-size: var(--font-size-smaller);
margin-top: 10px;
line-height: 1.4;
padding: 0.375rem 0;
}
.accounts-dialog .error-message {
color: var(--color-error);
padding: 0.375rem 0.625rem;
background-color: rgba(231, 76, 60, 0.1);
border-radius: 4px;
margin-bottom: 0.75rem;
}
.accounts-dialog .info-message {
color: var(--color-success);
padding: 0.375rem 0.625rem;
background-color: rgba(46, 204, 113, 0.1);
border-radius: 4px;
margin-bottom: 0.75rem;
}
.accounts-dialog .additional-link {
font-size: var(--font-size-smallest);
margin-top: 1rem;
display: inline-block;
}
.accounts-dialog .accounts-close {
position: absolute;
top: 12px;
right: 16px;
font-size: 18px;
font-weight: var(--font-weight-bold);
line-height: 18px;
text-decoration: none;
color: var(--color-text-secondary);
opacity: 0.6;
transition: opacity var(--transition-speed-normal) var(--transition-timing);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.accounts-dialog .accounts-close:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.05);
}
.accounts-dialog #login-buttons-cancel-reset-password {
float: right;
}
.accounts-dialog #login-buttons-cancel-enroll-account {
float: right;
}
#login-dropdown-list {
position: absolute;
top: calc(-1 * var(--meteor-accounts-dialog-border-width));
left: calc(-1 * var(--meteor-accounts-dialog-border-width));
}
#login-buttons.login-buttons-dropdown-align-right #login-dropdown-list {
left: auto;
right: calc(-1 * var(--meteor-accounts-dialog-border-width));
}
#login-buttons-message-dialog .message {
/* we intentionally want it bigger on this dialog since it's the only thing displayed */
font-size: 100%;
}
.accounts-centered-dialog {
font-family: var(--font-family-primary);
z-index: 1001;
position: fixed;
/* Modern centering approach using transform */
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: var(--login-buttons-accounts-dialog-width);
}
#configure-login-service-dialog {
width: var(--configure-login-service-dialog-width);
/* Using transform for centering instead of negative margins */
}
#configure-login-service-dialog table {
width: 100%;
}
#configure-login-service-dialog input[type=text] {
width: 100%;
font-family: var(--font-family-monospace);
}
#configure-login-service-dialog ol {
margin-top: 10px;
margin-bottom: 10px;
}
#configure-login-service-dialog ol li {
margin-left: 30px;
}
#configure-login-service-dialog .configuration_labels {
width: 30%;
}
#configure-login-service-dialog .configuration_inputs {
width: 70%;
}
#configure-login-service-dialog .new-section {
margin-top: 10px;
}
#configure-login-service-dialog .url {
font-family: var(--font-family-monospace);
}
#configure-login-service-dialog-save-configuration {
float: right;
}
.configure-login-service-dismiss-button {
float: left;
}
#just-verified-dismiss-button, #messages-dialog-dismiss-button {
margin-top: 8px;
}
.hide-background {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 999;
background-color: var(--color-overlay);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
}
#login-buttons input[type=text],
#login-buttons input[type=email],
#login-buttons input[type=password],
.accounts-dialog input[type=text],
.accounts-dialog input[type=email],
.accounts-dialog input[type=password] {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--input-border-radius);
line-height: var(--line-height-base);
font-size: var(--font-size-base);
color: var(--color-text-primary);
background-color: var(--color-background-primary);
width: 100%;
box-sizing: border-box;
transition: border-color var(--transition-speed-normal) var(--transition-timing),
box-shadow var(--transition-speed-normal) var(--transition-timing);
}

View File

@@ -1,418 +0,0 @@
//////////////////// MIXINS
// Minimal, well-documented, general-purpose CSS mixins.
// (Some are same as Bootstrap.)
////////// Box-Sizing: Border-Box
// Setting `box-sizing: border-box` on an element causes the CSS
// layout algorithm to interpret `width` and `height` declarations
// as referring to the size of the border box (outside the border),
// not the content box as usual (inside the padding).
//
// This is especially useful for stretching a form element to the
// width of its container even if the form element has arbitrary
// padding and borders, which can be done using `width: 100%`.
//
// Browser support is IE 8+ and all modern browsers, with the caveat
// that `-moz-box-sizing` in Firefox is considered to have some
// buggy or non-compliant behavior. For example, min/max-width/height
// may not interact correctly. See
// https://bugzilla.mozilla.org/show_bug.cgi?id=243412.
.box-sizing-by-border () {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
////////// Box-Shadow
.box-shadow (...) {
box-shadow: @arguments;
-webkit-box-shadow: @arguments; // For Android
}
////////// Unselectable
.unselectable () {
-webkit-user-select: none; // Chrome/Safari
-moz-user-select: none; // Firefox
-ms-user-select: none; // IE10+
// These delarations not implemented in browsers yet:
-o-user-select: none;
user-select: none;
// In IE <= 9 and Opera, need unselectable="on" in the HTML.
}
//////////////////// LOGIN BUTTONS
@login-buttons-accounts-dialog-width: 250px;
@login-buttons-color: #596595;
@login-buttons-color-border: darken(@login-buttons-color, 10%);
@login-buttons-color-active: lighten(@login-buttons-color, 10%);
@login-buttons-color-active-border: darken(@login-buttons-color-active, 10%);
@login-buttons-config-color: darken(#f53, 10%);
@login-buttons-config-color-border: darken(@login-buttons-config-color, 10%);
@login-buttons-config-color-active: lighten(@login-buttons-config-color, 10%);
@login-buttons-config-color-active-border: darken(@login-buttons-config-color-active, 10%);
#login-buttons {
display: inline-block;
margin-right: 0.2px; // Fixes display on IE8: http://www.compsoft.co.uk/Blog/2009/11/inline-block-not-quite-inline-blocking.html
// This seems to keep the height of the line from
// being sensitive to the presence of the unicode down arrow,
// which otherwise bumps the baseline down by 1px.
line-height: 1;
.login-button {
position: relative; // so that we can position the image absolutely within the button
}
button.login-button {
width: 100%;
}
.login-buttons-with-only-one-button {
display: inline-block;
.login-button { display: inline-block; }
.login-text-and-button {
display: inline-block;
}
}
.login-display-name {
display: inline-block;
padding-right: 2px;
line-height: 1.5;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.loading {
line-height: 1;
background-image: url(data:image/gif;base64,R0lGODlhEAALAPQAAP///wAAANra2tDQ0Orq6gYGBgAAAC4uLoKCgmBgYLq6uiIiIkpKSoqKimRkZL6+viYmJgQEBE5OTubm5tjY2PT09Dg4ONzc3PLy8ra2tqCgoMrKyu7u7gAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCwAAACwAAAAAEAALAAAFLSAgjmRpnqSgCuLKAq5AEIM4zDVw03ve27ifDgfkEYe04kDIDC5zrtYKRa2WQgAh+QQJCwAAACwAAAAAEAALAAAFJGBhGAVgnqhpHIeRvsDawqns0qeN5+y967tYLyicBYE7EYkYAgAh+QQJCwAAACwAAAAAEAALAAAFNiAgjothLOOIJAkiGgxjpGKiKMkbz7SN6zIawJcDwIK9W/HISxGBzdHTuBNOmcJVCyoUlk7CEAAh+QQJCwAAACwAAAAAEAALAAAFNSAgjqQIRRFUAo3jNGIkSdHqPI8Tz3V55zuaDacDyIQ+YrBH+hWPzJFzOQQaeavWi7oqnVIhACH5BAkLAAAALAAAAAAQAAsAAAUyICCOZGme1rJY5kRRk7hI0mJSVUXJtF3iOl7tltsBZsNfUegjAY3I5sgFY55KqdX1GgIAIfkECQsAAAAsAAAAABAACwAABTcgII5kaZ4kcV2EqLJipmnZhWGXaOOitm2aXQ4g7P2Ct2ER4AMul00kj5g0Al8tADY2y6C+4FIIACH5BAkLAAAALAAAAAAQAAsAAAUvICCOZGme5ERRk6iy7qpyHCVStA3gNa/7txxwlwv2isSacYUc+l4tADQGQ1mvpBAAIfkECQsAAAAsAAAAABAACwAABS8gII5kaZ7kRFGTqLLuqnIcJVK0DeA1r/u3HHCXC/aKxJpxhRz6Xi0ANAZDWa+kEAA7AAAAAAAAAAAA);
width: 16px;
background-position: center center;
background-repeat: no-repeat;
}
}
#login-buttons .login-button, .accounts-dialog .login-button {
cursor: pointer;
.unselectable();
padding: 4px 8px;
font-size: 80%;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
line-height: 1.5;
text-align: center;
color: #fff;
background: @login-buttons-color;
border: 1px solid @login-buttons-color-border;
border-radius: 4px;
&:hover {
background: @login-buttons-color-active;
}
&:active {
background: @login-buttons-color-active;
.box-shadow(0 2px 3px 0 rgba(0, 0, 0, 0.2) inset);
}
&.login-button-disabled, &.login-button-disabled:active {
color: #ddd;
background: #aaa;
border: 1px solid lighten(#aaa, 10%);
.box-shadow(none);
}
}
// precendence of this selector is significant
.accounts-dialog * {
// A base for our dialog CSS, to reset browser styles and protect against
// the app's CSS. Dialogs include the dropdown, config modals, and the
// reset password modal. We can't completely isolate the dialogs from
// the app's CSS, and that isn't the goal because the app can style them.
// This rule is a compromise that should take precedence over some very
// broad rules but be overridden by more specific ones.
// Add more declarations here if they help the dialogs look good
// out-of-the-box in more apps.
padding: 0;
margin: 0;
line-height: inherit;
color: inherit;
font: inherit;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.accounts-dialog .login-button {
width: auto;
margin-bottom: 4px;
}
#login-buttons {
.login-buttons-padding {
display: inline-block;
width: 30px;
}
.login-display-name { margin-right: 4px; }
.configure-button {
background: @login-buttons-config-color;
border-color: @login-buttons-config-color-border;
&:active, &:hover {
background: @login-buttons-config-color-active;
border-color: @login-buttons-config-color-active-border;
}
}
.login-image {
display: inline-block;
position: absolute;
left: 6px;
top: 6px;
width: 16px;
height: 16px;
}
.text-besides-image {
margin-left: 18px;
}
.no-services { color: red; }
.login-link-and-dropdown-list {
position: relative;
}
.login-close-text {
float: left;
position: relative;
padding-bottom: 8px;
}
.login-text-and-button .loading, .login-link-and-dropdown-list .loading {
display: inline-block;
}
&.login-buttons-dropdown-align-left #login-dropdown-list .loading {
float: right;
}
&.login-buttons-dropdown-align-right #login-dropdown-list .loading {
float: left;
}
.login-close-text-clear { clear: both; }
.or { text-align: center; }
.hline { text-decoration: line-through; color: lightgrey; }
.or-text { font-weight: bold; }
#signup-link { float: right; }
#forgot-password-link, #resend-passwordless-code { float: left; }
#back-to-login-link { float: right; }
}
#login-buttons a, .accounts-dialog a {
cursor: pointer;
text-decoration: underline;
}
#login-buttons.login-buttons-dropdown-align-right .login-close-text {
float: right;
}
@meteor-accounts-base-padding: 8px;
@meteor-accounts-dialog-border-width: 1px;
.accounts-dialog {
border: @meteor-accounts-dialog-border-width solid #ccc;
z-index: 1000;
background: white;
border-radius: 4px;
padding: 8px 12px;
margin: -8px -12px 0 -12px;
width: @login-buttons-accounts-dialog-width;
.box-shadow(0 0 3px 0 rgba(0, 0, 0, 0.2));
// Labels and links inherit app's font with this line commented out:
//font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 16px;
color: #333;
// XXX Make the dropdown and dialogs look good without a top-level
// line-height: 1.6. For now, we apply it to everything except
// the "Close" link, which we want to have the same line-height
// as the "Sign in" link.
& > * { line-height: 1.6; }
& > .login-close-text {
line-height: inherit;
font-size: inherit;
font-family: inherit;
}
label, .title {
font-size: 80%;
margin-top: 7px;
margin-bottom: -2px;
}
label {
// Bootstrap sets labels as 'display: block;'. Undo that.
display: inline;
}
input[type=text], input[type=email], input[type=password] {
// Be pixel-accurate in IE 8+ regardless of our borders and
// paddings, at the expense of IE 7.
// Any heights or widths applied to this element will set the
// size of the border box (including padding and borders)
// instead of the content box. This makes it possible to
// do width 100%.
.box-sizing-by-border();
width: 100%;
// A fix purely for the "meteor add bootstrap" experience.
// Bootstrap sets "height: 20px" on form fields, which is too
// small when applied to the border box. People have complained
// that Bootstrap takes this approach for the sake of IE 7:
// https://github.com/twitter/bootstrap/issues/2935
// Our work-around is to override Bootstrap's rule (with higher
// precedence).
&[type] { height: auto; }
}
.login-button-form-submit { margin-top: 8px; }
.message { font-size: 80%; margin-top: 8px; line-height: 1.3; }
.error-message { color: red; }
.info-message { color: green; }
.additional-link { font-size: 75%; }
.accounts-close {
position: absolute;
top: 0;
right: 5px;
font-size: 20px;
font-weight: bold;
line-height: 20px;
text-decoration: none;
color: #000;
opacity: 0.4;
&:hover {
opacity: 0.8;
}
}
#login-buttons-cancel-reset-password { float: right; }
#login-buttons-cancel-enroll-account { float: right; }
}
#login-dropdown-list {
position: absolute;
// The top-left of the border-box of the dropdown is absolutely
// positioned within its container, so we need to compensate
// for the border. The padding is already compensated for by
// negative margins on the dropdown.
// XXX We could use negative margins to compensate for the
// border too.
top: -@meteor-accounts-dialog-border-width;
left: -@meteor-accounts-dialog-border-width;
}
#login-buttons.login-buttons-dropdown-align-right #login-dropdown-list {
left: auto;
right: -@meteor-accounts-dialog-border-width;
}
#login-buttons-message-dialog .message {
/* we intentionally want it bigger on this dialog since it's the only thing displayed */
font-size: 100%;
}
.accounts-centered-dialog {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
z-index: 1001;
position: fixed;
left: 50%;
margin-left: -(@login-buttons-accounts-dialog-width
+ @meteor-accounts-base-padding) / 2;
top: 50%;
margin-top: -40px; /* = approximately -height/2, though height can change */
}
@configure-login-service-dialog-width: 530px;
#configure-login-service-dialog {
width: @configure-login-service-dialog-width;
margin-left: -(@configure-login-service-dialog-width
+ @meteor-accounts-base-padding) / 2;
margin-top: -300px; /* = approximately -height/2, though height can change */
table { width: 100%; }
input[type=text] {
width: 100%;
font-family: "Courier New", Courier, monospace;
}
ol {
margin-top: 10px;
margin-bottom: 10px;
li { margin-left: 30px; }
}
.configuration_labels { width: 30%; }
.configuration_inputs { width: 70%; }
.new-section { margin-top: 10px; }
.url { font-family: "Courier New", Courier, monospace; }
}
#configure-login-service-dialog-save-configuration {
float: right;
}
.configure-login-service-dismiss-button {
float: left;
}
#just-verified-dismiss-button, #messages-dialog-dismiss-button {
margin-top: 8px;
}
.hide-background {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 999;
/* XXX consider replacing with DXImageTransform */
background-color: rgb(0.2, 0.2, 0.2); /* fallback for IE7-8 */
background-color: rgba(0, 0, 0, 0.7);
}
#login-buttons, .accounts-dialog {
input[type=text], input[type=email], input[type=password] {
padding: 4px;
border: 1px solid #aaa;
border-radius: 3px;
line-height: 1;
}
}

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: 'Unstyled version of login widgets',
version: '1.7.2',
version: '1.8.0',
});
Package.onUse(function(api) {
@@ -10,7 +10,7 @@ Package.onUse(function(api) {
'service-configuration',
'accounts-base',
'ecmascript',
'templating@1.4.1',
'templating@1.4.4',
'session',
],
'client'
@@ -45,12 +45,11 @@ Package.onUse(function(api) {
'client'
);
// The less source defining the default style for accounts-ui. Just adding
// The CSS source defining the default style for accounts-ui. Just adding
// this package doesn't actually apply these styles; they need to be
// `@import`ed from some non-import less file. The accounts-ui package does
// imported from another CSS file. The accounts-ui package does
// that for you, or you can do it in your app.
api.use('less@3.0.2 || 4.0.0');
api.addFiles('login_buttons.import.less');
api.addFiles('login_buttons.import.css');
});
Package.onTest(api => {

View File

@@ -0,0 +1,2 @@
/* Import the CSS from accounts-ui-unstyled */
@import url("{accounts-ui-unstyled}/login_buttons.import.css");

View File

@@ -1 +0,0 @@
@import "{accounts-ui-unstyled}/login_buttons.import.less";

View File

@@ -1,13 +1,12 @@
Package.describe({
summary: "Simple templates to add login widgets to an app",
version: '1.4.3',
version: '1.5.0',
});
Package.onUse(api => {
// Export Accounts (etc) to packages using this one.
api.imply('accounts-base', ['client', 'server']);
api.use('accounts-ui-unstyled', 'client');
api.use('less@3.0.2 || 4.0.0', 'client');
api.addFiles(['login_buttons.less'], 'client');
api.addFiles(['login_buttons.css'], 'client');
});

View File

@@ -142,16 +142,6 @@ BCp.initializeMeteorAppSwcrc = function () {
return lastModifiedSwcConfig;
};
let lastModifiedSwcLegacyConfig;
BCp.initializeMeteorAppLegacyConfig = function () {
const swcLegacyConfig = convertBabelTargetsForSwc(Babel.getMinimumModernBrowserVersions());
if (this.isVerbose() && !lastModifiedSwcLegacyConfig) {
logConfigBlock('SWC Legacy Config', swcLegacyConfig);
}
lastModifiedSwcLegacyConfig = swcLegacyConfig;
return lastModifiedSwcConfig;
};
// Helper function to check if @swc/helpers is available
function hasSwcHelpers() {
return fs.existsSync(`${getMeteorAppDir()}/node_modules/@swc/helpers`);
@@ -196,7 +186,6 @@ BCp.processFilesForTarget = function (inputFiles) {
this.initializeMeteorAppConfig();
this.initializeMeteorAppSwcrc();
this.initializeMeteorAppLegacyConfig();
this.initializeMeteorAppSwcHelpersAvailable();
inputFiles.forEach(function (inputFile) {
@@ -242,6 +231,43 @@ BCp.processOneFileForTarget = function (inputFile, source) {
sourceMap: null,
bare: !! fileOptions.bare
};
const arch = inputFile.getArch();
const isLegacyWebArch = arch.includes('legacy');
// 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) && !isLegacyWebArch) {
try {
// Get the full path to the file
const fullPath = inputFile.getPathInPackage();
// Read the file directly
toBeAdded.data = source;
// Try to read the corresponding map file
const mapPath = fullPath + '.map';
if (fs.existsSync(mapPath)) {
const mapContent = fs.readFileSync(mapPath, 'utf8');
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
console.error('Error reading Rspack file:', e);
}
}
// If you need to exclude a specific file within a package from Babel
// compilation, pass the { transpile: false } options to api.addFiles
@@ -255,16 +281,16 @@ BCp.processOneFileForTarget = function (inputFile, source) {
! excludedFileExtensionPattern.test(inputFilePath)) {
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);
} else if (arch === "web.browser") {
features.modernBrowsers = true;
} else if (arch === "web.cordova") {
features.modernBrowsers = ! getMeteorConfig()?.cordova?.disableModern;
features.modernBrowsers = ! getMeteorConfig()?.modern?.cordova === false;
}
features.topLevelAwait = inputFile.supportsTopLevelAwait &&
@@ -331,8 +357,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,
}),
},
@@ -342,13 +371,29 @@ BCp.processOneFileForTarget = function (inputFile, source) {
filename,
sourceFileName: filename,
...(isLegacyWebArch && {
env: { targets: lastModifiedSwcLegacyConfig || {} },
env: {
targets: {
chrome: '49',
edge: '15',
firefox: '30',
safari: '10',
ios: '10',
android: '5',
opera: '42',
ie: '11',
node: '8',
electron: '1.6',
},
mode: 'entry',
coreJs: '3.37',
},
}),
};
// Merge with app-level SWC config
if (lastModifiedSwcConfig) {
swcOptions = deepMerge(swcOptions, lastModifiedSwcConfig, [
'jsc.target',
'env.targets',
'module.type',
]);
@@ -374,7 +419,6 @@ BCp.processOneFileForTarget = function (inputFile, source) {
const isNodeModulesCode = packageName == null && inputFilePath.includes("node_modules/");
const isAppCode = packageName == null && !isNodeModulesCode;
const isPackageCode = packageName != null;
const isLegacyWebArch = arch.includes('legacy');
const transpConfig = getMeteorConfig()?.modern?.transpiler;
const hasModernTranspiler = transpConfig != null && transpConfig !== false;
@@ -1096,14 +1140,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,
@@ -1126,7 +1172,7 @@ function logTranspilation({
: color(originPaddedRaw, 35);
const cacheStatus = errorMessage
? color('⚠️ Fallback', 33)
: usedSwc
: usedSwc || usedRspack
? cacheHit
? color('🟢 Cache hit', 32)
: color('🔴 Cache miss', 31)

View File

@@ -1,14 +1,15 @@
Package.describe({
name: "babel-compiler",
summary: "Parser/transpiler for ECMAScript 2015+ syntax",
version: '7.12.2',
version: '7.13.0',
devOnly: true,
});
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.15.3",
});
Package.onUse(function (api) {

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "Generates the boilerplate html from program's manifest",
version: '2.0.2',
version: '2.1.0',
});
Npm.depends({

View File

@@ -77,7 +77,11 @@ export const closeTemplate = ({
src: rootUrlPathPrefix + pathname,
})
)),
process.env.METEOR_APP_CUSTOM_SCRIPT_URL ?
template(" <script type=\"text/javascript\" src=\"<%- src %>\"></script>")({
src: process.env.METEOR_APP_CUSTOM_SCRIPT_URL
})
: '',
'',
'',
'</body>',

View File

@@ -66,6 +66,7 @@ export namespace Match {
function Where<T>(condition: (val: any) => val is T): Matcher<T>;
function Where(condition: (val: any) => boolean): Matcher<any>;
var NonEmptyString: Matcher<string>;
/**
* Returns true if the value matches the pattern.
* @param value The value to check

View File

@@ -17,6 +17,11 @@ const format = result => {
return err;
}
function nonEmptyStringCondition(value) {
check(value, String);
return value.length > 0;
}
/**
* @summary Check that a value matches a [pattern](#matchpatterns).
* If the value does not match the pattern, throw a `Match.Error`.
@@ -77,6 +82,8 @@ export const Match = {
return new Where(condition);
},
NonEmptyString: ['__NonEmptyString__'],
ObjectIncluding: function(pattern) {
return new ObjectIncluding(pattern)
},
@@ -204,6 +211,7 @@ const stringForErrorMessage = (value, options = {}) => {
return EJSON.stringify(value);
};
const typeofChecks = [
[String, 'string'],
[Number, 'number'],
@@ -283,6 +291,11 @@ const testSubtree = (value, pattern, collectErrors = false, errors = [], path =
if (pattern === Object) {
pattern = Match.ObjectIncluding({});
}
// This must be invoked before pattern instanceof Array as strings are regarded as arrays
// We invoke the pattern as IIFE so that `pattern isntanceof Where` catches it
if (pattern === Match.NonEmptyString) {
pattern = new Where(nonEmptyStringCondition);
}
// Array (checked AFTER Any, which is implemented as an Array).
if (pattern instanceof Array) {

View File

@@ -175,7 +175,8 @@ Tinytest.add('check - check', test => {
fails(true, false);
fails(true, 'true');
fails('false', false);
matches('xx', Match.NonEmptyString);
fails('', Match.NonEmptyString);
matches(/foo/, RegExp);
fails(/foo/, String);
matches(new Date, Date);
@@ -787,4 +788,4 @@ Tinytest.add(
test.equal(new Match.ObjectIncluding(), Match.ObjectIncluding());
test.equal(new Match.ObjectWithValues(), Match.ObjectWithValues());
}
);
);

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: 'Check whether a value matches a pattern',
version: '1.4.4',
version: '1.5.0',
});
Package.onUse(api => {

View File

@@ -1,6 +1,6 @@
Package.describe({
name: 'ecmascript',
version: '0.16.13',
version: '0.17.0',
summary: 'Compiler plugin that supports ES2015+ in all .js files',
documentation: 'README.md',
});

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "The Meteor command-line tool",
version: "3.3.1",
version: "3.4.0",
});
Package.includeTool();

View File

@@ -1,6 +1,6 @@
import { Mongo } from 'meteor/mongo';
import { EJSONable, EJSONableProperty } from 'meteor/ejson';
import { DDP } from 'meteor/ddp';
import { Mongo } from "meteor/mongo";
import { EJSONable, EJSONableProperty } from "meteor/ejson";
import { DDP } from "meteor/ddp";
export type global_Error = Error;
@@ -21,7 +21,7 @@ export namespace Meteor {
var release: string;
var meteorRelease: string;
interface ErrorConstructor {
new (...args: any[]): Error;
errorType: string;
@@ -181,7 +181,13 @@ export namespace Meteor {
| EJSONable[]
| EJSONableProperty
| EJSONableProperty[]
>(name: string, ...args: any[]): Promise<Result> & { stubPromise: Promise<Result>, serverPromise: Promise<Result> };
>(
name: string,
...args: any[]
): Promise<Result> & {
stubPromise: Promise<Result>;
serverPromise: Promise<Result>;
};
interface MethodApplyOptions<
Result extends
@@ -261,7 +267,10 @@ export namespace Meteor {
error: global_Error | Meteor.Error | undefined,
result?: Result
) => void
): Promise<Result> & { stubPromise: Promise<Result>, serverPromise: Promise<Result> };
): Promise<Result> & {
stubPromise: Promise<Result>;
serverPromise: Promise<Result>;
};
/** Method **/
/** Url **/
@@ -317,6 +326,28 @@ export namespace Meteor {
* @param func The function to run
*/
function defer(func: Function): void;
/**
* Wrap a function so that it only runs in the specified environments.
* @param func The function to wrap
* @param options An object with an `on` property that is an array of environment names: `"development"`, `"production"`, and/or `"test"`.
*/
function deferrable<T extends Function>(
func: T,
options: { on: Array<"development" | "production" | "test"> }
): T | void;
/**
* Wrap a function so that it only runs in development environment.
* @param func The function to wrap
*/
function deferDev<T extends Function>(func: T): T | void;
/**
* Wrap a function so that it only runs in production environment.
* @param func The function to wrap
*/
function deferProd<T extends Function>(func: T): T | void;
/** Timeout **/
/** utils **/
@@ -336,7 +367,10 @@ export namespace Meteor {
* @param func A function that takes a callback as its final parameter
* @param context Optional `this` object against which the original function will be invoked
*/
function wrapAsync<T extends Function>(func: T, context?: ThisParameterType<T>): Function;
function wrapAsync<T extends Function>(
func: T,
context?: ThisParameterType<T>
): Function;
function bindEnvironment<TFunc extends Function>(func: TFunc): TFunc;
@@ -396,7 +430,7 @@ export namespace Meteor {
* others can be set using Meteor's standard OAuth login parameters */
loginUrlParameters?: {
include_granted_scopes: boolean;
},
};
},
callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void
): void;
@@ -440,7 +474,6 @@ export namespace Meteor {
): void;
/** Login **/
/** Connection **/
function reconnect(): void;
@@ -518,7 +551,11 @@ export interface Subscription {
* @param fields The fields in the document that have changed, together with their new values. If a field is not present in `fields` it was left unchanged; if it is present in `fields` and
* has a value of `undefined` it was removed from the document. If `_id` is present it is ignored.
*/
changed(collection: string, id: string, fields: Record<string, unknown>): void;
changed(
collection: string,
id: string,
fields: Record<string, unknown>
): void;
/** Access inside the publish function. The incoming connection for this subscription. */
connection: Meteor.Connection;
/**

View File

@@ -2,7 +2,7 @@
Package.describe({
summary: "Core Meteor environment",
version: '2.1.1',
version: '2.2.0',
});
Package.registerBuildPlugin({

View File

@@ -15,9 +15,8 @@ function withoutInvocation(f) {
return function () {
CurrentInvocation.withValue(null, f);
};
} else {
return f;
}
return f;
}
function bindAndCatch(context, f) {
@@ -56,7 +55,7 @@ Meteor.setInterval = function (f, duration) {
* @locus Anywhere
* @param {Object} id The handle returned by `Meteor.setInterval`
*/
Meteor.clearInterval = function(x) {
Meteor.clearInterval = function (x) {
return clearInterval(x);
};
@@ -66,7 +65,7 @@ Meteor.clearInterval = function(x) {
* @locus Anywhere
* @param {Object} id The handle returned by `Meteor.setTimeout`
*/
Meteor.clearTimeout = function(x) {
Meteor.clearTimeout = function (x) {
return clearTimeout(x);
};
@@ -84,3 +83,54 @@ Meteor.clearTimeout = function(x) {
Meteor.defer = function (f) {
Meteor._setImmediate(bindAndCatch("defer callback", f));
};
/**
* @memberOf Meteor
* @summary Defer execution of a function to run asynchronously in the background based on environment (similar to Meteor.isDevelopment ? Meteor.defer(fn) : Meteor.startup(fn)).
* @locus Anywhere
* @param {Function} func The function to run
* @param {Object} options The options object
* @param {Array<String>} options.on Condition to determine whether to defer the function, you can pass an array of environments ['development', 'production', 'test']
*/
Meteor.deferrable = function (f, options) {
var on = (options && options.on) || [];
// throw if on is not an array
if (!Array.isArray(on)) {
throw new Error("options.on must be an array");
}
var env = Meteor.isDevelopment
? "development"
: Meteor.isProduction
? "production"
: "test";
if (on.includes(env)) {
return Meteor.defer(f);
}
return f();
};
/**
* @memberOf Meteor
* @summary Defer execution of a function to run asynchronously in the background in development (similar to Meteor.isDevelopment ? Meteor.defer(fn) : Meteor.startup(fn)).
* @locus Anywhere
* @param {Function} func The function to run
* @param {Object} options The options object
*/
Meteor.deferDev = function (f) {
return Meteor.deferrable(f, { on: ["development", "test"] });
};
/**
* @memberOf Meteor
* @summary Defer execution of a function to run asynchronously in the background in production (similar to Meteor.isProduction ? Meteor.defer(fn) : Meteor.startup(fn)).
* @locus Anywhere
* @param {Function} func The function to run
* @param {Object} options The options object
*/
Meteor.deferProd = function (f) {
return Meteor.deferrable(f, { on: ["production"] });
};

View File

@@ -1,21 +1,77 @@
Tinytest.addAsync('timers - defer', function (test, onComplete) {
var x = 'a';
Tinytest.addAsync("timers - defer", function (test, onComplete) {
let x = "a";
Meteor.defer(function () {
test.equal(x, 'b');
test.equal(x, "b");
onComplete();
});
x = 'b';
x = "b";
});
Tinytest.addAsync('timers - nested defer', function (test, onComplete) {
var x = 'a';
Tinytest.addAsync("timers - nested defer", function (test, onComplete) {
let x = "a";
Meteor.defer(function () {
test.equal(x, 'b');
test.equal(x, "b");
Meteor.defer(function () {
test.equal(x, 'c');
test.equal(x, "c");
onComplete();
});
x = 'c';
x = "c";
});
x = 'b';
x = "b";
});
Tinytest.addAsync("timers - deferrable", function (test, onComplete) {
let x = "a";
Meteor.deferrable(
function () {
test.equal(x, "b");
onComplete();
},
{ on: ["development", "production", "test"] }
);
x = "b";
});
Tinytest.addAsync(
"timers - deferrable not in current env",
function (test, onComplete) {
let x = "a";
Meteor.deferrable(
function () {
x = "b";
},
{ on: [] }
);
test.equal(x, "b");
onComplete();
}
);
Tinytest.addAsync(
"timers - deferrable works with async functions",
function (test, onComplete) {
let x = Meteor.deferrable(
function () {
return "start value";
},
{ on: [] }
);
test.equal(x, "start value");
Meteor.deferrable(
function () {
test.equal(x, "value");
onComplete();
},
{ on: ["development", "production", "test"] }
);
Meteor.deferrable(
async function () {
return "value";
},
{ on: [] }
).then((value) => (x = value));
}
);

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "JavaScript minifier",
version: '3.0.4',
version: '3.1.0',
});
Npm.depends({

View File

@@ -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
}
}

View File

@@ -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();

View File

@@ -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');
});
});

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "Meteor's client-side datastore: a port of MongoDB to Javascript",
version: "2.0.4",
version: "2.0.5",
});
Package.onUse((api) => {

View File

@@ -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<Function>} 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<String, Function>} 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<String, Function>} 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
})

View File

@@ -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;
}
},
};

View File

@@ -53,6 +53,50 @@ export namespace Mongo {
? T
: U;
/**
* Configuration options for Mongo Collection constructor
*/
interface CollectionOptions<T = any, U = T> {
/**
* 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 <T extends NpmModuleMongodb.Document, U = T>(
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<T, U>
): Collection<T, U>;
/**
@@ -92,6 +116,68 @@ export namespace Mongo {
getCollection<
TCollection extends Collection<any, any> | undefined = Collection<NpmModuleMongodb.Document> | 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<T = any, U = T>(extension: (this: Collection<T, U>, name: string | null, options?: CollectionOptions<T, U>) => 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<T = any, U = T>(name: string, method: (this: Collection<T, U>, ...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<Function>;
/**
* Get all registered prototype methods (useful for debugging).
* @returns Map of method names to functions
*/
getPrototypeMethods(): Map<string, Function>;
/**
* Get all registered static methods (useful for debugging).
* @returns Map of method names to functions
*/
getStaticMethods(): Map<string, Function>;
}
interface Collection<T extends NpmModuleMongodb.Document, U = T> {
allow<Fn extends Transform<T> = 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<T = any, U = T>(extension: (this: Collection<T, U>, name: string | null, options?: CollectionOptions<T, U>) => 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<T = any, U = T>(name: string, method: (this: Collection<T, U>, ...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<Function>;
/**
* Get all registered prototype methods (useful for debugging).
* @returns Map of method names to functions
*/
getPrototypeMethods(): Map<string, Function>;
/**
* Get all registered static methods (useful for debugging).
* @returns Map of method names to functions
*/
getStaticMethods(): Map<string, Function>;
}
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<T extends Collection<any, any> | undefined = Collection<NpmModuleMongodb.Document> | undefined>(name: string): T;
/**
* A record of all defined Mongo.Collection instances, indexed by collection name.
* @internal
*/
var _collections: Map<string, Collection<any, any>>;
function setConnectionOptions(options: any): void;
}

View File

@@ -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<void> {
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;

View File

@@ -9,7 +9,7 @@
Package.describe({
summary: "Adaptor for using MongoDB and Minimongo over DDP",
version: "2.1.4",
version: "2.2.0",
});
Npm.depends({
@@ -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");

View File

@@ -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();
});

View File

@@ -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) {

View File

@@ -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(

View File

@@ -1,9 +1,8 @@
Package.describe({
name: 'react-fast-refresh',
version: '0.2.9',
version: '0.3.0',
summary: 'Automatically update React components with HMR',
documentation: 'README.md',
devOnly: true,
});
Npm.depends({

View File

@@ -0,0 +1,3 @@
# rspack
The rspack package hooks into the Meteor lifecycle to run the rspack bundler independently, compiling app code while preserving Meteor packages as external. It automatically integrates the rspack dev server and HMR mechanism, and manages client and server bundles for development and production. By default, rspack is configured to support secured code for client and server, tree shaking, full ESM support with export fields in package.json, and so on. It also enables the user to provide custom configuration.

View File

@@ -0,0 +1,609 @@
/**
* @module build-context
* @description Functions for managing build context and module files for Rspack plugin
*/
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');
const {
getMeteorAppDir,
getMeteorInitialAppEntrypoints,
isMeteorAppDevelopment,
isMeteorAppRun,
isMeteorAppBuild,
isMeteorBlazeProject,
isMeteorAppNative,
} = require('meteor/tools-core/lib/meteor');
const {
getGlobalState,
setGlobalState
} = require('meteor/tools-core/lib/global-state');
const {
addGitignoreEntries
} = require('meteor/tools-core/lib/git');
const {
RSPACK_BUILD_CONTEXT,
RSPACK_CHUNKS_CONTEXT,
RSPACK_ASSETS_CONTEXT,
GLOBAL_STATE_KEYS,
FILE_ROLE,
} = require('./constants');
// Common warning message for autogenerated files
const AUTO_GENERATED_WARNING = `* ⚠️ Note: This file is autogenerated. It is not meant to be modified manually.
* These files also act as a cache: they can be safely removed and will be
* regenerated on the next build. They should be ignored in IDE suggestions
* and version control.`;
/**
* Gets entry points from Meteor configuration
* Retrieves from global state if already stored, otherwise gets from Meteor
* @returns {Object} Object containing entry points for client and server
*/
export function getInitialEntrypoints() {
const existingEntrypoint = getGlobalState(GLOBAL_STATE_KEYS.INITIAL_ENTRYPONTS);
if (existingEntrypoint) return existingEntrypoint;
const initialEntrypoints = getMeteorInitialAppEntrypoints();
const hasInitialEntrypoints = initialEntrypoints && Object.values(initialEntrypoints).length > 0 && Object.values(initialEntrypoints).every((value) => value != null);
if (hasInitialEntrypoints) {
setGlobalState(GLOBAL_STATE_KEYS.INITIAL_ENTRYPONTS, initialEntrypoints);
}
return initialEntrypoints;
}
/**
* Ensures the Rspack build context directory exists
* Creates the directory if it doesn't exist and adds it to .gitignore
* @returns {string} Path to the build context directory
* @throws {Error} If directory creation fails
*/
export function ensureRspackBuildContextExists() {
const appDir = getMeteorAppDir();
const buildContextPath = path.join(appDir, RSPACK_BUILD_CONTEXT);
if (!fs.existsSync(buildContextPath)) {
try {
fs.mkdirSync(buildContextPath, { recursive: true });
} catch (error) {
logError(`Failed to create Rspack build context directory: ${error.message}`);
throw error;
}
}
addGitignoreEntries(
appDir,
[
RSPACK_BUILD_CONTEXT,
`*/${RSPACK_ASSETS_CONTEXT}`,
`*/${RSPACK_CHUNKS_CONTEXT}`,
RSPACK_DOCTOR_CONTEXT,
],
'Meteor Modern-Tools build context directories',
);
return buildContextPath;
}
/**
* Ensures module files exist in the build context directory
* Creates default module files if they don't exist
* @returns {void}
*/
export function ensureModuleFilesExist() {
const appDir = getMeteorAppDir();
const env = {
...(isMeteorAppDevelopment() ? { isDevelopment: true } : { isProduction: true }),
isNative: isMeteorAppNative(),
};
const commandRole = isMeteorAppRun()
? { role: FILE_ROLE.run }
: isMeteorAppBuild()
? { role: FILE_ROLE.build }
: { role: FILE_ROLE.run };
const initialEntrypoints = getInitialEntrypoints();
const mainClientFiles = {
entryFile: initialEntrypoints.mainClient || '',
outputFile: getBuildFilePath({ isMain: true, isClient: true, ...env, role: FILE_ROLE.output, onlyFilename: true }),
};
const mainServerFiles = {
entryFile: initialEntrypoints.mainServer || '',
outputFile: getBuildFilePath({ isMain: true, isServer: true, ...env, role: FILE_ROLE.output, onlyFilename: true }),
};
const isTestEager =
initialEntrypoints.testModule == null &&
initialEntrypoints.testClient == null &&
initialEntrypoints.testServer == null;
const isTestModule = initialEntrypoints.testModule != null || isTestEager;
const testClientFiles = {
entryFile: initialEntrypoints.testClient || '',
outputFile: getBuildFilePath({ isTest: true, isTestModule, isClient: true, role: FILE_ROLE.output, onlyFilename: true }),
};
const testServerFiles = {
entryFile: initialEntrypoints.testServer || '',
outputFile: getBuildFilePath({ isTest: true, isTestModule, isServer: true, role: FILE_ROLE.output, onlyFilename: true }),
};
const moduleFiles = {
/* Main module files for client and server */
[getBuildFilePath({ isMain: true, isClient: true, ...env, ...commandRole })]:
getBuildFileContent({ isMain: true, isClient: true, ...env, ...commandRole, ...mainClientFiles }),
[getBuildFilePath({ isMain: true, isClient: true, ...env, role: FILE_ROLE.entry })]:
getBuildFileContent({ isMain: true, isClient: true, ...env, role: FILE_ROLE.entry, ...mainClientFiles }),
[getBuildFilePath({ isMain: true, isClient: true, ...env, role: FILE_ROLE.output })]:
getBuildFileContent({ isMain: true, isClient: true, ...env, role: FILE_ROLE.output, ...mainClientFiles }),
[getBuildFilePath({ isMain: true, isServer: true, ...env, ...commandRole })]:
getBuildFileContent({ isMain: true, isServer: true, ...env, ...commandRole, ...mainServerFiles }),
[getBuildFilePath({ isMain: true, isServer: true, ...env, role: FILE_ROLE.entry })]:
getBuildFileContent({ isMain: true, isServer: true, ...env, role: FILE_ROLE.entry, ...mainServerFiles }),
[getBuildFilePath({ isMain: true, isServer: true, ...env, role: FILE_ROLE.output })]:
getBuildFileContent({ isMain: true, isServer: true, ...env, role: FILE_ROLE.output, ...mainServerFiles }),
/* Test module files when test module, test module files for client and server are present or eager discovery */
[getBuildFilePath({ isTest: true, isTestModule, isClient: true, ...commandRole })]:
getBuildFileContent({ isTest: true, isTestModule, isClient: true, ...commandRole, ...testClientFiles }),
[getBuildFilePath({ isTest: true, isTestModule, isClient: true, role: FILE_ROLE.entry })]:
getBuildFileContent({ isTest: true, isTestModule, isClient: true, role: FILE_ROLE.entry, ...testClientFiles }),
[getBuildFilePath({ isTest: true, isTestModule, isClient: true, role: FILE_ROLE.output })]:
getBuildFileContent({ isTest: true, isTestModule, isClient: true, role: FILE_ROLE.output, ...testClientFiles }),
[getBuildFilePath({ isTest: true, isTestModule, isServer: true, ...commandRole })]:
getBuildFileContent({ isTest: true, isTestModule, isServer: true, ...commandRole, ...testServerFiles }),
[getBuildFilePath({ isTest: true, isTestModule, isServer: true, role: FILE_ROLE.entry })]:
getBuildFileContent({ isTest: true, isTestModule, isServer: true, role: FILE_ROLE.entry, ...testServerFiles }),
[getBuildFilePath({ isTest: true, isTestModule, isServer: true, role: FILE_ROLE.output })]:
getBuildFileContent({ isTest: true, isTestModule, isServer: true, role: FILE_ROLE.output, ...testServerFiles }),
};
Object.entries(moduleFiles).forEach(([filename, defaultContent]) => {
// 1. Build full path and ensure directory exists
const filePath = path.join(appDir, RSPACK_BUILD_CONTEXT, filename);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
logError(`Failed to create directory ${dir}: ${err.message}`);
return; // stop here if we cant make the folder
}
}
// 2. If the file exists, check its contents
if (fs.existsSync(filePath)) {
let existing;
try {
existing = fs.readFileSync(filePath, 'utf8');
} catch (err) {
logError(`Failed to read existing file ${filename}: ${err.message}`);
return;
}
// 3. If it doesn't already start with the new defaultContent, overwrite it
if (!existing.includes(defaultContent)) {
try {
fs.writeFileSync(filePath, defaultContent, 'utf8');
} catch (err) {
logError(`Failed to rewrite module file ${filename}: ${err.message}`);
}
}
// 4. If the file doesn't exist at all, write it for the first time
} else {
try {
fs.writeFileSync(filePath, defaultContent, 'utf8');
} catch (err) {
logError(`Failed to create module file ${filename}: ${err.message}`);
}
}
});
}
/**
* Generates a build file path based on configuration parameters
* @param {Object} config - Configuration object containing build settings
* @returns {string} The build file path or filename
*/
export function getBuildFilePath(config) {
// Determine the module part (directory name)
let module = '';
if (config?.isTest) {
module = 'test';
} else if (config?.isMain) {
module = 'main';
}
// Determine the side part (first part of filename)
let side = '';
if (config?.isServer) {
side = 'server';
} else if (config?.isClient) {
side = 'client';
}
// Determine the environment part (only for non-test files)
let env = '';
if (!config?.isTest) {
if (config?.isDevelopment) {
env = 'dev';
} else if (config?.isProduction) {
env = 'prod';
}
}
// Determine the role part
let role = config?.role;
if ([FILE_ROLE.run, FILE_ROLE.build].includes(role)) {
role = 'meteor';
} else if ([FILE_ROLE.output].includes(role)) {
role = 'rspack';
}
// 5. Get file extension (default to js)
const extension = config?.extension || 'js';
// 6. Construct the filename: {side}-{role}.{extension}
const filename = `${side}-${role}.${extension}`;
// Return either just the filename or the full path
if (config?.onlyFilename) {
return filename;
} else {
// Full path format: {module}[-{env}]/{filename}
const envSuffix = env ? `-${env}` : '';
return `${module}${envSuffix}/${filename}`;
}
}
/**
* Gets the appropriate banner based on file configuration
* @param {Object} config - Configuration object
* @param {string} side - The side (client, server, test)
* @param {string} env - The environment (development, production)
* @param {string} module - The module (main, test)
* @param {string} role - The role (build, entry, run, output)
* @returns {string} The banner content
*/
function getBanner(config, side, env, module, role) {
const envDisplay = capitalizeFirstLetter(env || module);
const sideDisplay = capitalizeFirstLetter(side);
// For test mode, use the existing banners
if (module === 'test') {
// Test file banners
if (role === FILE_ROLE.entry) {
// For test mode, if side is client or server, include it in the title
const testType = side === 'test' ? 'Test' : `Test ${sideDisplay}`;
return `/**
* @file ${side}-entry.js
* @description Entry point for Rspack test build process
* --------------------------------------------------------------------------
* ⚡ Rspack ${testType} Entry (${envDisplay})
* --------------------------------------------------------------------------
* • [■ ${side}-entry.js ] ──▶ [ ${side}-rspack.js ] ──▶ [ ${side}-meteor.js ]
*
* This file is the starting point for the Rspack test build. It imports your
* Meteor app's test modules so Rspack can resolve every dependency and
* generate the bundled output: \`${side}-rspack.js\`.
*
${AUTO_GENERATED_WARNING}
*/`;
}
if (role === FILE_ROLE.output) {
// For test mode, if side is client or server, include it in the title
const testType = side === 'test' ? 'Test' : `Test ${sideDisplay}`;
return `/**
* @file ${side}-rspack.js
* @description Bundled output generated by Rspack for tests
* --------------------------------------------------------------------------
* ⚡ Rspack ${testType} App (${envDisplay})
* --------------------------------------------------------------------------
* • [ ${side}-entry.js ] ──▶ [■ ${side}-rspack.js ] ──▶ [ ${side}-meteor.js ]
*
* This file is the bundle that Rspack outputs for tests. It contains all of
* your test code in one optimized file. Next step is loading this bundle via
* \`${side}-meteor.js\`.
*
${AUTO_GENERATED_WARNING}
*/`;
}
if (role === FILE_ROLE.run || role === FILE_ROLE.build) {
// For test mode, if side is client or server, include it in the title
const testType = side === 'test' ? 'Test' : `Test ${sideDisplay}`;
return `/**
* @file ${side}-meteor.js
* @description Meteor runtime file that imports the Rspack test bundle
* --------------------------------------------------------------------------
* ☄️ Meteor ${testType} App (${envDisplay})
* --------------------------------------------------------------------------
* • [ ${side}-entry.js ] ──▶ [ ${side}-rspack.js ] ──▶ [■ ${side}-meteor.js ]
*
* Defined under \`meteor.testModule${side === 'test' ? '' : `.${side}`}\` in package.json. Meteor loads this
* file at runtime to import the Rspack test bundle (\`${side}-rspack.js\`) and
* run your tests.
*
${AUTO_GENERATED_WARNING}
*/`;
}
return '';
}
// For main modules (not test mode), use the new templates
// Entry files
if (role === FILE_ROLE.entry) {
return `/**
* @file ${side}-entry.js
* @description Entry point for Rspack build process
* --------------------------------------------------------------------------
* 🔌 Rspack ${sideDisplay} Entry (${envDisplay})
* --------------------------------------------------------------------------
* • [■ ${side}-entry.js ] ──▶ [ ${side}-rspack.js ] ──▶ [ ${side}-meteor.js ]
*
* This file is the entry point that Rspack uses to start the build process.
* It imports the module defined in \`meteor.mainModule.${side}\` inside package.json.
* From here, Rspack can trace the entire dependency graph of your application
* and generate the bundled output (\`${side}-rspack.js\`).
*
${AUTO_GENERATED_WARNING}
*/`;
}
// Rspack output files
if (role === FILE_ROLE.output) {
return `/**
* @file ${side}-rspack.js
* @description Bundled output generated by Rspack
* --------------------------------------------------------------------------
* ⚡ Rspack ${sideDisplay} App (${envDisplay})
* --------------------------------------------------------------------------
* • [ ${side}-entry.js ] ──▶ [■ ${side}-rspack.js ] ──▶ [ ${side}-meteor.js ]
*
* This file is the bundled output generated by Rspack.
* It contains all application code and assets combined into one build.
* It is not used directly, but will be imported by the Meteor main module
* file (\`${side}-meteor.js\`) so that Meteor runs the Rspack bundle.
*
${AUTO_GENERATED_WARNING}
*/`;
}
// Meteor files (run or build role)
if (role === FILE_ROLE.run || role === FILE_ROLE.build) {
return `/**
* @file ${side}-meteor.js
* @description Meteor runtime file that imports the Rspack bundle
* --------------------------------------------------------------------------
* ☄️ Meteor ${sideDisplay} App (${envDisplay})
* --------------------------------------------------------------------------
* • [ ${side}-entry.js ] ──▶ [ ${side}-rspack.js ] ──▶ [■ ${side}-meteor.js ]
*
* This file overrides the corresponding \`meteor.mainModule.${side}\` entry in
* package.json. Meteor loads it at runtime, and it imports the Rspack
* bundle (\`${side}-rspack.js\`) so the application executes using the build
* produced by Rspack.
*
${AUTO_GENERATED_WARNING}
*/`;
}
return '';
}
/**
* Gets the HMR code if applicable
* @returns {string} The HMR code or empty string
*/
function getHmrCode(config, role) {
if (role === FILE_ROLE.entry && config?.isClient && !config?.isTest) {
return `/* Enables HMR */
if (module.hot) {
module.hot.accept();
}`;
}
return '';
}
/**
* Gets the import content based on configuration
* @returns {string} The import content
*/
function getImportContent(config, side, role) {
if (config?.entryFile && role === FILE_ROLE.entry) {
return `/* Link to 🔌 Meteor ${capitalizeFirstLetter(side)} Entry */
import '../../${config?.entryFile}';`;
}
if (config?.outputFile &&
(role === FILE_ROLE.build || config?.isProduction ||
(role === FILE_ROLE.run &&
(config?.isServer || config?.isTest || config?.isNative)))
) {
return `/* Link to ⚡ Rspack ${capitalizeFirstLetter(side)} App */
${
(isMeteorBlazeProject() && config?.isClient && '// In Blaze, import happens last so HTML files preload first') ||
`import './${config?.outputFile || ''}';`
}`;
}
if (role === FILE_ROLE.run && config?.isServer && !config?.isTest) {
return '/* No link to ☄️ Meteor Server App as served by HMR server */';
}
if (role === FILE_ROLE.run && config?.isClient && !config?.isTest) {
return '/* No link to ⚡ Rspack Client App as served by HMR server */';
}
if (role === FILE_ROLE.output && config?.isClient && !config?.isTest) {
return '/* No code generated as served by HMR server */';
}
if (role === FILE_ROLE.output && (config?.isServer || config?.isTest)) {
return '/* Code generated */';
}
if (role === FILE_ROLE.entry && config?.isTest) {
return '/* Tests automatically imported */';
}
return '';
}
/**
* Generates build file content based on configuration parameters
* @param {Object} config - Configuration object
* @returns {string} The build file content
*/
export function getBuildFileContent(config) {
// Extract configuration values
const module = config?.isTest ? 'test' : config?.isMain ? 'main' : '';
const side = config?.isTestModule ? 'test' : config?.isServer ? 'server' : config?.isClient ? 'client' : '';
const env = config?.isDevelopment ? 'development' : config?.isProduction ? 'production' : '';
const role = config?.role;
// Get banner based on configuration
const banner = getBanner(config, side, env, module, role);
// Get HMR code if applicable
const hmr = getHmrCode(config, role);
// Get import content based on configuration
const importContent = getImportContent(config, side, role);
// Combine all parts to create the file content
return `${banner}
${hmr && `
${hmr}
` || ''}
${importContent}
`;
}
/**
* Cleans the build context files of the current environment
* Removes all build files and directories for the current environment
* Also cleans _build-* files from public and private folders
* @returns {void}
*/
export function cleanBuildContextFiles() {
const appDir = getMeteorAppDir();
const buildContextPath = path.join(appDir, RSPACK_BUILD_CONTEXT);
// Only proceed if the build context directory exists
if (!fs.existsSync(buildContextPath)) {
return;
}
// Get current environment
const env = {
...(isMeteorAppDevelopment() ? { isDevelopment: true } : { isProduction: true }),
isNative: isMeteorAppNative(),
};
try {
// Clean main module directories
const mainClientPath = path.dirname(path.join(buildContextPath, getBuildFilePath({ isMain: true, isClient: true, ...env })));
const mainServerPath = path.dirname(path.join(buildContextPath, getBuildFilePath({ isMain: true, isServer: true, ...env })));
// Clean test module directories if they exist
const testModulePath = path.dirname(path.join(buildContextPath, getBuildFilePath({ isTest: true, isTestModule: true })));
const testClientPath = path.dirname(path.join(buildContextPath, getBuildFilePath({ isTest: true, isClient: true })));
const testServerPath = path.dirname(path.join(buildContextPath, getBuildFilePath({ isTest: true, isServer: true })));
// Create a Set to ensure unique directory paths
const uniqueDirPaths = new Set([mainClientPath, mainServerPath, testModulePath, testClientPath, testServerPath]);
// Remove directories if they exist
[...uniqueDirPaths].forEach(dirPath => {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
});
// Clean _build-* files from public and private folders
const publicDir = path.join(appDir, 'public');
const privateDir = path.join(appDir, 'private');
[publicDir, privateDir].forEach(dir => {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
files.forEach(file => {
if ([RSPACK_ASSETS_CONTEXT, RSPACK_CHUNKS_CONTEXT, RSPACK_DOCTOR_CONTEXT].includes(file)) {
const filePath = path.join(dir, file);
fs.rmSync(filePath, { recursive: true, force: true });
}
});
// Also remove client-rspack.js from public directory if it exists
if (dir === publicDir) {
const clientRspackPath = path.join(dir, 'client-rspack.js');
if (fs.existsSync(clientRspackPath)) {
fs.rmSync(clientRspackPath, { force: true });
}
}
} catch (err) {
logError(`Failed to clean _build-* files from ${dir}: ${err.message}`);
}
}
});
} catch (error) {
logError(`Failed to clean build context files: ${error.message}`);
}
}
/**
* Ensures the rspack.config.js file exists at the project level
* Creates the file if it doesn't exist with the required template
* 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();
// 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.
*
* Provides typed flags on the \`Meteor\` object, such as:
* - \`Meteor.isClient\` / \`Meteor.isServer\`
* - \`Meteor.isDevelopment\` / \`Meteor.isProduction\`
* - …and other flags available
*
* Use these flags to adjust your build settings based on environment.
*/
module.exports = defineConfig(Meteor => {
return {};
});
`;
if (!fs.existsSync(jsConfigPath)) {
try {
fs.writeFileSync(jsConfigPath, configTemplate, 'utf8');
} catch (error) {
logError(`Failed to create rspack.config.js file: ${error.message}`);
throw error;
}
}
return jsConfigPath;
}

View File

@@ -0,0 +1,226 @@
/**
* @module compilation-helpers
* @description Helper functions for Rspack compilation tracking
*
* This module provides utility functions for tracking Rspack compilations,
* including setting up compilation tracking, waiting for first compilation,
* and formatting time values.
*/
const {
GLOBAL_STATE_KEYS
} = require('./constants');
const {
getGlobalState,
setGlobalState
} = require('meteor/tools-core/lib/global-state');
// Helper function to format milliseconds with comma separators
function formatMilliseconds(ms) {
return ms.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Sets up compilation tracking and callbacks
* @returns {Object} Object containing compilation tracking state and callbacks
*/
export function setupCompilationTracking() {
// Initialize global state for first compilation tracking
const clientFirstCompile = {
resolved: false,
resolve: null
};
const serverFirstCompile = {
resolved: false,
resolve: null
};
// Store in global state
setGlobalState(GLOBAL_STATE_KEYS.CLIENT_FIRST_COMPILE, clientFirstCompile);
setGlobalState(GLOBAL_STATE_KEYS.SERVER_FIRST_COMPILE, serverFirstCompile);
// Create promises for first compilation of client and server
const clientFirstCompilePromise = new Promise(resolve => {
clientFirstCompile.resolve = resolve;
});
const serverFirstCompilePromise = new Promise(resolve => {
serverFirstCompile.resolve = resolve;
});
// Create a shared state to track compilation times
const compilationState = {
clientMs: null,
serverMs: null,
timeoutId: null,
initialCompilationOccurred: false,
previousClientResolved: false,
previousServerResolved: false,
previousMaxTime: 0,
// Base delay in milliseconds
baseDelay: 100,
// Calculate dynamic defer time based on previous maximum time
calculateDeferTime: function() {
// Use a fixed base delay plus a margin based on previous maximum time
// The margin is 20% of the previous maximum time
return this.baseDelay + this.previousMaxTime;
},
// Function to print the maximum time once compilations are complete
printMaxTime: function() {
const clientResolved = clientFirstCompile?.resolved || false;
const serverResolved = serverFirstCompile?.resolved || false;
// Check if this is the first time both client and server are resolved
// but were previously not both resolved
if (clientResolved && serverResolved &&
!(this.previousClientResolved && this.previousServerResolved) &&
!this.initialCompilationOccurred) {
this.initialCompilationOccurred = true;
}
// Update previous resolved states for next call
this.previousClientResolved = clientResolved;
this.previousServerResolved = serverResolved;
const shouldPrint = this.initialCompilationOccurred &&
(this.clientMs !== null || this.serverMs !== null);
// Clear any existing timeout
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
// Handle cases where only one compilation runs
if (shouldPrint) {
// Use the available time or default to the other one
const clientTime = this.clientMs !== null ? this.clientMs : 0;
const serverTime = this.serverMs !== null ? this.serverMs : 0;
// Calculate defer time based on previous maximum time
const deferTime = this.calculateDeferTime();
// Set a timeout to wait for both compilations to likely finish
this.timeoutId = setTimeout(() => {
const maxMs = Math.max(clientTime, serverTime);
console.log(
`| Total: ${formatMilliseconds(maxMs)} ms (Rspack ${
this.initialCompilationOccurred ? 'Rebuild' : 'Build'
} App)`
);
// Store the current maximum time for future defer time calculations
this.previousMaxTime = Math.max(maxMs, this.previousMaxTime);
// Reset the state for next compilation cycle
clearTimeout(this.timeoutId);
this.clientMs = null;
this.serverMs = null;
this.timeoutId = null;
}, deferTime);
}
},
};
// Define separate onCompile callbacks for client and server
const onCompileClient = (data) => {
// Resolve the promise if it's the first compilation
const clientState = getGlobalState(GLOBAL_STATE_KEYS.CLIENT_FIRST_COMPILE, clientFirstCompile);
if (!clientState?.resolved) {
clientState.resolved = true;
clientState.resolve();
setGlobalState(GLOBAL_STATE_KEYS.CLIENT_FIRST_COMPILE, clientState);
}
if (process.env.METEOR_PROFILE) {
// Extract milliseconds from compilation message
const msMatch = data.match(/in (\d+) ms/);
if (msMatch && msMatch[1]) {
// Store the client compilation time
compilationState.clientMs = parseInt(msMatch[1], 10);
// Try to print max time if both compilations are complete
compilationState.printMaxTime();
}
}
};
const onCompileServer = (data) => {
// Resolve the promise if it's the first compilation
const serverState = getGlobalState(GLOBAL_STATE_KEYS.SERVER_FIRST_COMPILE, serverFirstCompile);
if (!serverState?.resolved) {
serverState.resolved = true;
serverState.resolve();
setGlobalState(GLOBAL_STATE_KEYS.SERVER_FIRST_COMPILE, serverState);
}
if (process.env.METEOR_PROFILE) {
// Extract milliseconds from compilation message
const msMatch = data.match(/in (\d+) ms/);
if (msMatch && msMatch[1]) {
// Store the server compilation time
compilationState.serverMs = parseInt(msMatch[1], 10);
// Try to print max time if both compilations are complete
compilationState.printMaxTime();
}
}
};
return {
clientFirstCompile,
serverFirstCompile,
clientFirstCompilePromise,
serverFirstCompilePromise,
onCompileClient,
onCompileServer
};
}
/**
* Waits for first compilation to complete
* @param {Object} clientFirstCompile - Client first compilation state
* @param {Object} serverFirstCompile - Server first compilation state
* @param {Promise} clientFirstCompilePromise - Promise for client first compilation
* @param {Promise} serverFirstCompilePromise - Promise for server first compilation
* @param {Object} options - Options for waiting
* @param {string} options.target - Target to wait for: 'client', 'server', or 'both' (default)
* @param {string} options.version - Specific version to wait for (optional)
* @returns {Promise<void>} A promise that resolves when first compilation is complete
*/
export async function waitForFirstCompilation(
clientFirstCompile,
serverFirstCompile,
clientFirstCompilePromise,
serverFirstCompilePromise,
options = { target: 'both' }
) {
const clientState = getGlobalState(GLOBAL_STATE_KEYS.CLIENT_FIRST_COMPILE, clientFirstCompile);
const serverState = getGlobalState(GLOBAL_STATE_KEYS.SERVER_FIRST_COMPILE, serverFirstCompile);
// If compilation is already complete, return immediately
if (process.env.RSPACK_FIRST_COMPILATION_COMPLETE) {
return;
}
// Determine which compilation(s) to wait for based on target
switch (options.target) {
case 'client':
if (!clientState?.resolved) {
await clientFirstCompilePromise;
}
break;
case 'server':
if (!serverState?.resolved) {
await serverFirstCompilePromise;
}
break;
case 'both':
default:
if (!clientState?.resolved && !serverState?.resolved) {
await Promise.all([clientFirstCompilePromise, serverFirstCompilePromise]);
}
break;
}
process.env.RSPACK_FIRST_COMPILATION_COMPLETE = true;
}

View File

@@ -0,0 +1,377 @@
/**
* @module config
* @description Functions for configuring Meteor for Rspack
*/
import { glob } from 'glob';
import path from 'path';
import fs from 'fs';
const { logInfo } = require('meteor/tools-core/lib/log');
const {
getMeteorAppFilesAndFolders,
setMeteorAppIgnore,
setMeteorAppEntrypoints,
setMeteorAppCustomScriptUrl,
isMeteorAppDevelopment,
isMeteorAppRun,
isMeteorAppBuild,
isMeteorAppDebug,
isMeteorAppTest,
isMeteorAppConfigModernVerbose,
isMeteorBlazeProject,
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
* For Less projects, it excludes .less files
* For SCSS projects, it excludes .scss files
* @returns {string[]} Array of file extensions to ignore
*/
function getFileExtensionsToIgnore() {
const isAnyCompilerProject =
isMeteorBlazeProject() || isMeteorLessProject() || isMeteorScssProject();
if (!isAnyCompilerProject) {
return [];
}
const allFiles = glob.sync('**/*', {
nodir: true,
dot: true,
ignore: ['node_modules/**', '.meteor/**'],
});
const existingExts = Array.from(
new Set(allFiles.map(f => path.extname(f).toLowerCase())),
);
// Base extensions to ignore
const baseExtensions = [
'.ts',
'.tsx',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.json',
];
// Filter existing extensions based on project type
let filteredExts = existingExts;
// For Blaze projects, exclude .html files
if (isMeteorBlazeProject()) {
filteredExts = existingExts.filter(ext => ext !== '.html');
}
// Check for Less projects and exclude .less files
if (isMeteorLessProject()) {
filteredExts = filteredExts.filter(ext => ext !== '.less');
}
// Check for SCSS projects and exclude .scss files
if (isMeteorScssProject()) {
filteredExts = filteredExts.filter(ext => ext !== '.scss');
}
return Array.from(new Set([...baseExtensions, ...filteredExts])).filter(
ext => ext !== '',
);
}
/**
* Configures Meteor settings for Rspack
* Sets up file ignores, entry points, and custom script URL
* Creates necessary module files and writes content to them
* @returns {void}
*/
export function configureMeteorForRspack() {
const meteorAppConfig = getMeteorAppConfig();
const initialEntrypoints = getInitialEntrypoints();
// Ignore node_modules to prevent Meteor from processing them
const projectRootFilesAndFolders = getMeteorAppFilesAndFolders({
recursive: false,
});
const initialEntrypointContexts = [
initialEntrypoints.mainClient,
initialEntrypoints.mainServer,
].map(entrypoint => path.dirname(entrypoint));
const includedDirs = ['public', 'private', '.meteor', RSPACK_BUILD_CONTEXT];
const ignoredDirs = projectRootFilesAndFolders.directories.filter(
dir => !includedDirs.includes(dir),
);
const envPackageDirs = getMeteorEnvPackageDirs().map(
dir => path.normalize(dir)?.split(path.sep)?.filter(Boolean)?.[0],
);
let extraFoldersToIgnore = [
...ignoredDirs
.filter(
dir =>
![
'public',
'private',
'.meteor',
'packages',
...envPackageDirs,
RSPACK_BUILD_CONTEXT,
].includes(dir),
)
.map(dir => `${dir}/**`),
];
let extraFilesToIgnore = [];
// Get extensions to ignore based on project type
const extensionsToIgnore = getFileExtensionsToIgnore();
// If we have extensions to ignore, apply them to the ignored directories
if (extensionsToIgnore.length > 0) {
extraFilesToIgnore = ignoredDirs.flatMap(dir =>
extensionsToIgnore.map(ext => `${dir}/**/*${ext}`),
);
extraFoldersToIgnore = [];
}
// Skip CSS/HTML files in entrypoint contexts
extraFilesToIgnore = [
...extraFilesToIgnore,
...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(
getBuildFilePath({
isTest: true,
}),
)}/**`;
const otherMainIgnorePath =
(isMeteorAppDevelopment() &&
`${RSPACK_BUILD_CONTEXT}/${path.dirname(
getBuildFilePath({
isMain: true,
isProduction: true,
}),
)}/**`) ||
`${RSPACK_BUILD_CONTEXT}/${path.dirname(
getBuildFilePath({
isMain: true,
isDevelopment: true,
}),
)}/**`;
const foldersToIgnore = [
...((isMeteorAppTest() && [otherMainIgnorePath]) || [
testIgnorePath,
otherMainIgnorePath,
]),
'node_modules/**',
...extraFoldersToIgnore,
].filter(Boolean);
const rootFilesToIgnore = [
...projectRootFilesAndFolders.files.filter(
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()) {
logInfo(`[i] Meteor app ignores: ${meteorAppIgnores}`);
}
const env = isMeteorAppDevelopment()
? { isDevelopment: true }
: { isProduction: true };
const commandRole = isMeteorAppRun()
? { role: FILE_ROLE.run }
: isMeteorAppBuild()
? { role: FILE_ROLE.build }
: { role: FILE_ROLE.run };
const mainClientModule = getBuildFilePath({
isMain: true,
...env,
...commandRole,
isClient: true,
});
const mainServerModule = getBuildFilePath({
isMain: true,
...env,
...commandRole,
isServer: true,
});
const isTestEager =
initialEntrypoints.testModule == null &&
initialEntrypoints.testClient == null &&
initialEntrypoints.testServer == null;
const isTestModule = initialEntrypoints.testModule != null || isTestEager;
const testClientModule = getBuildFilePath({
isTest: true,
...env,
...commandRole,
isTestModule,
isClient: true,
});
const testServerModule = getBuildFilePath({
isTest: true,
...env,
...commandRole,
isTestModule,
isServer: true,
});
const appEntrypoints = {
mainClient: `${RSPACK_BUILD_CONTEXT}/${mainClientModule}`,
mainServer: `${RSPACK_BUILD_CONTEXT}/${mainServerModule}`,
...((isTestModule && {
testClient: `${RSPACK_BUILD_CONTEXT}/${testClientModule}`,
testServer: `${RSPACK_BUILD_CONTEXT}/${testServerModule}`,
}) || {
testClient: `${RSPACK_BUILD_CONTEXT}/${testClientModule}`,
testServer: `${RSPACK_BUILD_CONTEXT}/${testServerModule}`,
}),
};
// Set entry points in environment variables if they exist
setMeteorAppEntrypoints(appEntrypoints);
if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) {
logInfo(`[i] App entrypoints: ${JSON.stringify(appEntrypoints, null, 2)}`);
}
// Ensure module files exist
ensureModuleFilesExist();
// Write content to module files
if (isMeteorAppRun() && isMeteorAppDevelopment()) {
const customScriptUrl = `/__rspack__/${getBuildFilePath({
...env,
isMain: true,
isClient: true,
role: FILE_ROLE.output,
onlyFilename: true,
})}`;
setMeteorAppCustomScriptUrl(customScriptUrl);
if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) {
logInfo(`[i] App custom script: ${customScriptUrl}`);
}
}
}

View File

@@ -0,0 +1,102 @@
/**
* @module constants
* @description Constants and global state keys for Rspack plugin
*/
export const DEFAULT_RSPACK_VERSION = '1.7.1';
export const DEFAULT_METEOR_RSPACK_VERSION = '1.0.0';
export const DEFAULT_METEOR_RSPACK_REACT_HMR_VERSION = '1.4.3';
export const DEFAULT_METEOR_RSPACK_REACT_REFRESH_VERSION = '0.17.0';
export const DEFAULT_METEOR_RSPACK_SWC_LOADER_VERSION = '0.2.6';
export const DEFAULT_METEOR_RSPACK_SWC_HELPERS_VERSION = '0.5.17';
export const DEFAULT_RSDOCTOR_RSPACK_PLUGIN_VERSION = '1.2.3';
/**
* Global state keys used for storing and retrieving state across the application
* @constant {Object}
* @property {string} CLIENT_PROCESS - Key for storing the client process
* @property {string} SERVER_PROCESS - Key for storing the server process
* @property {string} RSPACK_INSTALLATION_CHECKED - Key for tracking if Rspack installation was checked
* @property {string} IS_REACT_ENABLED - Key for tracking if React is enabled
* @property {string} INITIAL_ENTRYPONTS - Key for storing initial entrypoints
* @property {string} CLIENT_FIRST_COMPILE - Key for tracking client first compilation state
* @property {string} SERVER_FIRST_COMPILE - Key for tracking server first compilation state
* @property {string} BUILD_CONTEXT_FILES_CLEANED - Key for tracking if build context files have been cleaned
*/
export const GLOBAL_STATE_KEYS = {
CLIENT_PROCESS: 'rspack.clientProcess',
SERVER_PROCESS: 'rspack.serverProcess',
RSPACK_INSTALLATION_CHECKED: 'rspack.rspackInstallationChecked',
RSPACK_REACT_INSTALLATION_CHECKED: 'rspack.rspackReactInstallationChecked',
RSPACK_DOCTOR_INSTALLATION_CHECKED: 'rspack.rspackDoctorInstallationChecked',
REACT_CHECKED: 'rspack.reactChecked',
TYPESCRIPT_CHECKED: 'rspack.typescriptChecked',
ANGULAR_CHECKED: 'rspack.angularChecked',
INITIAL_ENTRYPONTS: 'meteor.initialEntrypoints',
CLIENT_FIRST_COMPILE: 'rspack.clientFirstCompile',
SERVER_FIRST_COMPILE: 'rspack.serverFirstCompile',
BUILD_CONTEXT_FILES_CLEANED: 'rspack.buildContextFilesCleaned',
};
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 =
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 =
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 =
meteorConfig?.chunksContext ||
process.env.RSPACK_CHUNKS_CONTEXT ||
'build-chunks';
process.env.RSPACK_CHUNKS_CONTEXT = RSPACK_CHUNKS_CONTEXT;
/**
* Directory name for Rspack doctor context
* @type {string}
*/
export const RSPACK_DOCTOR_CONTEXT = '.rsdoctor';
/**
* Regex pattern for hot update files
* @constant {RegExp}
*/
export const RSPACK_HOT_UPDATE_REGEX = /^\/(.+\.hot-update\.(?:json|js))$/;
export const FILE_ROLE = {
build: 'build',
entry: 'entry',
run: 'run',
output: 'output',
};

View File

@@ -0,0 +1,315 @@
/**
* @module dependencies
* @description Functions for managing dependencies for Rspack plugin
*/
import {
DEFAULT_METEOR_RSPACK_REACT_REFRESH_VERSION,
DEFAULT_METEOR_RSPACK_SWC_HELPERS_VERSION,
DEFAULT_RSDOCTOR_RSPACK_PLUGIN_VERSION
} from "./constants";
const {
getGlobalState,
setGlobalState,
} = require('meteor/tools-core/lib/global-state');
const {
logProgress,
logSuccess,
logInfo,
logError,
} = require('meteor/tools-core/lib/log');
const {
isMeteorAppUpdate,
getMeteorAppDir,
} = require('meteor/tools-core/lib/meteor');
const {
checkNpmDependencyExists,
installNpmDependency,
checkNpmDependencyVersion,
} = require('meteor/tools-core/lib/npm');
const {
joinWithAnd,
} = require('meteor/tools-core/lib/string');
const {
DEFAULT_RSPACK_VERSION,
DEFAULT_METEOR_RSPACK_VERSION,
DEFAULT_METEOR_RSPACK_REACT_HMR_VERSION,
GLOBAL_STATE_KEYS,
} = require('./constants');
/**
* Generic function to ensure dependencies are installed with correct versions
* @param {Object[]} dependencies - Array of dependency objects with name, version, and semverCondition
* @param {string} globalStateKey - Global state key to track if check has been done
* @param {string} packageName - Name of the package for logging purposes
* @returns {Promise<void>} A promise that resolves when the check/installation is complete
* @throws {Error} If installation fails
*/
async function ensureDependenciesInstalled(dependencies, globalStateKey, packageName) {
// Skip if already checked
if (getGlobalState(globalStateKey, false)) {
return;
}
const appDir = getMeteorAppDir();
// Filter dependencies that need to be installed (missing or wrong version)
const allDepsToInstall = dependencies.filter(dep =>
!checkNpmDependencyExists(dep.name, { cwd: appDir }) ||
!checkNpmDependencyVersion(dep.name, {
cwd: appDir,
versionRequirement: dep.version,
semverCondition: dep.semverCondition || 'gte',
existenceOnly: dep.existenceOnly,
})
);
// Format dependencies for installation
const dependencyStrings = allDepsToInstall.map(dep => `${dep.name}@${dep.version}`);
if (allDepsToInstall.length > 0) {
let devDepsSuccess = true;
let regularDepsSuccess = true;
let devDepsStrings = [];
let regularDepsStrings = [];
// Display a header for the installation process
logProgress(`┌─────────────────────────────────────────────────`);
logProgress(`${packageName} Dependencies Installation`);
logProgress(`└─────────────────────────────────────────────────`);
// Show what dependencies will be installed
logInfo(`The following ${packageName} dependencies need to be installed:`);
dependencyStrings.forEach(dep => {
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) {
devDepsStrings = devDepsToInstall.map(dep => `${dep.name}@${dep.version}`);
// Log progress for dev dependencies
logProgress(
`🔧 Installing ${devDepsToInstall.length} dev dependenc${
devDepsToInstall.length === 1 ? "y" : "ies"
}...`
);
devDepsSuccess = await installNpmDependency(devDepsStrings, {
cwd: appDir,
dev: true,
yarn: isYarnProj,
});
}
// Install regular dependencies
const regularDepsToInstall = allDepsToInstall.filter(dep => dep.dev === false);
if (regularDepsToInstall.length > 0) {
regularDepsStrings = regularDepsToInstall.map(dep => `${dep.name}@${dep.version}`);
// Log progress for regular dependencies
logProgress(
`🔧 Installing ${regularDepsToInstall.length} dependenc${
regularDepsToInstall.length === 1 ? "y" : "ies"
}...`
);
regularDepsSuccess = await installNpmDependency(regularDepsStrings, {
cwd: appDir,
dev: false,
yarn: isYarnProj,
});
}
const success = devDepsSuccess && regularDepsSuccess;
if (!success) {
const isYarnProj = process.env.YARN_ENABLED === 'true';
logError(`\n┌─────────────────────────────────────────────────`);
logError(`│ ❌ ${packageName} Installation Failed`);
logError(`└─────────────────────────────────────────────────`);
if (!devDepsSuccess && devDepsStrings.length > 0) {
const devInstallCommand = isYarnProj
? `yarn add --dev ${devDepsStrings.join(' ').trim()}`
: `meteor npm install -D ${devDepsStrings.join(' ').trim()}`;
logError(`For dev dependencies, run: ${devInstallCommand}`);
}
if (!regularDepsSuccess && regularDepsStrings.length > 0) {
const regularInstallCommand = isYarnProj
? `yarn add ${regularDepsStrings.join(' ').trim()}`
: `meteor npm install ${regularDepsStrings.join(' ').trim()}`;
logError(`For regular dependencies, run: ${regularInstallCommand}`);
}
const allFailedDeps = [];
if (!devDepsSuccess) allFailedDeps.push('dev dependencies');
if (!regularDepsSuccess) allFailedDeps.push('regular dependencies');
throw new Error(
`Failed to install ${packageName} ${joinWithAnd(allFailedDeps)}. Please install them manually with the commands above.`
);
}
logSuccess(`${packageName} dependencies installed`);
if (isMeteorAppUpdate()) {
const isYarnProj = process.env.YARN_ENABLED === 'true';
const installCommand = isYarnProj ? 'yarn install' : 'npm install';
logInfo(`\n┌───────────────────────────────────────────────────────────────────────┐`);
logInfo(`│ 🔔 IMPORTANT: Project Stability Reminder │`);
logInfo(`├───────────────────────────────────────────────────────────────────────┤`);
logInfo(`│ After the Meteor update finishes, please run \`${installCommand}\` in your │`);
logInfo(`│ project directory. │`);
logInfo(`│ │`);
logInfo(`│ This helps keep your dependencies correct and your project stable. │`);
logInfo(`└───────────────────────────────────────────────────────────────────────┘`);
}
}
// Mark as checked
setGlobalState(globalStateKey, true);
}
/**
* Checks if Rspack is installed, and installs it if not
* @returns {Promise<void>} A promise that resolves when the check/installation is complete
* @throws {Error} If Rspack installation fails
*/
export async function ensureRspackInstalled() {
const dependencies = [
{ name: '@rspack/cli', version: DEFAULT_RSPACK_VERSION, semverCondition: 'gte', dev: true },
{ name: '@rspack/core', version: DEFAULT_RSPACK_VERSION, semverCondition: 'gte', dev: true },
{ name: '@meteorjs/rspack', version: DEFAULT_METEOR_RSPACK_VERSION, semverCondition: 'gte', dev: true },
{ name: '@swc/helpers', version: DEFAULT_METEOR_RSPACK_SWC_HELPERS_VERSION, semverCondition: 'gte', dev: false },
{ name: '@rsdoctor/rspack-plugin', version: DEFAULT_RSDOCTOR_RSPACK_PLUGIN_VERSION, semverCondition: 'gte', dev: true },
];
await ensureDependenciesInstalled(
dependencies,
GLOBAL_STATE_KEYS.RSPACK_INSTALLATION_CHECKED,
'Rspack',
);
}
/**
* Checks if React is installed and sets global state accordingly
* Sets global state and environment variables based on React detection
* @returns {Promise<void>} A promise that resolves when the check is complete
*/
export function checkReactInstalled() {
// Skip if already checked
if (getGlobalState(GLOBAL_STATE_KEYS.REACT_CHECKED, false)) {
return;
}
const appDir = getMeteorAppDir();
// Check if React is a dependency in the project
const isReactInstalled = checkNpmDependencyExists('react', { cwd: appDir });
if (isReactInstalled) {
// Set environment variable to indicate React is enabled
process.env.METEOR_REACT_ENABLED = 'true';
} else {
process.env.METEOR_REACT_ENABLED = 'false';
}
// Mark as checked
setGlobalState(GLOBAL_STATE_KEYS.REACT_CHECKED, true);
return isReactInstalled;
}
export async function ensureRspackReactInstalled() {
const dependencies = [
{ name: '@rspack/plugin-react-refresh', version: DEFAULT_METEOR_RSPACK_REACT_HMR_VERSION, semverCondition: 'gte', dev: true },
{ name: 'react-refresh', version: DEFAULT_METEOR_RSPACK_REACT_REFRESH_VERSION, semverCondition: 'gte', dev: true },
];
await ensureDependenciesInstalled(
dependencies,
GLOBAL_STATE_KEYS.RSPACK_REACT_INSTALLATION_CHECKED,
'Rspack React'
);
}
/**
* Checks if Rspack Doctor is installed, and installs it if not
* @returns {Promise<void>} A promise that resolves when the check/installation is complete
* @throws {Error} If Rspack Doctor installation fails
*/
export async function ensureRspackDoctorInstalled() {
const dependencies = [
{ name: '@rsdoctor/rspack-plugin', version: DEFAULT_RSDOCTOR_RSPACK_PLUGIN_VERSION, semverCondition: 'gte', dev: true },
];
await ensureDependenciesInstalled(
dependencies,
GLOBAL_STATE_KEYS.RSPACK_DOCTOR_INSTALLATION_CHECKED,
'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;
}
/**
* Checks if Angular is installed and sets global state accordingly
* Sets global state and environment variables based on Angular detection
* @returns {boolean} Whether Angular is installed
*/
export function checkAngularInstalled() {
// Skip if already checked
if (getGlobalState(GLOBAL_STATE_KEYS.ANGULAR_CHECKED, false)) {
return;
}
const appDir = getMeteorAppDir();
// Check if @nx/angular-rspack is a dependency in the project
const isAngularInstalled = checkNpmDependencyExists('@nx/angular-rspack', { cwd: appDir });
if (isAngularInstalled) {
// Set environment variable to indicate Angular is enabled
process.env.METEOR_ANGULAR_ENABLED = 'true';
} else {
process.env.METEOR_ANGULAR_ENABLED = 'false';
}
// Mark as checked
setGlobalState(GLOBAL_STATE_KEYS.ANGULAR_CHECKED, true);
return isAngularInstalled;
}

View File

@@ -0,0 +1,515 @@
/**
* @module processes
* @description Functions for managing Rspack processes
*/
import fs from 'fs';
import path from 'path';
const {
spawnProcess,
stopProcess,
isProcessRunning
} = require('meteor/tools-core/lib/process');
const {
logError,
logInfo,
} = require('meteor/tools-core/lib/log');
const {
getMeteorAppDir,
isMeteorAppTest,
isMeteorAppTestFullApp,
isMeteorAppDevelopment,
isMeteorAppProduction,
isMeteorAppDebug,
isMeteorAppRun,
isMeteorAppBuild,
isMeteorAppNative,
isMeteorBlazeProject,
isMeteorBlazeHotProject,
getMeteorInitialAppEntrypoints,
isMeteorAppConfigModernVerbose,
isMeteorBundleVisualizerProject,
getMeteorAppPort,
} = require('meteor/tools-core/lib/meteor');
const {
checkNpmDependencyExists,
getNpxCommand,
getMonorepoPath,
} = require('meteor/tools-core/lib/npm');
const {
getGlobalState,
setGlobalState
} = require('meteor/tools-core/lib/global-state');
const {
GLOBAL_STATE_KEYS,
RSPACK_CHUNKS_CONTEXT,
RSPACK_ASSETS_CONTEXT,
FILE_ROLE,
} = require('./constants');
const {
getBuildFilePath,
getBuildFileContent,
} = require('./build-context');
/**
* 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 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;
}
/**
* 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 .ts extension next
const tsPath = `${configBasePath}.ts`;
if (fs.existsSync(tsPath)) {
return tsPath;
}
// 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 with suggestion to run npm install
const isYarnProj = process.env.YARN_ENABLED === 'true';
const installCommand = isYarnProj ? 'yarn install' : 'npm install';
throw new Error(
`Could not find rspack.config.js, rspack.config.ts, rspack.config.mjs, or rspack.config.cjs.\n\n` +
`Try running \`${installCommand}\` in your project directory and then re-run the build.\n` +
`This will ensure @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
* @param {boolean} options.isTest - Whether this is for test build
* @param {boolean} options.isTestLike - Whether test envs should be inherited
* @returns {Object} Object containing params (command line arguments) and envs (environment variables)
*/
export function getRspackEnv({ isClient, isServer, isTest: inIsTest, isTestLike: inIsTestLike }) {
const RSPACK_BUILD_CONTEXT = require('./constants').RSPACK_BUILD_CONTEXT;
const initialEntrypoints = getMeteorInitialAppEntrypoints();
const isTest = inIsTest != null ? inIsTest : isMeteorAppTest();
const isTestLike = isTest || inIsTestLike;
const isTestEager =
initialEntrypoints.testModule == null &&
initialEntrypoints.testClient == null &&
initialEntrypoints.testServer == null;
const isTestModule = initialEntrypoints.testModule != null || isTestEager;
const isTestFullApp = isMeteorAppTestFullApp();
const module = isTest ? { isTest: true } : { isMain: true };
const env = isMeteorAppDevelopment()
? { isDevelopment: true }
: { isProduction: true };
const side = isClient ? { isClient: true } : { isServer: true };
const commandRole = isMeteorAppRun()
? { role: FILE_ROLE.run }
: isMeteorAppBuild()
? { role: FILE_ROLE.build }
: { role: FILE_ROLE.run };
const entryKey = `${isTest && isTestModule ? 'test' : 'main'}${isClient ? 'Client' : 'Server'}`;
const inputFilePath = initialEntrypoints[entryKey];
const isTypescriptEnabled = process.env.METEOR_TYPESCRIPT_ENABLED === 'true' ||
inputFilePath?.endsWith('.ts') ||
inputFilePath?.endsWith('.tsx');
const isReactEnabled = process.env.METEOR_REACT_ENABLED === 'true';
const isAngularEnabled = process.env.METEOR_ANGULAR_ENABLED === 'true';
const isTsxEnabled = isTypescriptEnabled && (inputFilePath?.endsWith('.tsx') || isReactEnabled);
const isJsxEnabled = !isTypescriptEnabled && (inputFilePath?.endsWith('.jsx') || isReactEnabled);
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()],
['isDebug', isMeteorAppDebug()],
['isVerbose', isMeteorAppConfigModernVerbose()],
['isTest', isTest],
...(isTestLike ? [['isTestLike', isTestLike || isTest]] : []),
...(isTestLike && isTestFullApp && [['isTestFullApp', isTestFullApp]] || []),
...(isTestLike && isTestModule && [['isTestModule', isTestModule]] || []),
...(isTestLike && isTestEager && [['isTestEager', isTestEager]] || []),
['isRun', isMeteorAppRun()],
['isBuild', isMeteorAppBuild()],
['isNative', isMeteorAppNative()],
['isClient', isClient],
['isServer', isServer],
['entryPath', getBuildFilePath({ ...module, ...env, ...side, isTestModule, role: FILE_ROLE.entry }) ],
['outputPath', getBuildFilePath({ ...module, ...env, ...side, isTestModule, role: FILE_ROLE.output }) ],
['outputFilename',
getBuildFilePath({
...env,
...side,
isMain: true,
role: FILE_ROLE.output,
onlyFilename: true,
}),
],
['runPath', getBuildFilePath({ ...module, ...env, ...side, ...commandRole }) ],
['buildContext', RSPACK_BUILD_CONTEXT],
['chunksContext', RSPACK_CHUNKS_CONTEXT],
['assetsContext', RSPACK_ASSETS_CONTEXT],
['devServerPort', process.env.RSPACK_DEVSERVER_PORT],
['projectConfigPath', projectConfigPath],
['configPath', configPath],
...((isTest &&
initialEntrypoints.testClient &&
initialEntrypoints.testServer && [
['testClientEntry', initialEntrypoints.testClient],
['testServerEntry', initialEntrypoints.testServer],
]) ||
(isTest &&
initialEntrypoints.testModule && [
['testEntry', initialEntrypoints.testModule],
]) || [
['mainClientEntry', initialEntrypoints.mainClient],
['mainClientHtmlEntry', initialEntrypoints.mainClientHtml],
['mainServerEntry', initialEntrypoints.mainServer],
]),
...(swcExternalHelpers && [['swcExternalHelpers', swcExternalHelpers]] || []),
...(isReactEnabled && [['isReactEnabled', isReactEnabled]] || []),
...(isBlazeEnabled && [['isBlazeEnabled', isBlazeEnabled]] || []),
...(isBlazeHotEnabled && [['isBlazeHotEnabled', isBlazeHotEnabled]] || []),
...(isTypescriptEnabled && [['isTypescriptEnabled', isTypescriptEnabled]] || []),
...(isAngularEnabled && [['isAngularEnabled', isAngularEnabled]] || []),
...(isTsxEnabled && [['isTsxEnabled', isTsxEnabled]] || []),
...(isJsxEnabled && [['isJsxEnabled', isJsxEnabled]] || []),
...(isBundleVisualizerEnabled && [
['isBundleVisualizerEnabled', isBundleVisualizerEnabled],
['rsdoctorClientPort', process.env.RSDOCTOR_CLIENT_PORT],
['rsdoctorServerPort', process.env.RSDOCTOR_SERVER_PORT],
] || []),
].filter(Boolean);
// 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 };
}
/**
* Starts Rspack for client in serve mode
* @param {Object} options - Options for client serve
* @param {Function} options.onCompile - Callback function to be called when compilation is complete
* @returns {Object} The client process object
*/
export function startRspackClientServe(options = {}) {
const { onCompile } = options;
// Get the current client process from global state
const clientProcess = getGlobalState(GLOBAL_STATE_KEYS.CLIENT_PROCESS, null);
// Skip if client process is already running
if (clientProcess && isProcessRunning(clientProcess)) {
return clientProcess;
}
const appDir = getMeteorAppDir();
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")) {
onCompile(data);
}
},
onStderr: (data) => {
// Check if this is an EADDRINUSE error in development mode (which we want to completely ignore)
if (isMeteorAppDevelopment() && data.includes('EADDRINUSE')) {
logError(`[Rspack Client Error] ${data}`);
return;
}
// Check if this is actually an informational message (like webpack-dev-server messages)
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) => {
const errorMsg = `Rspack Error: ${err.message}`;
logError(errorMsg);
throw new Error(errorMsg);
}
});
// Store the new process in global state
setGlobalState(GLOBAL_STATE_KEYS.CLIENT_PROCESS, newClientProcess);
return newClientProcess;
}
/**
* Starts Rspack for server in build --watch mode
* @param {Object} options - Options for server watch
* @param {Function} options.onCompile - Callback function to be called when compilation is complete
* @returns {Object} The server process object
*/
export function startRspackServerWatch(options = {}) {
const { onCompile } = options;
// Get the current server process from global state
const serverProcess = getGlobalState(GLOBAL_STATE_KEYS.SERVER_PROCESS, null);
// Skip if server process is already running
if (serverProcess && isProcessRunning(serverProcess)) {
return serverProcess;
}
const appDir = getMeteorAppDir();
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")) {
onCompile(data);
}
},
onStderr: (data) => {
// Check if this is actually an informational message (like webpack-dev-server messages)
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) => {
const errorMsg = `Rspack Error: ${err.message}`;
logError(errorMsg);
throw new Error(errorMsg);
}
});
// Store the new process in global state
setGlobalState(GLOBAL_STATE_KEYS.SERVER_PROCESS, newServerProcess);
return newServerProcess;
}
/**
* Runs Rspack build for both client and server without watch mode
* @param {Object} options - Options for the build
* @param {boolean} options.isClient - Whether this is a client build
* @param {boolean} options.isServer - Whether this is a server build
* @param {boolean} options.isTestModule - Whether this is a test module
* @param {Function} options.onCompile - Callback function to be called when compilation is complete
* @param {boolean} options.watch - Whether to run Rspack in watch mode
* @returns {Promise<void>} A promise that resolves when the build is complete
* @throws {Error} If the build process fails
*/
export async function runRspackBuild({ isClient, isServer, isTest, isTestModule, isTestLike, onCompile, watch, label = 'Build' } = {}) {
const appDir = getMeteorAppDir();
const configFile = getConfigFilePath();
const endpoint = 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, isTestLike });
const rspackArgs = [
'rspack',
'build',
'--config',
configFile,
...(watch && ['--watch']) || [],
...params,
].filter(Boolean);
const { command, args } = getNpxCommand(rspackArgs);
spawnProcess(
command,
args,
{
cwd: appDir,
env: { ...process.env, ...envs },
onStdout: (data) => {
logInfo(`[Rspack ${label} ${endpoint}] ${data}`);
if (onCompile && data.trim().includes("compiled")) {
onCompile(data);
}
},
onStderr: (data) => {
// Check if this is actually an informational message (like webpack-dev-server messages)
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}`);
}
},
onExit: (code) => {
if (code === 0) {
resolve();
} else {
const error = new Error(`Rspack ${label} failed in ${endpoint} with exit code ${code}`);
logError(error.message);
reject(error);
}
},
onError: (err) => {
logError(`Rspack ${label} ${endpoint} error: ${err.message}`);
reject(err);
}
});
});
}
/**
* Cleans up processes when the plugin is stopped
* Stops any running client and server processes and clears their global state
* @returns {void}
*/
export function cleanup() {
const clientProcess = getGlobalState(GLOBAL_STATE_KEYS.CLIENT_PROCESS, null);
if (clientProcess) {
stopProcess(clientProcess);
setGlobalState(GLOBAL_STATE_KEYS.CLIENT_PROCESS, null);
}
const serverProcess = getGlobalState(GLOBAL_STATE_KEYS.SERVER_PROCESS, null);
if (serverProcess) {
stopProcess(serverProcess);
setGlobalState(GLOBAL_STATE_KEYS.SERVER_PROCESS, null);
}
}

View File

@@ -0,0 +1,33 @@
Package.describe({
summary: "Integrate rspack into the Meteor lifecycle to run the bundler independently",
version: '1.0.0',
});
Package.registerBuildPlugin({
name: 'rspack',
sources: [
'lib/constants.js',
'lib/dependencies.js',
'lib/build-context.js',
'lib/processes.js',
'lib/config.js',
'rspack_plugin.js',
],
use: ['modules@0.8.2', 'ecmascript', 'tools-core'],
});
Npm.devDepends({
'http-proxy-middleware': '3.0.5',
});
Package.onUse(function (api) {
api.use('ecmascript', ['client', 'server']);
api.use(['tools-core', 'webapp']);
api.mainModule('rspack_server.js', 'server');
});
Package.onTest(function (api) {
api.use(['tinytest', 'ecmascript', 'rspack']);
api.addFiles(['rspack_tests.js']);
});

View File

@@ -0,0 +1,347 @@
/**
* @module rspack_plugin
* @description Rspack Plugin for Meteor
*
* This is the main entry point for the Rspack plugin. It orchestrates the integration
* between Rspack and Meteor by:
* 1. Ensuring Rspack and related dependencies are installed
* 2. Setting up the build context directory
* 3. Configuring Meteor settings for Rspack
* 4. Starting Rspack processes based on the Meteor command (run or build)
* 5. Handling cleanup when the plugin is stopped
*
* The plugin uses top-level await to ensure asynchronous operations complete
* before Meteor continues execution.
*/
// Import modules from lib
const {
GLOBAL_STATE_KEYS,
} = require('./lib/constants');
const {
ensureRspackInstalled,
checkReactInstalled,
checkAngularInstalled,
checkTypescriptInstalled,
ensureRspackReactInstalled,
} = require('./lib/dependencies');
const {
ensureRspackBuildContextExists,
ensureRspackConfigExists,
cleanBuildContextFiles,
} = require('./lib/build-context');
const {
startRspackClientServe,
startRspackServerWatch,
runRspackBuild,
cleanup,
calculateDevServerPort,
calculateRsdoctorClientPort,
calculateRsdoctorServerPort,
getConfigFilePath,
getCustomConfigFilePath,
} = require('./lib/processes');
const {
configureMeteorForRspack
} = require('./lib/config');
const {
setupCompilationTracking,
waitForFirstCompilation,
} = require('./lib/compilation');
const {
getGlobalState,
setGlobalState
} = require('meteor/tools-core/lib/global-state');
const {
isMeteorAppRun,
isMeteorAppBuild,
isMeteorAppUpdate,
getMeteorInitialAppEntrypoints,
getMeteorAppEntrypoints,
isMeteorAppTest,
isMeteorAppTestWatch,
isMeteorAppTestFullApp,
isMeteorAppDevelopment,
isMeteorAppProduction,
isMeteorAppDebug,
isMeteorAppConfigModernVerbose,
isMeteorAppNative,
isMeteorBundleVisualizerProject,
} = require('meteor/tools-core/lib/meteor');
const {
logInfo,
logError,
} = require('meteor/tools-core/lib/log');
const {
getNpxCommand,
getNpmCommand,
getYarnCommand,
isYarnProject,
} = require('meteor/tools-core/lib/npm');
const { hasMeteorAppConfigAutoInstallDeps } = require("../tools-core/lib/meteor");
if (isMeteorAppRun() || isMeteorAppBuild() || isMeteorAppTest() || isMeteorAppUpdate()) {
// Get entry points from Meteor configuration
const initialEntrypoints = getMeteorInitialAppEntrypoints();
// Check if mainClient and mainServer exist
if (!initialEntrypoints.mainClient || !initialEntrypoints.mainServer) {
logError(`\n┌─────────────────────────────────────────────────`);
logError(`│ ❌ Missing Required Entry Points`);
logError(`└─────────────────────────────────────────────────`);
logError(`Your project is missing the required entry points for Rspack.`);
logError(`Please add the following to your package.json file:`);
logError(`
{
"meteor": {
"mainModule": {
"client": "client/main.js",
"server": "server/main.js"
}
}
}
`);
logError(`Make sure to replace the paths with your actual entry point files.`);
throw new Error(
"Missing required entry points. Please add meteor.mainModule.client and meteor.mainModule.server in your package.json file."
);
}
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
if (!getGlobalState(GLOBAL_STATE_KEYS.BUILD_CONTEXT_FILES_CLEANED)) {
cleanBuildContextFiles();
setGlobalState(GLOBAL_STATE_KEYS.BUILD_CONTEXT_FILES_CLEANED, true);
}
// Auto install deps (by default enabled)
if (hasMeteorAppConfigAutoInstallDeps()) {
// Ensure Rspack is installed
await ensureRspackInstalled();
}
// Check if Rspack React is installed
if (checkReactInstalled()) {
// Auto install deps (by default enabled)
if (hasMeteorAppConfigAutoInstallDeps()) {
await ensureRspackReactInstalled();
}
}
} catch (error) {
logError(`Rspack plugin error: ${error.message}`);
throw error;
}
}
if (isMeteorAppRun() || isMeteorAppBuild() || isMeteorAppTest()) {
try {
// Check if Angular is installed
checkAngularInstalled();
// Check if TypeScript is installed
checkTypescriptInstalled();
// Ensure the Rspack build context directory exists
ensureRspackBuildContextExists();
// Ensure the rspack.config.js file exists at the project level
ensureRspackConfigExists();
// 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', () => {
cleanup();
process.exit();
});
// When running `meteor run` command
if (isMeteorAppRun()) {
// Setup compilation tracking and callbacks
const {
clientFirstCompile,
serverFirstCompile,
clientFirstCompilePromise,
serverFirstCompilePromise,
onCompileClient,
onCompileServer,
} = setupCompilationTracking();
// For 'run' command, start Rspack in appropriate modes with distinct callbacks
if (isMeteorAppDevelopment() && !isMeteorAppNative()) {
startRspackClientServe({ onCompile: onCompileClient });
startRspackServerWatch({ onCompile: onCompileServer });
} else if (isMeteorAppProduction() || isMeteorAppNative()) {
runRspackBuild({
isClient: true,
isServer: false,
watch: true,
onCompile: onCompileClient,
});
runRspackBuild({
isServer: true,
isClient: false,
watch: true,
onCompile: onCompileServer,
});
}
// Wait for first compilation to complete
await waitForFirstCompilation(clientFirstCompile, serverFirstCompile, clientFirstCompilePromise, serverFirstCompilePromise);
// When running `meteor test` command
} else if (isMeteorAppTest()) {
const initialEntrypoints = getMeteorInitialAppEntrypoints();
// Setup compilation tracking and callbacks
const {
clientFirstCompile,
serverFirstCompile,
clientFirstCompilePromise,
serverFirstCompilePromise,
onCompileClient,
onCompileServer,
} = setupCompilationTracking();
// When run test for full app, run Rspack app server as well
// isTestLike ensures the app runtime environment inherit test envs
if (isMeteorAppTestFullApp()) {
await runRspackBuild({
isTest: false,
isTestLike: true,
isServer: true,
isClient: false,
});
if (isMeteorAppTestWatch()) {
runRspackBuild({
isServer: true,
isClient: false,
isTest: false,
isTestLike: true,
watch: true,
});
}
}
// When testModule is specified for client or server, run Rspack considering those files
if (initialEntrypoints?.testClient || initialEntrypoints?.testServer) {
runRspackBuild({
isTest: true,
isClient: true,
isServer: false,
watch: isMeteorAppTestWatch(),
onCompile: onCompileClient,
label: 'Test',
});
runRspackBuild({
isTest: true,
isClient: false,
isServer: true,
watch: isMeteorAppTestWatch(),
onCompile: onCompileServer,
label: 'Test',
});
// Wait for first compilation to complete
await waitForFirstCompilation(clientFirstCompile, serverFirstCompile, clientFirstCompilePromise, serverFirstCompilePromise);
// When testModule is specified as a single file or not specified
} else {
runRspackBuild({
isTest: true,
isTestModule: true,
isClient: true,
isServer: false,
watch: isMeteorAppTestWatch(),
onCompile: onCompileClient,
label: 'Test',
});
runRspackBuild({
isTest: true,
isTestModule: true,
isClient: false,
isServer: true,
watch: isMeteorAppTestWatch(),
onCompile: onCompileServer,
label: 'Test',
});
await waitForFirstCompilation(clientFirstCompile, serverFirstCompile, clientFirstCompilePromise, serverFirstCompilePromise, { target: 'server' });
}
// When running `meteor build` command
} else if (isMeteorAppBuild()) {
// For 'build' command, run Rspack build without watch mode
// Run client and server builds in parallel and wait for both to complete
await Promise.all([
runRspackBuild({ isClient: true, isServer: false }),
runRspackBuild({ isServer: true, isClient: false }),
]);
}
} catch (error) {
logError(`Rspack plugin error: ${error.message}`);
throw error;
}
}

View File

@@ -0,0 +1,204 @@
import { Meteor } from 'meteor/meteor';
import { WebApp, WebAppInternals } from 'meteor/webapp';
import path from 'path';
import { parse as parseUrl } from 'url';
import {
RSPACK_CHUNKS_CONTEXT,
RSPACK_ASSETS_CONTEXT,
RSPACK_HOT_UPDATE_REGEX,
} 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) {
const { shuffleString } = require('meteor/tools-core/lib/string');
const { createProxyMiddleware } = require('http-proxy-middleware');
// Target URL for the Rspack dev server
const target = `http://localhost:${process.env.RSPACK_DEVSERVER_PORT}`;
// Proxy HMR websocket upgrade requests
WebApp.connectHandlers.use('/ws',
createProxyMiddleware( {
target,
ws: true,
logLevel: 'debug'
})
);
// Proxy all dev asset requests under the rspack prefix
WebApp.connectHandlers.use('/__rspack__',
createProxyMiddleware({
target,
changeOrigin: true,
ws: true,
logLevel: 'debug',
})
);
WebApp.rawConnectHandlers.use((req, res, next) => {
// If this request is already under /__rspack__/, don't redirect it again.
if (req.url.startsWith('/__rspack__/')) {
return next();
}
// 1) match ANY URL whose last segment ends with ".hot-update.js" or ".hot-update.json",
// e.g. "/main.ce385971e9f19307.hot-update.js"
// "/ui_pages_tasks_tasks-page_jsx.ce385971e9f19307.hot-update.js"
// "/foo/bar/baz.1234abcd.hot-update.json"
const hotUpdate = req.url.match(RSPACK_HOT_UPDATE_REGEX);
if (hotUpdate) {
// Redirect "/something.hot-update.js" → "/__rspack__/something.hot-update.js"
const target = `/__rspack__/${hotUpdate[1]}`;
res.writeHead(307, { Location: target });
return res.end();
}
// 2) match "/build-chunks/<anything>"
const bundlesMatch = req.url.match(RSPACK_CHUNKS_REGEX);
if (bundlesMatch) {
// Redirect "/bundles/foo.js" → "/__rspack__/build-chunks/foo.js"
const target = `/__rspack__/${rspackChunksContext}/${bundlesMatch[1]}`;
res.writeHead(307, { Location: target });
return res.end();
}
// 3) match "/build-assets/<anything>"
const assetsMatch = req.url.match(RSPACK_ASSETS_REGEX);
if (assetsMatch) {
// Redirect "/build-assets/foo.js" → "/__rspack__/build-assets/foo.js"
const target = `/__rspack__/${rspackAssetsContext}/${assetsMatch[1]}`;
res.writeHead(307, { Location: target });
return res.end();
}
// Otherwise, let it pass through
next();
});
/**
* Force client to reload after Rspack server compilation and restart, which doesnt happen automatically.
* On each server reload, generate a new client hash once to force Meteors client reload.
* After the first reload, apply Meteor's default behavior.
*/
function enableClientReloadOnServerStart() {
Meteor.startup(() => {
const originalCalc = WebApp.calculateClientHashReplaceable;
let hasShuffled = false;
let cachedHash = {};
let prevRealHash = {};
WebApp.calculateClientHashReplaceable = function (...args) {
const arch = args[0];
const realHash = originalCalc.apply(this, args);
if (prevRealHash[arch] && realHash !== prevRealHash[arch]) {
prevRealHash[arch] = realHash;
return realHash;
}
prevRealHash[arch] = realHash;
if (cachedHash[arch] == null) {
cachedHash[arch] = shuffleString(realHash);
hasShuffled = true;
}
return cachedHash[arch];
};
});
}
// 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);
};

View File

@@ -0,0 +1 @@
Tinytest.add('rspack', () => {});

View File

@@ -1,8 +1,9 @@
Package.describe({
name: "shell-server",
version: '0.6.2',
version: '0.7.0',
summary: "Server-side component of the `meteor shell` command.",
documentation: "README.md"
documentation: "README.md",
devOnly: true,
});
Package.onUse(function(api) {

View File

@@ -1,8 +1,9 @@
Package.describe({
name: 'standard-minifier-css',
version: '1.9.3',
version: '1.10.0',
summary: 'Standard css minifier used with Meteor apps by default.',
documentation: 'README.md',
devOnly: true,
});
Package.registerBuildPlugin({

View File

@@ -1,8 +1,9 @@
Package.describe({
name: 'standard-minifier-js',
version: '3.1.1',
version: '3.2.0',
summary: 'Standard javascript minifiers used with Meteor apps by default.',
documentation: 'README.md',
devOnly: true,
});
Package.registerBuildPlugin({
@@ -12,7 +13,7 @@ Package.registerBuildPlugin({
'ecmascript'
],
npmDependencies: {
'@meteorjs/swc-core': '1.12.14',
'@meteorjs/swc-core': '1.15.3',
'acorn': '8.10.0',
"@babel/runtime": "7.18.9",
'@babel/parser': '7.22.7',

View File

@@ -49,6 +49,7 @@ export class MeteorMinifier {
const NODE_ENV = process.env.NODE_ENV || 'development';
let content = file.getContentsAsString();
const isLegacyWebArch = file?._arch === 'web.browser.legacy';
return swc.minifySync(
content,
@@ -60,6 +61,7 @@ export class MeteorMinifier {
unused: true,
dead_code: true,
typeofs: false,
...(isLegacyWebArch && { defaults: false }),
global_defs: {
'process.env.NODE_ENV': NODE_ENV,

View File

@@ -1,8 +1,9 @@
Package.describe({
name: 'standard-minifiers',
version: '1.1.1',
version: '1.2.0',
summary: 'Standard minifiers used with Meteor apps by default.',
documentation: 'README.md'
documentation: 'README.md',
devOnly: true,
});
Package.onUse(function(api) {

View File

@@ -1,8 +1,9 @@
Package.describe({
name: 'static-html',
summary: "Define static page content in .html files",
version: '1.4.0',
git: 'https://github.com/meteor/meteor.git'
version: '1.5.0',
git: 'https://github.com/meteor/meteor.git',
devOnly: true,
});
Package.registerBuildPlugin({

File diff suppressed because it is too large Load Diff

View File

@@ -44,12 +44,15 @@
<ul class="navbar-nav mr-auto">
{{#each groupPaths}}
<li class="nav-item"><span class="nav-link">&nbsp;-&nbsp;</span></li>
<li class="nav-item"><a class="nav-link" href="#">{{name}}</a></li>
<li class="nav-item"><a class="nav-link group" href="#">{{name}}</a></li>
{{/each}}
</ul>
<form class="navbar-form pull-right">
<span id="current-client-test"></span>
{{#if isFiltered}}
<button class="btn btn-secondary run-all">Run All Tests</button>
{{/if}}
<button class="btn btn-primary rerun">
{{#if rerunScheduled}}
Rerun scheduled...

View File

@@ -1,9 +1,13 @@
////
//// Setup
////
import { diff_match_patch } from './diff_match_patch_uncompressed'
import { diffChars } from 'diff'
import 'bootstrap/dist/css/bootstrap.min.css';
const arraysEqual = (a, b) => {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
// dependency for the count of tests running/passed/failed, etc. drives
// the navbar and the like.
var countDep = new Tracker.Dependency;
@@ -43,12 +47,118 @@ window.onerror = (message, source, line) => {
Session.set("uncaughtErrors", Array.from(uncaughtErrors));
};
Session.setDefault("groupPath", ["tinytest"]);
var getGroupPathFromURL = function() {
var pathname = window.location.pathname;
var match = pathname.match(/^\/group\/(.+)$/);
if (match) {
try {
return JSON.parse(decodeURIComponent(match[1]));
} catch (e) {
console.warn('Invalid group path in URL:', match[1]);
}
}
return ["tinytest"];
};
var setGroupPathInURL = function(groupPath, pushState = true) {
var newURL = '/';
if (!arraysEqual(groupPath, ['tinytest'])) {
newURL = '/group/' + encodeURIComponent(JSON.stringify(groupPath));
}
var historyState = { groupPath: groupPath };
if (pushState) {
window.history.pushState(historyState, '', newURL);
} else {
window.history.replaceState(historyState, '', newURL);
}
};
// Initialize group path from URL, fallback to default
var initialGroupPath = getGroupPathFromURL();
Session.setDefault("groupPath", initialGroupPath);
Session.set("rerunScheduled", false);
// Safeguards for rapid navigation
var isNavigating = false;
var lastNavigationTime = 0;
var navigationDebounceMs = 200; // Minimum time between navigations
// Handle browser back/forward navigation
window.addEventListener('popstate', function(event) {
var now = Date.now();
// Safeguard 1: Prevent overlapping navigations
if (isNavigating) {
console.log('Navigation already in progress, ignoring');
return;
}
// Safeguard 2: Debounce rapid successive calls
if (now - lastNavigationTime < navigationDebounceMs) {
console.log('Navigation too rapid, ignoring');
return;
}
var newGroupPath = getGroupPathFromURL();
var currentGroupPath = Session.get("groupPath");
if (!arraysEqual(newGroupPath, currentGroupPath)) {
// Set navigation flag
isNavigating = true;
lastNavigationTime = now;
// Emulate the EXACT same sequence as changeToPath
// 1. URL is already changed by browser, but ensure it's correct
setGroupPathInURL(newGroupPath, false); // replaceState, don't create new entry
// 2. Update session state (SAME as changeToPath)
Session.set("groupPath", newGroupPath);
Session.set("rerunScheduled", true);
// 3. Clean reload (SAME as changeToPath)
Reload._reload();
}
});
// This function is exported. It's called on client startup by the
// bundle generated by `meteor test` or `meteor test-packages`.
runTests = function () {
// Reset navigation safeguards when app starts
isNavigating = false;
// Reset all test state before starting
running = true;
totalCount = 0;
passedCount = 0;
failedCount = 0;
failedTests = [];
resultTree = [];
// Reset dependencies to trigger UI updates
countDep.changed();
topLevelGroupsDep.changed();
// Get current group path from URL (in case of refresh)
var currentGroupPath = getGroupPathFromURL();
Session.set("groupPath", currentGroupPath);
// Only update URL if it's actually different from what we expect
var expectedURL;
if (currentGroupPath && currentGroupPath.length > 0 && JSON.stringify(currentGroupPath) !== JSON.stringify(["tinytest"])) {
expectedURL = '/group/' + encodeURIComponent(JSON.stringify(currentGroupPath));
} else {
expectedURL = '/';
}
// Only do replaceState if the URL doesn't match what we expect
if (window.location.pathname !== expectedURL) {
setGroupPathInURL(currentGroupPath, false);
}
document.body.innerHTML = "";
document.head.title = "Tests";
@@ -62,7 +172,7 @@ runTests = function () {
Tracker.flush();
Meteor.connection._unsubscribeAll();
}, Session.get("groupPath"));
}, currentGroupPath);
};
@@ -322,6 +432,10 @@ Template.progressBar.helpers({
//// Template - groupNav
var changeToPath = function (path) {
// Update URL with new group path (pushState creates new history entry)
setGroupPathInURL(path, true);
// Update session to trigger UI updates
Session.set("groupPath", path);
Session.set("rerunScheduled", true);
// pretend there's just been a hot code push
@@ -340,6 +454,10 @@ Template.groupNav.helpers({
},
rerunScheduled: function () {
return Session.get("rerunScheduled");
},
isFiltered: function () {
var groupPath = Session.get("groupPath");
return groupPath.length > 1 || groupPath[0] !== "tinytest";
}
});
@@ -350,6 +468,9 @@ Template.groupNav.events({
'click .rerun': function () {
Session.set("rerunScheduled", true);
Reload._reload();
},
'click .run-all': function () {
changeToPath(["tinytest"]);
}
});
@@ -507,10 +628,13 @@ Template.event.events({
// e.g. doDiff('abc', 'bcd') => [[-1, 'a'], [0, 'bc'], [1, 'd']]
var doDiff = function (str1, str2) {
var D = new diff_match_patch();
var pieces = D.diff_main(str1, str2, false);
D.diff_cleanupSemantic(pieces);
return pieces;
const diff = diffChars(str1, str2);
return diff.map(part => {
if (part.added) return [1, part.value];
if (part.removed) return [-1, part.value];
return [0, part.value];
});
};
Template.event.helpers({

View File

@@ -1,11 +1,12 @@
Package.describe({
summary: "Run tests interactively in the browser",
version: '1.4.0',
version: '1.5.0',
documentation: null
});
Npm.depends({
'bootstrap': '4.3.1',
'diff': '8.0.2'
});
Package.onUse(function (api) {

View File

@@ -0,0 +1,5 @@
# tools-core
The tools-core package exposes helpers for managing modern tools in Meteor, providing modules for npm, log, process management, and so on; and exporting them from a Meteor package rather than being directly tied to the Meteor tool.
These helpers will be useful to integrate a modern bundler like Rspack and a native solution like CapacitorJS.

View File

@@ -0,0 +1,138 @@
import fs from 'fs';
import path from 'path';
import { logProgress, logSuccess, logInfo, logError } from './log';
/**
* Checks if the given directory is a git repository
* @param {string} dir - Directory to check
* @returns {boolean} - True if the directory is a git repository
*/
export function isGitRepository(dir) {
try {
const gitDir = path.join(dir, '.git');
return fs.existsSync(gitDir) && fs.statSync(gitDir).isDirectory();
} catch (error) {
return false;
}
}
/**
* Checks if a .gitignore file exists in the given directory
* @param {string} dir - Directory to check
* @returns {boolean} - True if .gitignore exists
*/
export function gitignoreExists(dir) {
try {
const gitignorePath = path.join(dir, '.gitignore');
return fs.existsSync(gitignorePath);
} catch (error) {
return false;
}
}
/**
* Creates a .gitignore file in the given directory if it doesn't exist
* @param {string} dir - Directory where to create .gitignore
* @param {string[]} [initialEntries=[]] - Initial entries to add to the .gitignore file
* @returns {boolean} - True if .gitignore was created or already exists
*/
export function ensureGitignoreExists(dir, initialEntries = []) {
const gitignorePath = path.join(dir, '.gitignore');
if (!gitignoreExists(dir)) {
try {
const content = initialEntries.length > 0 ? initialEntries.join('\n') + '\n' : '';
fs.writeFileSync(gitignorePath, content, 'utf8');
return true;
} catch (error) {
console.error(`Error creating .gitignore file: ${error.message}`);
return false;
}
}
return true;
}
/**
* Checks if specific entries exist in the .gitignore file
* @param {string} dir - Directory containing the .gitignore file
* @param {string[]} entries - Entries to check
* @returns {string[]} - Entries that don't exist in the .gitignore file
*/
export function getMissingGitignoreEntries(dir, entries) {
if (!gitignoreExists(dir)) {
return entries;
}
try {
const gitignorePath = path.join(dir, '.gitignore');
const content = fs.readFileSync(gitignorePath, 'utf8');
const lines = content.split('\n').map(line => line.trim());
return entries.filter(entry => !lines.includes(entry));
} catch (error) {
console.error(`Error reading .gitignore file: ${error.message}`);
return entries;
}
}
/**
* Adds entries to the .gitignore file if they don't exist
* @param {string} dir - Directory containing the .gitignore file
* @param {string[]} entries - Entries to add
* @param {string} [context] - Optional context to add as a comment before the entries
* @returns {boolean} - True if entries were added successfully
*/
export function addGitignoreEntries(dir, entries, context = '') {
// Ensure .gitignore exists
if (!ensureGitignoreExists(dir)) {
return false;
}
// Get entries that don't exist
const missingEntries = getMissingGitignoreEntries(dir, entries);
if (missingEntries.length === 0) {
return true; // All entries already exist
}
// Display a header for the gitignore entries addition
logProgress(`┌─────────────────────────────────────────────────`);
logProgress(`│ Adding Gitignore Entries${context ? ` for ${context}` : ''}`);
logProgress(`└─────────────────────────────────────────────────`);
// Show what entries will be added
logInfo(`The following entries will be added to .gitignore:`);
missingEntries.forEach(entry => {
logInfo(`${entry}`);
});
try {
const gitignorePath = path.join(dir, '.gitignore');
let content = '';
if (fs.existsSync(gitignorePath)) {
content = fs.readFileSync(gitignorePath, 'utf8');
// Ensure there's a newline at the end if the file is not empty
if (content.length > 0 && !content.endsWith('\n')) {
content += '\n';
}
}
// Add context as a comment if provided
if (context) {
content += `\n# ${context}\n`;
}
content += missingEntries.join('\n') + '\n';
fs.writeFileSync(gitignorePath, content, 'utf8');
logSuccess(`✅ Gitignore entries${context ? ` for ${context}` : ''} added`);
return true;
} catch (error) {
logError(`\n┌─────────────────────────────────────────────────`);
logError(`│ ❌ Failed to Add Gitignore Entries${context ? ` for ${context}` : ''}`);
logError(`└─────────────────────────────────────────────────`);
logError(`Error: ${error.message}`);
return false;
}
}

View File

@@ -0,0 +1,45 @@
/**
* Global state management for Meteor packages.
* This module provides a way to store and retrieve global state that persists across file changes.
*/
/**
* Gets a value from the global state.
* @param {string} key - The key to retrieve.
* @param {any} defaultValue - The default value to return if the key doesn't exist.
* @returns {any} The value associated with the key, or the default value if not found.
*/
export function getGlobalState(key, defaultValue) {
return Package.meteor?.global?.[key] !== undefined
? Package.meteor.global.persistentState[key]
: defaultValue;
}
/**
* Sets a value in the global state.
* @param {string} key - The key to set.
* @param {any} value - The value to associate with the key.
*/
export function setGlobalState(key, value) {
// Create a namespace for our global state if it doesn't exist
if (!Package?.meteor.global.persistentState) {
Package.meteor.global.persistentState = {};
}
Package.meteor.global.persistentState[key] = value;
}
/**
* Removes a key from the global state.
* @param {string} key - The key to remove.
*/
export function removeGlobalState(key) {
delete Package.meteor.global.persistentState[key];
}
/**
* Clears all keys from the global state.
*/
export function clearGlobalState() {
Package.meteor.global.persistentState = {};
}

View File

@@ -0,0 +1,87 @@
/**
* Build gitignore-style "unignore" patterns for specific files/folders.
*
* Rules:
* - Files: !a/ !a/b/ !a/b/c.txt
* - Folders (must end with '/'):
* !a/ !a/b/ !a/b/c/ !a/b/c/**
*
* @param {string[]} inputPaths Paths to keep. Use '/' for dirs (e.g. 'assets/public/').
* @param {Object} [options]
* @param {boolean} [options.includeAllAncestors=true] If false, only include the immediate parent dir.
* @param {boolean} [options.includeGlobForDirs=true] Emit '**' for directories.
* @param {number} [options.skipLevel=0] Skip this many levels from the beginning.
* @returns {string[]} Negation patterns, in correct order.
*/
export function buildUnignorePatterns(inputPaths, {
includeAllAncestors = true,
includeGlobForDirs = true,
skipLevel = 0,
} = {}) {
const out = [];
const seen = new Set();
const push = (p) => {
if (!seen.has(p)) {
seen.add(p);
out.push(p);
}
};
for (let raw of inputPaths) {
if (!raw || typeof raw !== 'string') continue;
// Normalize: forward slashes, drop leading './', collapse double slashes
let anchored = raw.startsWith('/');
let p = raw.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/\/{2,}/g, '/');
// detect dir by trailing slash
const isDir = p.endsWith('/');
// strip leading + trailing slashes for splitting, but remember anchoring
const core = p.replace(/^\/+/, '').replace(/\/+$/, '');
if (!core) continue;
const parts = core.split('/');
// Process based on skipLevel
if (skipLevel >= parts.length) {
// Skip everything if skipLevel is greater than or equal to the number of parts
continue;
}
// Ancestors (top-down)
if (includeAllAncestors) {
// Start from skipLevel + 1 to skip the specified number of levels
const startLevel = Math.max(1, skipLevel + 1);
for (let i = startLevel; i <= parts.length - 1; i++) {
const anc = (anchored ? '/' : '') + parts.slice(0, i).join('/') + '/';
push('!' + anc);
}
} else if (parts.length > 1) {
// Only immediate parent
// For minimal mode with skipLevel, we need to check if the parent is at a level we should skip
if (skipLevel < parts.length - 1) {
// Check if the parent's level is greater than skipLevel
const parentLevel = parts.length - 1;
if (parentLevel > skipLevel) {
const parent = (anchored ? '/' : '') + parts.slice(0, parts.length - 1).join('/') + '/';
push('!' + parent);
}
}
}
// Add the file/directory pattern
if (isDir) {
const dir = (anchored ? '/' : '') + parts.join('/') + '/';
push('!' + dir);
if (includeGlobForDirs) push('!' + dir + '**');
} else {
const file = (anchored ? '/' : '') + parts.join('/');
push('!' + file);
}
}
return out;
}

View File

@@ -0,0 +1,43 @@
// Check if colors should be disabled
const shouldDisableColors = !!process.env.METEOR_DISABLE_COLORS;
// ANSI color codes
const colors = {
reset: shouldDisableColors ? '' : '\x1b[0m',
blue: shouldDisableColors ? '' : '\x1b[34m',
red: shouldDisableColors ? '' : '\x1b[31m',
purple: shouldDisableColors ? '' : '\x1b[35m',
green: shouldDisableColors ? '' : '\x1b[32m'
};
/**
* Log a progress message in blue
* @param {string} message - The message to log
*/
export function logProgress(message) {
console.log(`${colors.blue}${message}${colors.reset}`);
}
/**
* Log an error message in red
* @param {string} message - The message to log
*/
export function logError(message) {
console.error(`${colors.red}${message}${colors.reset}`);
}
/**
* Log an info message in purple
* @param {string} message - The message to log
*/
export function logInfo(message) {
console.log(`${colors.purple}${message}${colors.reset}`);
}
/**
* Log a success message in green
* @param {string} message - The message to log
*/
export function logSuccess(message) {
console.log(`${colors.green}${message}${colors.reset}`);
}

View File

@@ -0,0 +1,489 @@
const fs = require('fs');
const path = require('path');
/**
* Returns the current working directory of the Meteor application.
* @returns {string} The absolute path to the Meteor application directory.
*/
export function getMeteorAppDir() {
return process.cwd();
}
/**
* Reads and parses the package.json file of the Meteor application.
* @returns {Object} The parsed content of the package.json file.
*/
export function getMeteorAppPackageJson() {
return JSON.parse(
fs.readFileSync(`${getMeteorAppDir()}/package.json`, 'utf-8')
);
}
/**
* Retrieves the Meteor configuration from the application's package.json.
* @returns {Object|undefined} The Meteor configuration object or undefined if not found.
*/
export function getMeteorAppConfig() {
return typeof Plugin?.getMeteorConfig === 'function'
? Plugin.getMeteorConfig()
: getMeteorAppPackageJson()?.meteor;
}
/**
* Get Meteor's app port
* @returns {false|*}
*/
export function getMeteorAppPort() {
return Package?.meteor?.global?.currentCommand?.options?.['port'] || process.env.PORT || '3000';
}
/**
* Retrieves the modern configuration from the application's package.json.
* @returns {Object|undefined} The modern configuration object or undefined if not found.
*/
export function getMeteorAppConfigModern() {
return getMeteorAppConfig()?.modern;
}
/**
* Retrieves the verbose flag from the application's package.json.
* @returns {boolean|undefined} The verbose flag or undefined if not found.
*/
export function isMeteorAppConfigModernVerbose() {
return getMeteorAppConfigModern()?.verbose ||
getMeteorAppConfigModern()?.transpiler?.verbose || false;
}
/**
* Retrieves the auto install deps flag from the app's package.json.
* @returns {Boolean|*}
*/
export function hasMeteorAppConfigAutoInstallDeps() {
const { autoInstallDeps = true } = getMeteorAppConfig() || {};
return !!autoInstallDeps;
}
/**
* Retrieves the entry points for the Meteor application from the configuration.
* Uses Plugin.getMeteorConfig() if available, otherwise falls back to getMeteorAppConfig().
* @returns {Object} An object containing the main and test entry points for client and server.
* @returns {string|undefined} mainClient - The client main module path.
* @returns {string|undefined} mainServer - The server main module path.
* @returns {string|undefined} testClient - The client test module path.
* @returns {string|undefined} testServer - The server test module path.
*/
export function getMeteorAppEntrypoints() {
const meteorConfig = getMeteorAppConfig();
return {
mainClient: meteorConfig?.mainModule?.client,
mainServer: meteorConfig?.mainModule?.server,
testClient: meteorConfig?.testModule?.client || meteorConfig?.testModule,
testServer: meteorConfig?.testModule?.server || meteorConfig?.testModule,
};
}
/**
* Retrieves the initial entry points for the Meteor application from the package.json.
* @returns {Object} An object containing the main and test entry points for client and server.
* @returns {string|undefined} mainClient - The client main module path.
* @returns {string|undefined} mainClientHtml - The client main html path.
* @returns {string|undefined} mainServer - The server main module path.
* @returns {string|undefined} testClient - The client test module path.
* @returns {string|undefined} testServer - The server test module path.
*/
export function getMeteorInitialAppEntrypoints() {
const meteorConfig = getMeteorAppPackageJson()?.meteor;
const mainClient = meteorConfig?.mainModule?.client;
let mainClientHtml;
if (mainClient) {
const clientDir = path.dirname(mainClient);
const clientBasename = path.basename(mainClient, path.extname(mainClient));
const htmlPath = path.join(
getMeteorAppDir(),
clientDir,
`${clientBasename}.html`
);
if (fs.existsSync(htmlPath)) {
mainClientHtml = path.join(clientDir, `${clientBasename}.html`);
} else {
// Find first html in entry folder
const files = fs.readdirSync(path.join(getMeteorAppDir(), clientDir));
const htmlFile = files.find((file) => path.extname(file) === ".html");
if (htmlFile) {
mainClientHtml = path.join(clientDir, htmlFile);
}
}
}
return {
mainClient,
mainClientHtml,
mainServer: meteorConfig?.mainModule?.server,
...(meteorConfig?.testModule?.client && {
testClient: meteorConfig?.testModule?.client,
}),
...(meteorConfig?.testModule?.server && {
testServer: meteorConfig?.testModule?.server,
}),
...(!meteorConfig?.testModule?.client &&
!meteorConfig?.testModule?.server && {
testModule: meteorConfig?.testModule,
}),
};
}
/**
* Checks if the current Meteor project is configured as test module.
* @returns {boolean}
*/
export function isMeteorAppTestModule() {
return getMeteorInitialAppEntrypoints().testModule != null;
}
/**
* Sets the Meteor application entry points in environment variables.
* @param {Object} options - The entry points configuration object.
* @param {string} [options.mainClient] - The client main module path.
* @param {string} [options.mainServer] - The server main module path.
* @param {string} [options.testModule] - The test module path.
* @param {string} [options.testClient] - The client test module path.
* @param {string} [options.testServer] - The server test module path.
*/
export function setMeteorAppEntrypoints({
mainClient,
mainServer,
testModule,
testClient,
testServer,
}) {
if (mainClient) {
process.env.METEOR_CONFIG_CLIENT = mainClient;
}
if (mainServer) {
process.env.METEOR_CONFIG_SERVER = mainServer;
}
if (testModule) {
process.env.METEOR_CONFIG_TEST = testModule;
} else {
if (testClient) {
process.env.METEOR_CONFIG_TEST_CLIENT = testClient;
}
if (testServer) {
process.env.METEOR_CONFIG_TEST_SERVER = testServer;
}
}
global.reinitializeMeteorConfig?.();
}
/**
* Sets patterns to be ignored by the Meteor application in the environment variable.
* Appends the new ignore pattern to any existing ones.
* @param {string} ignore - The pattern to be ignored.
*/
export function setMeteorAppIgnore(ignore) {
process.env.METEOR_IGNORE = `${process.env.METEOR_IGNORE || ''} ${ignore}`.trim();
}
/**
* Checks if the current Meteor command is 'run'.
* @returns {boolean} True if the current command is 'run', false otherwise.
*/
export function isMeteorAppRun() {
return Package?.meteor?.global?.currentCommand?.name === 'run';
}
/**
* Checks if the current Meteor command is 'build'.
* @returns {boolean} True if the current command is 'build', false otherwise.
*/
export function isMeteorAppBuild() {
return ['build', 'deploy'].includes(Package?.meteor?.global?.currentCommand?.name);
}
/**
* Checks if the current Meteor command is 'update'.
* @returns {boolean} True if the current command is 'update', false otherwise.
*/
export function isMeteorAppUpdate() {
return Package?.meteor?.global?.currentCommand?.name === 'update';
}
/**
* Checks if the current Meteor command is 'test'.
* @returns {boolean} True if the current command is 'test', false otherwise.
*/
export function isMeteorAppTest() {
return Package?.meteor?.global?.currentCommand?.name === 'test';
}
/**
* Checks if the current Meteor command is 'test' and is running in full app mode.
* @returns {false|*}
*/
export function isMeteorAppTestFullApp() {
return isMeteorAppTest() && !!Package?.meteor?.global?.currentCommand?.options?.['full-app'];
}
/**
* Checks if the current Meteor command is 'test' and is running in watch mode.
* @returns {boolean} True if the current command is 'test' and is running in watch mode, false otherwise.
*/
export function isMeteorAppTestWatch() {
return isMeteorAppTest() && !Package?.meteor?.global?.currentCommand?.options?.once;
}
/**
* Check if the current Meteor current command is running Android.
* @returns {boolean}
*/
export function isMeteorAppNativeAndroid() {
return Package?.meteor?.global?.currentCommand?.options?.args?.some(_arg =>
['android', 'android-device'].includes(_arg)
);
}
/**
* Check if the current Meteor current command is running iOS.
* @returns {boolean}
*/
export function isMeteorAppNativeIos() {
return Package?.meteor?.global?.currentCommand?.options?.args?.some(_arg =>
['ios', 'ios-device'].includes(_arg)
);
}
/**
* Checks if the current Meteor command is running native.
* @returns {boolean}
*/
export function isMeteorAppNative() {
return isMeteorAppNativeAndroid() || isMeteorAppNativeIos();
}
/**
* Checks if the Meteor application is running in development mode.
* @returns {boolean} True if the application is in development mode, false otherwise.
*/
export function isMeteorAppDevelopment() {
return Package.meteor?.Meteor.isDevelopment && !isMeteorAppBuild();
}
/**
* Checks if the Meteor application is running in production mode.
* @returns {boolean} True if the application is in production mode, false otherwise.
*/
export function isMeteorAppProduction() {
return Package.meteor?.Meteor.isProduction || isMeteorAppBuild();
}
/**
* Checks if the Meteor application is running in debug mode.
* @returns {boolean} True if the application is in debug mode, false otherwise.
*/
export function isMeteorAppDebug() {
return Package.meteor?.Meteor.isDebug || (
!!process.env.NODE_INSPECTOR_IPC ||
!!process.env.VSCODE_INSPECTOR_OPTIONS ||
Object.keys(global.currentCommand?.options || {}).some(function(_arg) {
return ['inspect', 'debug', 'brk'].includes(_arg);
})
);
}
/**
* Sets a custom script URL for the Meteor application in the environment variable.
* @param {string} scriptUrl - The URL of the custom script.
*/
export function setMeteorAppCustomScriptUrl(scriptUrl) {
process.env.METEOR_APP_CUSTOM_SCRIPT_URL = scriptUrl;
}
/**
* Retrieves a list of all packages installed in the Meteor application.
* @returns {string[]} An array of package names.
*/
export function getMeteorAppPackages() {
return Object.keys(Package?.meteor?.global?.packageVersionMap || {});
}
/**
* Gets all files and folders from the root level of the Meteor application.
* @param {Object} options - Options for getting files and folders.
* @param {boolean} [options.recursive=true] - Whether to scan directories recursively.
* @param {Array<string>} [options.ignore=[]] - Patterns to ignore (e.g., ['node_modules', '.git']).
* @param {boolean} [options.includeStats=false] - Whether to include file/folder stats in the result.
* @param {string} [options.startPath] - Custom start path (defaults to Meteor app root).
* @returns {Object} An object with 'files' and 'directories' arrays containing paths relative to the root.
*/
export function getMeteorAppFilesAndFolders(options = {}) {
const {
recursive = true,
ignore = ['node_modules', '.git', '.meteor/local'],
includeStats = false,
startPath = getMeteorAppDir()
} = options;
// Helper function to check if a path should be ignored
const shouldIgnore = (itemPath) => {
const relativePath = path.relative(getMeteorAppDir(), itemPath);
return ignore.some(pattern => {
if (pattern.endsWith('/**')) {
const dirPattern = pattern.slice(0, -3);
return relativePath === dirPattern || relativePath.startsWith(`${dirPattern}/`);
}
return relativePath === pattern || relativePath.startsWith(`${pattern}/`);
});
};
// Helper function to recursively scan directories
const scanDirectory = (dirPath) => {
const result = {
files: [],
directories: []
};
if (shouldIgnore(dirPath)) {
return result;
}
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
// Skip if the item should be ignored
if (shouldIgnore(itemPath)) {
continue;
}
try {
const stats = fs.statSync(itemPath);
const relativePath = path.relative(getMeteorAppDir(), itemPath);
if (stats.isDirectory()) {
// Add directory to the result
result.directories.push(
includeStats ? { path: relativePath, stats } : relativePath
);
// Recursively scan subdirectories if recursive option is true
if (recursive) {
const subResult = scanDirectory(itemPath);
result.files.push(...subResult.files);
result.directories.push(...subResult.directories);
}
} else if (stats.isFile()) {
// Add file to the result
result.files.push(
includeStats ? { path: relativePath, stats } : relativePath
);
}
} catch (error) {
// Skip items that can't be accessed
console.error(`Error accessing ${itemPath}: ${error.message}`);
}
}
} catch (error) {
console.error(`Error reading directory ${dirPath}: ${error.message}`);
}
return result;
};
// Start the scan from the specified path
return scanDirectory(startPath);
}
/**
* Requires a module relative to the Meteor tools directory.
* @param {string} filePath - The path of the file to require, relative to the Meteor tools directory.
* @returns {Object} The exported module from the required file.
*/
export function getMeteorToolsRequire(filePath) {
const mainModule = global.process.mainModule;
const absPath = mainModule.filename.split(path.sep).slice(0, -1).join(path.sep);
return mainModule.require(path.resolve(absPath, filePath));
}
/**
* Checks if the Meteor application is a Blaze project.
* @returns {boolean} True if the application is a Blaze project, false otherwise.
*/
export function isMeteorBlazeProject() {
return getMeteorAppPackages().includes('blaze') || getMeteorAppPackages().includes('blaze-html-templates');
}
/**
* Checks if the Meteor application is a Blaze Hot project.
* @returns {boolean} True if the application is a Blaze Hot project, false otherwise.
*/
export function isMeteorBlazeHotProject() {
return isMeteorBlazeProject() && getMeteorAppPackages().includes('blaze-hot');
}
/**
* Checks if the Meteor application is a Coffeescript project.
* @returns {boolean}
*/
export function isMeteorCoffeescriptProject() {
return getMeteorAppPackages().includes('coffeescript');
}
/**
* Checks if the Meteor application is a Less project.
* @returns {boolean} True if the application has the 'less' package, false otherwise.
*/
export function isMeteorLessProject() {
return getMeteorAppPackages().includes('less');
}
/**
* Checks if the Meteor application is a SCSS project.
* @returns {boolean} True if the application has any package containing 'scss', false otherwise.
*/
export function isMeteorScssProject() {
return getMeteorAppPackages().some(pkg => pkg.includes('scss'));
}
/**
* Checks if the Meteor application is a Bundle Visualizer project.
* @returns {boolean}
*/
export function isMeteorBundleVisualizerProject() {
return getMeteorAppPackages().includes('bundle-visualizer');
}
/**
* Checks if the Meteor application is a Typescript project.
* @returns {boolean} True if the application is a Typescript project, false otherwise.
*/
export function isMeteorTypescriptProject() {
return getMeteorAppPackages().includes('typescript');
}
/**
* Checks if the current Meteor command is 'test-packages'.
* @returns {boolean} True if the current command is 'test-packages', false otherwise.
*/
export function isMeteorPackagesTest() {
return Package?.meteor?.global?.currentCommand?.name === 'test-packages';
}
/**
* Gets the package directories from the environment variables.
* @returns {string[]}
*/
export function getMeteorEnvPackageDirs() {
function packageDirsFromEnvVar(envVar, delimiter = path.delimiter) {
return process.env[envVar] && process.env[envVar].split(delimiter) || [];
}
return [
// METEOR_PACKAGE_DIRS should use the arch-specific delimiter
...(packageDirsFromEnvVar('METEOR_PACKAGE_DIRS', path.delimiter || ':')),
// PACKAGE_DIRS (deprecated) always used ':' separator (yes, even Windows)
...(packageDirsFromEnvVar('PACKAGE_DIRS', ':')),
];
}

View File

@@ -0,0 +1,487 @@
const fs = require('fs');
const path = require('path');
const { spawnProcess } = require('./process');
/**
* Gets the path to a Node.js binary using Plugin.getCurrentNodeBinDir() if available,
* otherwise returns null.
*
* @param {string} binaryName - The name of the binary (e.g., 'npm', 'npx', 'node')
* @returns {string|null} The path to the specified binary, or null if not available
*/
export function getNodeBinaryPath(binaryName) {
try {
// Try to access Plugin.getCurrentNodeBinDir()
if (typeof Plugin !== 'undefined' &&
typeof Plugin.getCurrentNodeBinDir === 'function' &&
Plugin.getCurrentNodeBinDir()) {
return path.join(Plugin.getCurrentNodeBinDir(), binaryName);
}
// If we're in a context where we can directly access the function
if (typeof getCurrentNodeBinDir === 'function') {
return path.join(getCurrentNodeBinDir(), binaryName);
}
return null;
} catch (e) {
// If any error occurs, return null
return null;
}
}
/**
* Checks if a npm dependency exists in the project.
* First checks optimistically in node_modules folder, then checks package.json.
*
* @param {string} dependency - The npm dependency name to check
* @param {Object} [options] - Options for the check
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
* @param {boolean} [options.checkNodeModules] - Whether to check in node_modules first (defaults to false)
* @returns {boolean} True if the dependency exists, false otherwise
*/
export function checkNpmDependencyExists(dependency, options = {}) {
const cwd = options.cwd || process.cwd();
// First, optimistically check if the dependency exists in node_modules
if (options.checkNodeModules) {
const nodeModulesPath = path.join(cwd, 'node_modules', dependency);
try {
if (fs.existsSync(nodeModulesPath)) {
// Check if it has a package.json to confirm it's a valid package
const packageJsonPath = path.join(nodeModulesPath, 'package.json');
if (fs.existsSync(packageJsonPath)) {
return true;
}
}
} catch (error) {
// If there's an error checking the file system, continue to the fallback method
}
}
// Fallback: Check package.json directly instead of using `npm ls`
try {
const packageJsonPath = path.join(cwd, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Check if the dependency is listed in any of the dependency sections
return !!(
(packageJson.dependencies && packageJson.dependencies[dependency]) ||
(packageJson.devDependencies && packageJson.devDependencies[dependency]) ||
(packageJson.optionalDependencies && packageJson.optionalDependencies[dependency]) ||
(packageJson.peerDependencies && packageJson.peerDependencies[dependency])
);
}
} catch (error) {
// If there's an error reading or parsing package.json, return false
return false;
}
// If we've reached this point, the dependency was not found
return false;
}
/**
* Checks if a npm binary exists in the project.
* Looks for the binary in the node_modules/.bin directory.
*
* @param {string} binary - The npm binary name to check
* @param {Object} [options] - Options for the check
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
* @returns {boolean} True if the binary exists, false otherwise
*/
export function checkNpmBinaryExists(binary, options = {}) {
const cwd = options.cwd || process.cwd();
const binaryPath = path.join(cwd, 'node_modules', '.bin', binary);
try {
// Check if the binary file exists and is executable
const stats = fs.statSync(binaryPath);
return stats.isFile() && (stats.mode & 0o111); // Check if executable bit is set
} catch (error) {
return false;
}
}
/**
* Builds npm install arguments based on options and dependencies
*
* @param {string|string[]} dependencies - The npm dependency or dependencies to install
* @param {Object} [options] - Options for the installation
* @param {boolean} [options.dev=false] - If true, install as a dev dependency
* @param {boolean} [options.exact=false] - If true, install with exact version
* @param {boolean} [options.isMeteorCommand=false] - If true, prepends 'npm' to the args for meteor command
* @returns {string[]} Array of arguments for the npm install command
*/
function buildNpmInstallArgs(dependencies, options = {}) {
const args = options.isMeteorCommand ? ['npm', 'install'] : ['install'];
// Add flags based on options
if (options.dev) {
args.push('--save-dev');
}
if (options.exact) {
args.push('--save-exact');
}
// Add dependencies to the command
if (Array.isArray(dependencies)) {
args.push(...dependencies);
} else {
args.push(dependencies);
}
return args;
}
/**
* Builds yarn install arguments based on options and dependencies
*
* @param {string|string[]} dependencies - The npm dependency or dependencies to install
* @param {Object} [options] - Options for the installation
* @param {boolean} [options.dev=false] - If true, install as a dev dependency
* @param {boolean} [options.exact=false] - If true, install with exact version
* @returns {string[]} Array of arguments for the yarn add command
*/
function buildYarnInstallArgs(dependencies, options = {}) {
const args = ['add'];
// Add flags based on options
if (options.dev) {
args.push('--dev');
}
if (options.exact) {
args.push('--exact');
}
// Add dependencies to the command
if (Array.isArray(dependencies)) {
args.push(...dependencies);
} else {
args.push(dependencies);
}
return args;
}
/**
* Executes a command and returns a promise that resolves to true if successful
*
* @param {string} command - The command to execute
* @param {string[]} args - The arguments for the command
* @param {Object} options - Options for the spawn process
* @param {string} options.cwd - Current working directory
* @returns {Promise<boolean>} A promise that resolves to true if command succeeded, false otherwise
*/
function executeCommand(command, args, options) {
return new Promise((resolve) => {
spawnProcess(command, args, {
cwd: options.cwd,
onExit: (code) => {
resolve(code === 0);
},
onError: () => {
resolve(false);
}
});
});
}
/**
* Installs a npm dependency using direct npm binary if available, otherwise falls back to `meteor npm install`.
* If yarn option is true, uses yarn instead.
*
* @param {string|string[]} dependencies - The npm dependency or dependencies to install
* @param {Object} [options] - Options for the installation
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
* @param {boolean} [options.dev=false] - If true, install as a dev dependency
* @param {boolean} [options.exact=false] - If true, install with exact version
* @param {boolean} [options.yarn=false] - If true, use yarn instead of npm
* @returns {Promise<boolean>} A promise that resolves to true if installation succeeded, false otherwise
*/
export function installNpmDependency(dependencies, options = {}) {
const cwd = options.cwd || process.cwd();
// If yarn option is true, use yarn
if (options.yarn) {
const { command, args: baseArgs } = getYarnCommand([]);
const args = buildYarnInstallArgs(dependencies, options);
return executeCommand(command, [...baseArgs, ...args], { cwd });
}
// Try to get the npm binary path
const npmBinaryPath = getNodeBinaryPath('npm');
// If we have a direct path to npm, use it
if (npmBinaryPath && fs.existsSync(npmBinaryPath)) {
const args = buildNpmInstallArgs(dependencies, options);
return executeCommand(npmBinaryPath, args, { cwd });
}
// Fall back to the current method using 'meteor npm install'
const args = buildNpmInstallArgs(dependencies, { ...options, isMeteorCommand: true });
return executeCommand('meteor', args, { cwd });
}
/**
* Checks if a specific npm dependency version meets a semver condition.
* First checks in node_modules if checkNodeModules is true, then checks project's package.json.
*
* @param {string} dependency - The npm dependency name to check
* @param {Object} [options] - Options for the check
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
* @param {string} [options.versionRequirement] - The version requirement to check against (e.g., '6.0.0')
* @param {string} [options.semverCondition='gte'] - The semver condition to use (e.g., 'gte', 'lt', 'eq')
* @param {boolean} [options.checkNodeModules] - Whether to check in node_modules first (defaults to false)
* @param {boolean} [options.existenceOnly] - If true, only checks if the dependency exists without version validation
* @returns {boolean} True if the dependency version meets the condition (or exists if existenceOnly is true), false otherwise
*/
export function checkNpmDependencyVersion(dependency, options = {}) {
const semver = require('semver');
const cwd = options.cwd || process.cwd();
const versionRequirement = options.versionRequirement;
const semverCondition = options.semverCondition || 'gte';
if (!dependency) {
throw new Error('Dependency name must be specified');
}
// If existenceOnly is true, delegate to checkNpmDependencyExists
if (options.existenceOnly) {
return checkNpmDependencyExists(dependency, {
cwd,
checkNodeModules: options.checkNodeModules
});
}
if (!versionRequirement) {
throw new Error('Version requirement must be specified');
}
if (!semver[semverCondition]) {
throw new Error(`Invalid semver condition: ${semverCondition}`);
}
// First, check in node_modules if the option is enabled
if (options.checkNodeModules) {
const nodeModulesPath = path.join(cwd, 'node_modules', dependency, 'package.json');
try {
if (fs.existsSync(nodeModulesPath)) {
const packageJson = JSON.parse(fs.readFileSync(nodeModulesPath, 'utf8'));
if (packageJson.version) {
return semver[semverCondition](packageJson.version, versionRequirement);
}
}
} catch (error) {
// If there's an error reading the package.json, continue to the fallback method
}
}
// Fallback: Check project's package.json directly
try {
const packageJsonPath = path.join(cwd, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Check all dependency sections for the package and its version
const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
for (const section of sections) {
if (packageJson[section] && packageJson[section][dependency]) {
const versionString = packageJson[section][dependency];
// Extract the version number from the version string (removing ^ or ~ if present)
const version = versionString.replace(/^[\^~]/, '');
return semver[semverCondition](version, versionRequirement);
}
}
}
} catch (error) {
// If there's an error reading or parsing package.json, return false
return false;
}
// If we've reached this point, the dependency version couldn't be determined
return false;
}
/**
* Gets the npm command and arguments
* @param {string[]} args - The arguments to pass to npm
* @returns {Object} An object with command, args, and base properties
*/
export function getNpmCommand(args) {
// Try to get the npm binary path
const npmBinaryPath = getNodeBinaryPath('npm');
// If we have a direct path to npm, use it
if (npmBinaryPath && fs.existsSync(npmBinaryPath)) {
return {
command: npmBinaryPath,
args: args,
prefix: `${npmBinaryPath}`,
};
}
// Fall back to the current method using 'meteor npm'
return {
command: 'meteor',
args: ['npm', ...args],
prefix: `meteor npm`,
};
}
/**
* Gets the npx command and arguments
* @param {string[]} args - The arguments to pass to npx
* @returns {Object} An object with command, args, and base properties
*/
export function getNpxCommand(args) {
// Try to get the npx binary path
const npxBinaryPath = getNodeBinaryPath('npx');
// If we have a direct path to npx, use it
if (npxBinaryPath && fs.existsSync(npxBinaryPath)) {
return {
command: npxBinaryPath,
args: args,
prefix: `${npxBinaryPath}`,
};
}
// Fall back to the current method using 'meteor npx'
return {
command: 'meteor',
args: ['npx', ...args],
prefix: `meteor npx`,
};
}
/**
* Checks if the current project is a Yarn project.
* Looks for yarn.lock file in the current working directory and checks packageManager in package.json.
*
* @param {Object} [options] - Options for the check
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
* @returns {boolean} True if it's a Yarn project, false otherwise
*/
export function isYarnProject(options = {}) {
const cwd = options.cwd || process.cwd();
// Check if yarn.lock exists
const yarnLockPath = path.join(cwd, 'yarn.lock');
if (fs.existsSync(yarnLockPath)) {
return true;
}
// Check packageManager field in package.json
try {
const packageJsonPath = path.join(cwd, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Check if packageManager contains "yarn"
if (packageJson.packageManager && packageJson.packageManager.includes('yarn')) {
return true;
}
}
} catch (error) {
// If there's an error reading or parsing package.json, continue
}
return false;
}
/**
* Gets the yarn command and arguments
* @param {string[]} args - The arguments to pass to yarn
* @returns {Object} An object with command, args, and base properties
*/
export function getYarnCommand(args) {
// Try to get the yarn binary path
const yarnBinaryPath = getNodeBinaryPath('yarn');
// If we have a direct path to yarn, use it
if (yarnBinaryPath && fs.existsSync(yarnBinaryPath)) {
return {
command: yarnBinaryPath,
args,
prefix: `${yarnBinaryPath}`,
};
}
// Fall back to using 'yarn' directly
return {
command: 'yarn',
args,
prefix: `yarn`,
};
}
/**
* Gets the path to the monorepo root by checking for common monorepo indicators.
* Traverses up the directory tree until it finds a monorepo indicator or reaches the root.
*
* @param {Object} [options] - Options for the detection
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
* @returns {string|null} Path to the monorepo root if found, null otherwise
*/
export function getMonorepoPath(options = {}) {
const cwd = options.cwd || process.cwd();
let currentDir = cwd;
// Function to check if directory has monorepo indicators
const hasMonorepoIndicators = (dir) => {
try {
// Check for npm/yarn workspaces in package.json
const packageJsonPath = path.join(dir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (packageJson.workspaces) {
return true;
}
}
// Check for Lerna
const lernaJsonPath = path.join(dir, 'lerna.json');
if (fs.existsSync(lernaJsonPath)) {
return true;
}
// Check for pnpm workspaces
const pnpmWorkspacePath = path.join(dir, 'pnpm-workspace.yaml');
if (fs.existsSync(pnpmWorkspacePath)) {
return true;
}
return false;
} catch (error) {
return false;
}
};
// Traverse up the directory tree
while (currentDir !== path.dirname(currentDir)) { // Stop when we reach the root directory
if (hasMonorepoIndicators(currentDir)) {
return currentDir;
}
currentDir = path.dirname(currentDir);
}
// Check the root directory as well
return hasMonorepoIndicators(currentDir) ? currentDir : null;
}
/**
* Detects if a directory is within a monorepo by checking for common monorepo indicators.
*
* @param {Object} [options] - Options for the detection
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
* @returns {boolean} True if the directory is within a monorepo, false otherwise
*/
export function isMonorepo(options = {}) {
return getMonorepoPath(options) !== null;
}

View File

@@ -0,0 +1,217 @@
const { spawn } = require('child_process');
const net = require('net');
/**
* Spawns a new OS process with the given command and arguments.
* Streams output with original styling and handles errors and exit events.
* Always preserves raw output formatting (colors, progress bars, etc.) and
* provides decoded string data to callbacks for logic/checking/logging.
*
* @param {string} command - The command to run
* @param {string[]} args - Arguments to pass to the command
* @param {Object} options - Options for the spawned process
* @param {Object} [options.env] - Environment variables to merge with process.env
* @param {string} [options.cwd] - Current working directory
* @param {boolean} [options.detached] - Whether to run the process detached from the parent
* @param {Function} [options.onStdout] - Callback for stdout data (receives decoded string)
* @param {Function} [options.onStderr] - Callback for stderr data (receives decoded string)
* @param {Function} [options.onExit] - Callback when process exits
* @param {Function} [options.onError] - Callback when process encounters an error
* @returns {Object} The spawned process with additional utility methods
*/
export function spawnProcess(command, args, options = {}) {
const proc = spawn(command, args, {
env: { ...process.env, ...(options.env || {}), FORCE_COLOR: '1', TERM: 'xterm-256color' },
cwd: options.cwd || process.cwd(),
stdio: ['pipe', 'pipe', 'pipe'],
detached: options.detached || false,
...(process.platform === 'win32' && { shell: true }),
});
// Add a reference to track if the process is running
proc.isRunning = true;
// Handle stdout
proc.stdout.on('data', (buf) => {
if (options.onStdout) {
options.onStdout(buf.toString());
}
});
// Handle stderr
proc.stderr.on('data', (buf) => {
if (options.onStderr) {
options.onStderr(buf.toString());
}
});
// Handle process exit
proc.on('close', (code, signal) => {
proc.isRunning = false;
if (options.onExit) options.onExit(code, signal);
});
// Handle process errors
proc.on('error', (err) => {
proc.isRunning = false;
if (options.onError) options.onError(err);
else console.error(`Process error: ${err.message}`);
});
// This happens sometimes when we write to stdin after the app
// is dead. If we don't register a handler, we get a top level
// exception and the whole app dies.
proc.stdin.on('error', () => {});
if (options.detached) proc.unref();
return proc;
}
/**
* Stops a running process.
*
* @param {Object} proc - The process to stop
* @param {Object} [options] - Options for stopping the process
* @param {string} [options.signal='SIGTERM'] - The signal to send to the process
* @param {number} [options.timeout=5000] - Timeout in ms before forcing kill with SIGKILL
* @returns {Promise<void>} A promise that resolves when the process is stopped
*/
export function stopProcess(proc, options = {}) {
if (!proc || !proc.pid || !isProcessRunning(proc)) {
return Promise.resolve();
}
const signal = options.signal || 'SIGTERM';
const timeout = options.timeout || 5000;
return new Promise((resolve) => {
// Set a timeout to force kill if the process doesn't exit gracefully
const forceKillTimeout = setTimeout(() => {
if (isProcessRunning(proc)) {
proc.kill('SIGKILL');
}
}, timeout);
// Listen for the process to exit
proc.on('close', () => {
clearTimeout(forceKillTimeout);
proc.isRunning = false;
resolve();
});
// Send the signal to terminate the process
proc.kill(signal);
});
}
/**
* Checks if a process is running.
*
* @param {Object} proc - The process to check
* @returns {boolean} True if the process is running, false otherwise
*/
export function isProcessRunning(proc) {
if (!proc || !proc.pid) {
return false;
}
// If we've been tracking the process state with our isRunning property
if (proc.isRunning === false) {
return false;
}
// Try to send signal 0 to the process, which doesn't actually send a signal
// but checks if the process exists
try {
process.kill(proc.pid, 0);
return true;
} catch (e) {
return false;
}
}
/**
* Checks if a port is available.
*
* @param {number} port - The port to check
* @param {string} [host='127.0.0.1'] - The host to check
* @returns {Promise<boolean>} A promise that resolves to true if the port is available, false otherwise
*/
export function isPortAvailable(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', (err) => {
if (err.code === 'EADDRINUSE') {
resolve(false);
} else {
// For other errors, we'll assume the port is not available
resolve(false);
}
});
server.once('listening', () => {
// Close the server and resolve with true (port is available)
server.close(() => {
resolve(true);
});
});
server.listen(port, host);
});
}
/**
* Waits for a port to become available or unavailable.
*
* @param {number} port - The port to check
* @param {Object} [options] - Options for waiting
* @param {string} [options.host='127.0.0.1'] - The host to check
* @param {boolean} [options.waitUntilAvailable=false] - If true, wait until port is available; if false, wait until port is in use
* @param {number} [options.timeout=30000] - Timeout in ms
* @param {number} [options.interval=500] - Interval between checks in ms
* @returns {Promise<boolean>} A promise that resolves to true if the condition is met, false if timed out
*/
export function waitForPort(port, options = {}) {
const host = options.host || '127.0.0.1';
const waitUntilAvailable = options.waitUntilAvailable || false;
const timeout = options.timeout || 30000;
const interval = options.interval || 500;
const startTime = Date.now();
return new Promise((resolve) => {
let timeoutId = null;
const check = async () => {
// Check if we've exceeded the timeout
if (Date.now() - startTime > timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
resolve(false);
return;
}
const isAvailable = await isPortAvailable(port, host);
// If we're waiting for the port to be available and it is, or
// if we're waiting for the port to be in use and it's not available
if ((waitUntilAvailable && isAvailable) || (!waitUntilAvailable && !isAvailable)) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
resolve(true);
return;
}
// Schedule the next check
timeoutId = setTimeout(check, interval);
};
// Start checking
check();
});
}

View File

@@ -0,0 +1,58 @@
/**
* Capitalizes the first letter of the given string.
*
* @param {string} str The input string.
* @returns {string} The string with its first character uppercased.
*/
export function capitalizeFirstLetter(str) {
if (typeof str !== 'string' || str.length === 0) {
return '';
}
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Shuffles the elements of the given array.
* @param arr
* @returns {*}
*/
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; --i) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/**
* Shuffles the characters of the given string.
* @param str
* @returns {string}
*/
export function shuffleString(str) {
return shuffleArray(str.split('')).join('');
}
/**
* Join an array of strings into a human-readable list.
*
* @param {string[]} items - The items to join.
* @param {object} [opts]
* @param {string} [opts.separator=', '] - Separator between items (except last).
* @param {string} [opts.lastSeparator=' and '] - Text to insert before the last item.
* @returns {string}
*/
export function joinWithAnd(
items,
{ separator = ', ', lastSeparator = ' and ' } = {},
) {
const len = items.length;
if (len === 0) return '';
if (len === 1) return items[0];
if (len === 2) return items[0] + lastSeparator + items[1];
return items
.slice(0, -1)
.reduce((acc, item, idx) => {
return acc + (idx === 0 ? '' : separator) + item;
}, '') + lastSeparator + items[len - 1];
}

Some files were not shown because too many files have changed in this diff Show More