Merge branch 'release-3.4.1' into examples-revamp

This commit is contained in:
Nacho Codoñer
2026-03-30 22:25:49 +02:00
committed by GitHub
74 changed files with 2024 additions and 208 deletions

95
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,95 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: "en-US"
reviews:
profile: "chill" # community repo — keep it welcoming
request_changes_workflow: false
high_level_summary: true
poem: false # serious OSS platform
in_progress_fortune: false # noise
review_status: false
review_details: false
commit_status: true
collapse_walkthrough: true
changed_files_summary: true
sequence_diagrams: false # overkill for package-level PRs
estimate_code_review_effort: true
assess_linked_issues: true
related_issues: true
related_prs: true
suggested_labels: true
auto_apply_labels: false
suggested_reviewers: true
auto_assign_reviewers: false
# Exclude generated, build, and Meteor-internal files
path_filters:
- "!**/node_modules/**"
- "!**/.meteor/**"
- "!**/bundle/**"
- "!**/programs/**"
- "!**/*.min.js"
- "!**/cordova-build/**"
- "!**/package-lock.json"
path_instructions:
- path: "packages/**"
instructions: >
This is a core Meteor Atmosphere package. Focus on API backwards
compatibility, DDP/reactivity correctness, and client/server split.
Avoid nitpicking style — the codebase has legacy patterns.
- path: "tools/**"
instructions: >
This is the Meteor build tool (Isobuild). Be thorough about
correctness, edge cases, and performance in the CLI/build pipeline.
- path: "npm-packages/**"
instructions: >
These are npm packages published from the Meteor monorepo.
Check for correct exports, peer dependency handling, and Node.js compatibility.
- path: "v3-docs/**"
instructions: >
Documentation for Meteor v3. Check for accuracy, clarity, and
correct code examples. Grammar and spelling matter here.
- path: "scripts/**"
instructions: >
Build and CI scripts. Focus on correctness, portability, and
error handling.
auto_review:
enabled: true
drafts: false
auto_incremental_review: true
auto_pause_after_reviewed_commits: 3
ignore_title_keywords:
- "WIP"
- "DO NOT MERGE"
base_branches: []
finishing_touches:
docstrings:
enabled: false # legacy JS — too much noise across 100s of packages
unit_tests:
enabled: true
simplify:
enabled: false
tools:
shellcheck:
enabled: true # ✅ they have .sh scripts in /scripts
markdownlint:
enabled: true # ✅ heavy docs contribution
languagetool:
enabled: true # ✅ useful for international doc contributors
level: "default"
disabled_categories:
- "TYPOGRAPHY" # too nitpicky for code comments
ruff:
enabled: false # ❌ not a Python project
biome:
enabled: false # ❌ they use ESLint already (.eslintignore exists)
ast-grep:
essential_rules: true
chat:
auto_reply: true

View File

@@ -86,10 +86,25 @@ jobs:
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:e2e -- -t="${{ matrix.category }}"
id: test-run
continue-on-error: true
timeout-minutes: 15
run: npm run test:e2e -- -t="${{ matrix.category }}"
- name: Retry failed tests for ${{ matrix.category }} (attempt 2)
id: test-retry-1
if: steps.test-run.outcome == 'failure'
continue-on-error: true
timeout-minutes: 15
run: |
echo "::warning::First attempt failed, retrying..."
sleep 90
npm run test:e2e -- -t="${{ matrix.category }}"
- name: Retry failed tests for ${{ matrix.category }} (attempt 3)
if: steps.test-retry-1.outcome == 'failure'
timeout-minutes: 15
run: |
echo "::warning::Second attempt failed, retrying..."
sleep 90
npm run test:e2e -- -t="${{ matrix.category }}"

View File

@@ -53,6 +53,14 @@ Current Reviewers:
- [@zodern](https://github.com/zodern)
- [@radekmie](https://github.com/radekmie)
##### Testing a contributor's branch locally
To quickly check out a PR branch from a fork for local testing, see the [Testing a fork branch](DEVELOPMENT.md#testing-a-fork-branch) section in `DEVELOPMENT.md`, or run:
```sh
npm run checkout:pr -- https://github.com/meteor/meteor/pull/<PR-number>
```
#### Core Committer
The contributors with commit access to meteor/meteor are employees of Meteor Software LP or community members who have distinguished themselves in other contribution areas or members of partner companies. If you want to become a core committer, please start writing PRs.

View File

@@ -64,6 +64,35 @@ can run Meteor directly from a Git checkout using these steps:
>
> Then you can use the chrome debugger inside `chrome://inspect`.
### Testing a fork branch
When reviewing a pull request or testing changes from a contributor's fork, use the `checkout-pr.js` script to set up a local branch automatically:
```sh
# From a PR URL (requires gh CLI or falls back to GitHub API via curl)
$ npm run checkout:pr -- https://github.com/meteor/meteor/pull/<PR-number>
# From a user:branch shorthand
$ npm run checkout:pr -- <user>:<branch>
# From a full fork repo URL and branch name (HTTPS)
$ npm run checkout:pr -- <fork-repo-url> <branch>
# From a full fork repo URL and branch name (SSH)
$ npm run checkout:pr -- git@github.com:<user>/<repo>.git <branch>
```
The script will:
1. Add the fork as a git remote (named after the fork owner) if not already present
2. Fetch the target branch
3. Create (or update) a local branch named `fork/<owner>/<branch>`
4. Print instructions for switching back to your previous branch
For upstream PRs (branches on `meteor/meteor` itself), the script detects the existing `origin` remote and checks out the branch directly without the `fork/` prefix.
If you run the script again for the same fork branch, it will fetch the latest changes and update the local branch.
### Notes when running from a checkout
The following are some distinct differences you must pay attention to when running Meteor from a checkout:

View File

@@ -40,6 +40,9 @@ Core React integration with custom Meteor local directory.
| React + JSX environment detection | Run, Prod, Test, Build |
| Image assets load (generated + public + background) | Run, Prod |
| `Meteor.disablePlugins` suppresses rspack plugins | Run, Prod, Test, Build |
| Unplugin transform hook fires on first run (fresh cache) | Init |
| Unplugin factory created on cached run — #14031 regression | Run |
| Unplugin transform + buildDependencies tracking in production | Prod |
| Custom rspack config (`rspack.config.cjs`) | All |
| HMR works in dev, disabled in prod | Run, Prod |
@@ -54,7 +57,7 @@ Full-featured React Router app with custom packages, Less, and advanced rspack c
| Compiler output cached in dev (babel.config.js) | Run |
| 404 page routing (renders "Page Not Found") | Run, Prod |
| Less stylesheet support (`white-space: break-spaces`) | Run, Prod |
| Meteor modules config styles (`align-content: center`) | Run, Prod |
| `meteor.modules` config styles (`align-content: center`) | Run, Prod |
| Custom HTML meta tags (`theme-color`) | Run, Prod |
| Default + custom package loading | Run |
| `resolve.extensions` loading (`.jsx`) | Run |
@@ -132,12 +135,15 @@ CoffeeScript language support.
### vue
Vue.js framework with Tailwind CSS.
Vue.js framework with Tailwind CSS, CSS auto-delegation, and `meteor.modules` config.
| What is covered | Phase |
|----------------|-------|
| Vue single-file components | All |
| Tailwind CSS styles (`.p-8` padding) | Run, Prod |
| CSS auto-delegation (`client/main.css` processed by Rspack, not Meteor) | All |
| `meteor.modules` config preserves `client/meteor.css` for Meteor processing | All |
| Rspack CSS + Meteor CSS coexistence in same entry folder | All |
| HMR works in dev, disabled in prod | Run, Prod |
### solid
@@ -227,6 +233,12 @@ Several apps import specific npm packages to verify that Meteor + Rspack handles
| `node:buffer` | `imports/api/links.js` | Node.js built-in via `node:` protocol in shared client/server code — must be ignored on client without errors |
| `@react-email/components` | `imports/emails/TestEmail.jsx` | JSX-heavy ESM package with many subpath exports |
### react (`apps/react/plugins/demo-unplugin.js`)
| Package | Reason |
|---------|--------|
| `unplugin` | Unplugin transform hook integration — validates rspack cache tracks plugin dependency files (#14031) |
### babel (`apps/babel/server/apollo.js`)
| Package | Reason |
@@ -262,13 +274,14 @@ Where each feature is tested across apps and skeletons.
| Static asset bundling | react-router, monorepo | |
| Less styles | react-router | |
| SCSS styles | typescript | |
| Tailwind CSS | vue | tailwind |
| Tailwind CSS | vue (PostCSS) | tailwind |
| Image asset loading | react | |
| 404 routing | react-router | |
| Meta tags | react-router | |
| Babel compiler plugin | react-router | |
| TypeScript type checking | typescript | |
| Meteor.disablePlugins | react | |
| Unplugin transform with cache (#14031) | react | |
| Custom package dirs | react-router | |
| CoffeeScript compilation | coffeescript | coffeescript |
| Server-only (no client) | server-only | |
@@ -278,6 +291,8 @@ Where each feature is tested across apps and skeletons.
| Custom NODE_ENV compilation | babel | |
| Portable build (no isDev/isProd defines) | typescript | |
| `Meteor.extendSwcConfig` (path aliases) | typescript | |
| CSS auto-delegation (entry folder filtering) | vue | |
| `meteor.modules` config (preserve files for Meteor) | react-router, vue | |
| `meteor reset` cleanup | all apps | all skeletons |
| Skeleton creation | | all 14 skeletons |
| Body style assertions | | react, tailwind (custom); most others (default) |

View File

@@ -14,6 +14,7 @@ When Meteor runs with the Rspack bundler enabled, this package is what generates
- **Asset externals and HTML generation** through custom Rspack plugins
- **A `defineConfig` helper** that accepts a factory function receiving Meteor environment flags and build utilities
- **Customizable config** via `rspack.config.js` in your project root, with safe merging that warns if you try to override reserved settings
- **Automatic CSS delegation** when rspack is configured with CSS, Less, or SCSS loaders, Meteor automatically detects the handled extensions after the first compilation and stops processing those files itself in the entry folder context. No `.meteorignore` entries needed.
## Installation

View File

@@ -0,0 +1,184 @@
const fs = require('fs');
const path = require('path');
/**
* Extract local file dependencies from a config file by parsing require/import statements using AST
* @param {string} configFilePath - Path to the config file to parse
* @returns {string[]} - Array of absolute paths to local dependencies
*/
function extractLocalDependencies(configFilePath) {
if (!configFilePath || !fs.existsSync(configFilePath)) {
return [];
}
try {
const swc = require('@swc/core');
const content = fs.readFileSync(configFilePath, 'utf-8');
const configDir = path.dirname(configFilePath);
const projectDir = process.cwd();
const dependencies = [];
// Parse the file into an AST
const ast = swc.parseSync(content, {
syntax: 'ecmascript',
dynamicImport: true,
target: 'es2020',
});
// Visit all nodes to find import/require statements
visitNode(ast, (node) => {
let modulePath = null;
// Handle require() calls: require('./plugin')
if (node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.value === 'require' &&
node.arguments.length > 0) {
const arg = node.arguments[0];
if (arg.expression?.type === 'StringLiteral') {
modulePath = arg.expression.value;
}
}
// Handle dynamic import() calls: import('./plugin')
if (node.type === 'CallExpression' &&
node.callee.type === 'Import' &&
node.arguments.length > 0) {
const arg = node.arguments[0];
if (arg.expression?.type === 'StringLiteral') {
modulePath = arg.expression.value;
}
}
// Handle static imports: import x from './plugin'
if (node.type === 'ImportDeclaration' && node.source?.type === 'StringLiteral') {
modulePath = node.source.value;
}
// Handle export re-exports: export * from './plugin'
if (node.type === 'ExportAllDeclaration' && node.source?.type === 'StringLiteral') {
modulePath = node.source.value;
}
// Handle named export re-exports: export { x } from './plugin'
if (node.type === 'ExportNamedDeclaration' && node.source?.type === 'StringLiteral') {
modulePath = node.source.value;
}
// If we found a module path, try to resolve it
if (modulePath) {
const resolvedPath = resolveLocalModule(modulePath, configDir, projectDir);
if (resolvedPath) {
dependencies.push(resolvedPath);
}
}
});
// Remove duplicates
return [...new Set(dependencies)];
} catch (error) {
console.warn('[Rspack Cache] Failed to parse config dependencies:', error.message);
return [];
}
}
/**
* Recursively visit all nodes in an AST
* @param {Object} node - AST node
* @param {Function} callback - Function to call for each node
*/
function visitNode(node, callback) {
if (!node || typeof node !== 'object') {
return;
}
callback(node);
// Visit all properties of the node
for (const key in node) {
if (Object.prototype.hasOwnProperty.call(node, key)) {
const value = node[key];
if (Array.isArray(value)) {
value.forEach(child => visitNode(child, callback));
} else if (typeof value === 'object') {
visitNode(value, callback);
}
}
}
}
/**
* Resolve a module path to an absolute path if it's a local file
* @param {string} modulePath - Module path from require/import statement
* @param {string} configDir - Directory containing the config file
* @param {string} projectDir - Project root directory
* @returns {string|null} - Resolved absolute path or null
*/
function resolveLocalModule(modulePath, configDir, projectDir) {
// Only process relative paths (starts with . or ..)
if (!modulePath.startsWith('.')) {
return null;
}
try {
let resolvedPath = path.resolve(configDir, modulePath);
const extensions = ['.js', '.mjs', '.cjs', '.ts', '.json'];
// If the path exists as-is, check if it's a directory needing index resolution
if (fs.existsSync(resolvedPath)) {
if (fs.statSync(resolvedPath).isDirectory()) {
let found = false;
for (const ext of extensions) {
const indexPath = path.join(resolvedPath, `index${ext}`);
if (fs.existsSync(indexPath)) {
resolvedPath = indexPath;
found = true;
break;
}
}
if (!found) {
return null;
}
}
} else {
// Try common extensions if file doesn't exist as-is
let found = false;
for (const ext of extensions) {
const pathWithExt = resolvedPath + ext;
if (fs.existsSync(pathWithExt)) {
resolvedPath = pathWithExt;
found = true;
break;
}
}
// If still not found, return null
if (!found) {
return null;
}
}
// Verify file is within project (not node_modules)
const resolvedReal = fs.realpathSync(resolvedPath);
const projectReal = fs.realpathSync(projectDir);
const isWithinProject =
resolvedReal === projectReal ||
resolvedReal.startsWith(projectReal + path.sep);
const hasNodeModulesSegment = resolvedReal.split(path.sep).includes('node_modules');
if (isWithinProject && !hasNodeModulesSegment) {
return resolvedPath;
}
} catch (error) {
// Silently ignore resolution errors
}
return null;
}
module.exports = {
extractLocalDependencies,
resolveLocalModule,
};

View File

@@ -1,12 +1,12 @@
{
"name": "@meteorjs/rspack",
"version": "1.1.0-beta.31",
"version": "1.1.0-beta.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@meteorjs/rspack",
"version": "1.1.0-beta.31",
"version": "1.1.0-beta.33",
"license": "ISC",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -14,9 +14,13 @@
"node-polyfill-webpack-plugin": "^4.1.0",
"webpack-merge": "^6.0.1"
},
"devDependencies": {
"semver": "^7.7.4"
},
"peerDependencies": {
"@rspack/cli": ">=1.3.0",
"@rspack/core": ">=1.3.0"
"@rspack/core": ">=1.3.0",
"@swc/core": ">=1.3.0"
}
},
"node_modules/@discoveryjs/json-ext": {
@@ -491,6 +495,268 @@
"node": ">=16.0.0"
}
},
"node_modules/@swc/core": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.21.tgz",
"integrity": "sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.21",
"@swc/core-darwin-x64": "1.15.21",
"@swc/core-linux-arm-gnueabihf": "1.15.21",
"@swc/core-linux-arm64-gnu": "1.15.21",
"@swc/core-linux-arm64-musl": "1.15.21",
"@swc/core-linux-ppc64-gnu": "1.15.21",
"@swc/core-linux-s390x-gnu": "1.15.21",
"@swc/core-linux-x64-gnu": "1.15.21",
"@swc/core-linux-x64-musl": "1.15.21",
"@swc/core-win32-arm64-msvc": "1.15.21",
"@swc/core-win32-ia32-msvc": "1.15.21",
"@swc/core-win32-x64-msvc": "1.15.21"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz",
"integrity": "sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz",
"integrity": "sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz",
"integrity": "sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz",
"integrity": "sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz",
"integrity": "sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz",
"integrity": "sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz",
"integrity": "sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==",
"cpu": [
"s390x"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz",
"integrity": "sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz",
"integrity": "sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz",
"integrity": "sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz",
"integrity": "sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.21",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz",
"integrity": "sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
@@ -4207,6 +4473,19 @@
"node": ">=10"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",

View File

@@ -1,19 +1,27 @@
{
"name": "@meteorjs/rspack",
"version": "1.1.0-beta.31",
"version": "1.1.0-beta.33",
"description": "Configuration logic for using Rspack in Meteor projects",
"main": "index.js",
"type": "commonjs",
"author": "",
"license": "ISC",
"scripts": {
"bump": "node ./scripts/bump-version.js",
"publish:beta": "bash ./scripts/publish-beta.sh"
},
"dependencies": {
"fast-deep-equal": "^3.1.3",
"ignore-loader": "^0.1.2",
"node-polyfill-webpack-plugin": "^4.1.0",
"webpack-merge": "^6.0.1"
},
"devDependencies": {
"semver": "^7.7.4"
},
"peerDependencies": {
"@rspack/cli": ">=1.3.0",
"@rspack/core": ">=1.3.0"
"@rspack/core": ">=1.3.0",
"@swc/core": ">=1.3.0"
}
}

View File

@@ -6,6 +6,96 @@
const { outputMeteorRspack } = require('../lib/meteorRspackHelpers');
/**
* Extracts file extensions that rspack is configured to handle
* from the resolved module.rules test patterns.
* @param {import('@rspack/core').Compiler} compiler
* @returns {Set<string>} Set of extensions like .css, .less, .scss
*/
function extractConfiguredExtensions(compiler) {
const delegatableExtensions = ['.css', '.less', '.scss', '.sass', '.styl'];
const found = new Set();
function inspectRules(rules) {
for (const rule of rules) {
if (!rule) continue;
if (rule.test) {
const testStr = rule.test instanceof RegExp
? rule.test.source
: String(rule.test);
for (const ext of delegatableExtensions) {
const escaped = ext.replace('.', '\\.');
if (testStr.includes(escaped)) {
found.add(ext);
}
}
}
if (rule.oneOf) inspectRules(rule.oneOf);
if (rule.rules) inspectRules(rule.rules);
}
}
inspectRules(compiler.options.module?.rules || []);
return found;
}
/**
* Extracts file extensions that rspack both has rules for AND actually compiled
* from files within entry folder paths (e.g. client/, server/).
* An extension is only delegated if Rspack compiled a file with that extension
* from an entry folder. Files in non-entry folders (e.g. imports/) don't count,
* since delegation only ignores entry folder files for Meteor.
* @param {import('@rspack/core').Stats} stats
* @param {import('@rspack/core').Compiler} compiler
* @returns {string[]} Array of extensions like ['.css', '.less', '.scss']
*/
function extractDelegatedExtensions(stats, compiler) {
const configured = extractConfiguredExtensions(compiler);
if (configured.size === 0) return [];
const path = require('path');
const fs = require('fs');
const appRoot = compiler.options.context || process.cwd();
// Read entry folders from package.json meteor.mainModule
const entryFolders = new Set();
try {
const pkgPath = path.join(appRoot, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const mainModule = pkg?.meteor?.mainModule || {};
for (const entry of Object.values(mainModule)) {
if (typeof entry === 'string') {
const folder = entry.split('/')[0];
if (folder) entryFolders.add(folder);
}
}
} catch (e) {
// If we can't read package.json, fall back to config-only
return Array.from(configured);
}
if (entryFolders.size === 0) return Array.from(configured);
const found = new Set();
for (const module of stats.compilation.modules) {
const resource = module.resource || module.userRequest;
if (!resource) continue;
const relativePath = path.relative(appRoot, resource);
const topFolder = relativePath.split(path.sep)[0];
if (!entryFolders.has(topFolder)) continue;
const ext = path.extname(resource);
if (configured.has(ext)) {
found.add(ext);
if (found.size === configured.size) break;
}
}
return Array.from(found);
}
class MeteorRspackOutputPlugin {
constructor(options = {}) {
this.pluginName = 'MeteorRspackOutputPlugin';
@@ -26,6 +116,7 @@ class MeteorRspackOutputPlugin {
...(this.getData(stats, {
compilationCount: this.compilationCount,
isRebuild: this.compilationCount > 1,
compiler,
}) || {}),
};
outputMeteorRspack(data);
@@ -33,4 +124,4 @@ class MeteorRspackOutputPlugin {
}
}
module.exports = { MeteorRspackOutputPlugin };
module.exports = { MeteorRspackOutputPlugin, extractDelegatedExtensions };

View File

@@ -10,7 +10,7 @@ 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 { MeteorRspackOutputPlugin } = require('./plugins/MeteorRspackOutputPlugin.js');
const { MeteorRspackOutputPlugin, extractDelegatedExtensions } = require('./plugins/MeteorRspackOutputPlugin.js');
const { generateEagerTestFile } = require("./lib/test.js");
const { getMeteorIgnoreEntries, createIgnoreGlobConfig } = require("./lib/ignore");
const {
@@ -27,6 +27,8 @@ const {
} = require('./lib/meteorRspackHelpers.js');
const { loadUserAndOverrideConfig } = require('./lib/meteorRspackConfigHelpers.js');
const { prepareMeteorRspackConfig } = require("./lib/meteorRspackConfigFactory");
const { extractLocalDependencies } = require('./lib/localDependenciesHelpers.js');
// Safe require that doesn't throw if the module isn't found
function safeRequire(moduleName) {
@@ -69,10 +71,16 @@ function createCacheStrategy(
const yarnLockPath = path.join(process.cwd(), 'yarn.lock');
const hasYarnLock = fs.existsSync(yarnLockPath);
// Extract local dependencies from project config (e.g., plugin files)
const localDependencies = projectConfigPath
? extractLocalDependencies(projectConfigPath)
: [];
// Build dependencies array
const buildDependencies = [
...(projectConfigPath ? [projectConfigPath] : []),
...(configPath ? [configPath] : []),
...localDependencies,
...(hasTsconfig ? [tsconfigPath] : []),
...(hasBabelRcConfig ? [babelRcConfig] : []),
...(hasBabelJsConfig ? [babelJsConfig] : []),
@@ -643,7 +651,7 @@ module.exports = async function (inMeteor = {}, argv = {}) {
port: devServerPort,
devMiddleware: {
writeToDisk: (filePath) =>
/\.(html)$/.test(filePath) && !filePath.includes(".hot-update."),
/\.(html)$/.test(filePath) || filePath.endsWith('sw.js'),
},
onListening(devServer) {
if (!devServer) return;
@@ -843,7 +851,7 @@ module.exports = async function (inMeteor = {}, argv = {}) {
// Add MeteorRspackOutputPlugin as the last plugin to output compilation info
const meteorRspackOutputPlugin = new MeteorRspackOutputPlugin({
getData: (stats, { isRebuild, compilationCount }) => ({
getData: (stats, { isRebuild, compilationCount, compiler }) => ({
name: config.name,
mode: config.mode,
hasErrors: stats.hasErrors(),
@@ -852,6 +860,9 @@ module.exports = async function (inMeteor = {}, argv = {}) {
statsOverrided,
compilationCount,
isRebuild,
...(!isRebuild && compiler && {
delegatedExtensions: extractDelegatedExtensions(stats, compiler),
}),
}),
});
config.plugins = [meteorRspackOutputPlugin, ...(config.plugins || [])];

View File

@@ -40,7 +40,8 @@
"install:e2e": "cd tools/e2e-tests && npm install && npx playwright install --with-deps chromium chromium-headless-shell",
"test:e2e": "cd tools/e2e-tests && npm test -- ",
"create-app:e2e": "cd tools/e2e-tests && node scripts/create-app.js",
"test:idle-bot": "node --test .github/scripts/__tests__/inactive-issues.test.js"
"test:idle-bot": "node --test .github/scripts/__tests__/inactive-issues.test.js",
"checkout:pr": "node scripts/checkout-pr.js"
},
"jshintConfig": {
"esversion": 11

View File

@@ -112,7 +112,7 @@ if (Meteor.isClient) {
loginAsUser1((err) => {
test.isUndefined(err, 'Unexpected error logging in as user1');
test.equal(
Object.keys(DDP._reconnectHook.callbacks).length,
DDP._reconnectHook.size(),
1,
'Only one onReconnect callback should be registered'
);
@@ -122,7 +122,7 @@ if (Meteor.isClient) {
setTimeout(() => {
test.isTrue(Meteor.status().connected);
test.equal(
Object.keys(DDP._reconnectHook.callbacks).length,
DDP._reconnectHook.size(),
1,
'Only one onReconnect callback should be registered'
);

View File

@@ -1680,13 +1680,13 @@ const defaultResumeLoginHandler = async (accounts, options) => {
// {hashedToken, when} for a hashed token or {token, when} for an
// unhashed token.
let oldUnhashedStyleToken;
let token = await user.services.resume.loginTokens.find(token =>
let token = user.services.resume.loginTokens.find(token =>
token.hashedToken === hashedToken
);
if (token) {
oldUnhashedStyleToken = false;
} else {
token = await user.services.resume.loginTokens.find(token =>
token = user.services.resume.loginTokens.find(token =>
token.token === options.resume
);
oldUnhashedStyleToken = true;

View File

@@ -1138,6 +1138,56 @@ if (Meteor.isClient) (() => {
})();
if (Meteor.isServer) {
Tinytest.add(
'passwords - passwordValidator accepts passwords within default maxLength',
test => {
// A password of 256 chars (default max) should be accepted
const validPassword = 'a'.repeat(256);
test.isTrue(
Match.test(validPassword, 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), algorithm: Match.OneOf('sha-256') }
)),
'Password of exactly 256 chars should be accepted'
);
}
);
Tinytest.add(
'passwords - passwordValidator rejects passwords exceeding default maxLength',
test => {
// A password of 257 chars should be rejected
const longPassword = 'a'.repeat(257);
test.isFalse(
Match.test(longPassword, 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), algorithm: Match.OneOf('sha-256') }
)),
'Password exceeding 256 chars should be rejected'
);
}
);
Tinytest.add(
'passwords - passwordValidator operator precedence is correct for maxLength fallback',
test => {
// This test verifies the fix: without proper parentheses around the || operator,
// `str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256`
// would evaluate as `(str.length <= undefined) || 256` which is always truthy (256),
// allowing passwords of any length.
const veryLongPassword = 'a'.repeat(1000);
test.isFalse(
Match.test(veryLongPassword, 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), algorithm: Match.OneOf('sha-256') }
)),
'Very long password (1000 chars) should be rejected when no custom maxLength is configured'
);
}
);
}
if (Meteor.isServer) (() => {
Tinytest.add('passwords - setup more than one onCreateUserHook', test => {

View File

@@ -36,34 +36,84 @@
// callback will propagate up to the iterator function, and will
// terminate calling the remaining callbacks if not caught.
const hasOwn = Object.prototype.hasOwnProperty;
export class Hook {
constructor(options) {
options = options || {};
this.nextCallbackId = 0;
this.callbacks = Object.create(null);
// Whether to wrap callbacks with Meteor.bindEnvironment
this.bindEnvironment = true;
if (options.bindEnvironment === false) {
this.bindEnvironment = false;
}
/**
* Creates a new Hook instance.
* @param {object} [options={}] - Configuration options for the hook.
* @param {boolean} [options.bindEnvironment=true] - Whether to automatically wrap registered callbacks with `Meteor.bindEnvironment`.
* If `true`, callbacks will run in the Meteor environment of the code that registered them.
* @param {boolean} [options.wrapAsync=true] - Whether to automatically wrap registered callbacks with `Meteor.wrapFn`.
* If `true`, callbacks will be prepared to run asynchronously.
* @param {Function} [options.exceptionHandler] - A custom function to handle exceptions thrown by registered callbacks.
* This function will be called with the exception as its argument.
* If provided, `options.debugPrintExceptions` will be ignored.
* @param {string} [options.debugPrintExceptions] - If an `exceptionHandler` is not provided, and this option is a string,
* exceptions thrown by callbacks will be logged to `Meteor._debug` with this string as a description.
*/
constructor(options = {}) {
this.callbacks = new Set();
this.wrapAsync = true;
if (options.wrapAsync === false) {
this.wrapAsync = false;
}
// Whether to wrap callbacks with Meteor.bindEnvironment
const { bindEnvironment = true, wrapAsync = true } = options;
this.bindEnvironment = !!bindEnvironment;
this.wrapAsync = !!wrapAsync;
if (options.exceptionHandler) {
this.exceptionHandler = options.exceptionHandler;
} else if (options.debugPrintExceptions) {
if (typeof options.debugPrintExceptions !== "string") {
throw new Error("Hook option debugPrintExceptions should be a string");
if (options.exceptionHandler) {
this.exceptionHandler = options.exceptionHandler;
} else if (options.debugPrintExceptions) {
if (typeof options.debugPrintExceptions !== "string") {
throw new Error("Hook option debugPrintExceptions should be a string");
}
this.exceptionHandler = options.debugPrintExceptions;
}
this.exceptionHandler = options.debugPrintExceptions;
}
/**
* Clears all registered callbacks from this Hook instance.
* After calling this method, the hook will have no callbacks registered.
*/
clear() {
this.callbacks.clear();
}
/**
* Returns the number of callbacks currently registered with this Hook instance.
* @returns {number} The number of registered callbacks.
*/
size() {
return this.callbacks.size;
}
/**
* Returns all registered callbacks as a new Array.
* This provides a snapshot of the current callbacks.
* @returns {Array<Function>} An array containing all registered callback functions.
*/
asArray() {
return Array.from(this.callbacks);
}
/**
* Replaces the current set of registered callbacks with a new set derived from the given array.
*
* @param {Array<Function>} arr An array of callback functions to register with this hook.
* @throws {Error} If the provided argument `arr` is not an array.
*/
fromArray(arr) {
if (!Array.isArray(arr)) {
throw new Error("Method fromArray expects an array");
}
this.callbacks = new Set(arr);
}
/**
* Registers a new callback with this Hook instance.
*
* @param {Function} callback The function to register. This function will be called when the hook is iterated over.
* @returns {{callback: Function, stop: Function}} An object containing:
* - `callback`: The actual callback function that was added to the hook's internal set (after any wrapping).
* - `stop`: A function that, when called, unregisters this specific callback from the hook.
*/
register(callback) {
const exceptionHandler = this.exceptionHandler || function (exception) {
// Note: this relies on the undocumented fact that if bindEnvironment's
@@ -75,29 +125,23 @@ export class Hook {
if (this.bindEnvironment) {
callback = Meteor.bindEnvironment(callback, exceptionHandler);
} else {
callback = dontBindEnvironment(callback, exceptionHandler);
callback = wrapHookWithErrorHandling(callback, exceptionHandler);
}
if (this.wrapAsync) {
callback = Meteor.wrapFn(callback);
}
const id = this.nextCallbackId++;
this.callbacks[id] = callback;
this.callbacks.add(callback);
return {
callback,
stop: () => {
delete this.callbacks[id];
this.callbacks.delete(callback);
}
};
}
clear() {
this.nextCallbackId = 0;
this.callbacks = [];
}
/**
* For each registered callback, call the passed iterator function with the callback.
*
@@ -110,17 +154,8 @@ export class Hook {
* @param iterator
*/
forEach(iterator) {
const ids = Object.keys(this.callbacks);
for (let i = 0; i < ids.length; ++i) {
const id = ids[i];
// check to see if the callback was removed during iteration
if (hasOwn.call(this.callbacks, id)) {
const callback = this.callbacks[id];
if (! iterator(callback)) {
break;
}
}
for (const callback of this.callbacks) {
if (!iterator(callback)) break;
}
}
@@ -133,16 +168,8 @@ export class Hook {
* @see forEach
*/
async forEachAsync(iterator) {
const ids = Object.keys(this.callbacks);
for (let i = 0; i < ids.length; ++i) {
const id = ids[i];
// check to see if the callback was removed during iteration
if (hasOwn.call(this.callbacks, id)) {
const callback = this.callbacks[id];
if (!await iterator(callback)) {
break;
}
}
for (const callback of this.callbacks) {
if (!await iterator(callback)) break;
}
}
@@ -153,27 +180,61 @@ export class Hook {
each(iterator) {
return this.forEach(iterator);
}
/**
* Makes the Hook instance iterable, allowing it to be used in `for...of` loops.
* It iterates over the registered callbacks.
* @returns {Iterator<Function>} An iterator for the registered callbacks.
*/
[Symbol.iterator]() {
return this.callbacks[Symbol.iterator]();
}
}
// Copied from Meteor.bindEnvironment and removed all the env stuff.
function dontBindEnvironment(func, onException, _this) {
if (!onException || typeof(onException) === 'string') {
const description = onException || "callback of async function";
onException = function (error) {
Meteor._debug(
"Exception in " + description,
error
);
};
}
return function (...args) {
/**
* Wraps a given function with error handling. If the wrapped function throws an exception,
* it will be caught and passed to the provided exception handler.
* This is similar to `Meteor.bindEnvironment` but without the Meteor environment binding.
*
* @param {Function} func The function to wrap.
* @param {Function|string} onException The exception handler function to call if `func` throws,
* or a string description for default exception logging.
* @param {any} _this The `this` context to bind to `func` when it is called.
* @returns {Function} A new function that executes `func` with error handling.
*/
function wrapHookWithErrorHandling(func, onException, _this) {
const exceptionHandler = normalizeHookExceptionHandler(onException);
return function executeHookWithErrorHandling(...args) {
let ret;
try {
ret = func.apply(_this, args);
} catch (e) {
onException(e);
exceptionHandler(e);
}
return ret;
};
}
/**
* Normalizes an exception handler, ensuring it is a function.
* If a function is provided, it is returned directly.
* If a string is provided, it is used as a description for a default handler that logs exceptions.
* Otherwise, a generic default handler that logs exceptions with a default description is returned.
*
* @param {Function|string} exceptionHandler The exception handler to normalize. Can be a function,
* a string description for logging, or any other value (which defaults to generic logging).
* @returns {Function} A function that handles exceptions.
*/
function normalizeHookExceptionHandler(exceptionHandler) {
if (typeof exceptionHandler === 'function') {
return exceptionHandler;
}
const description = typeof exceptionHandler === 'string'
? exceptionHandler
: "callback of async function";
return function defaultHookExceptionHandler(error) {
Meteor._debug(`Exception in ${description}`, error);
}
}

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "Register callbacks on a hook",
version: '1.6.1',
version: '1.6.2',
});
Package.onUse(function (api) {

View File

@@ -1050,23 +1050,33 @@ Object.assign(Subscription.prototype, {
// removed messages for the published objects; if that is necessary, call
// _removeAllDocuments first.
_deactivate: function() {
var self = this;
if (self._deactivated)
if (this._deactivated)
return;
self._deactivated = true;
self._callStopCallbacks();
this._deactivated = true;
this._callStopCallbacks().then(() => {
// Break reference chains to allow GC of the Session and its data.
// Without this, deactivated subscriptions retain live references
// to the (now-closed) session indefinitely.
this._session = null;
this._documents = new Map();
});
Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact(
"livedata", "subscriptions", -1);
},
_callStopCallbacks: function () {
var self = this;
// Tell listeners, so they can clean up
var callbacks = self._stopCallbacks;
self._stopCallbacks = [];
callbacks.forEach(function (callback) {
callback();
});
_callStopCallbacks: async function () {
// In Meteor 3, onStop callbacks can be async (e.g. observeHandle.stop()
// returns a Promise). We must await each one so that observer teardown
// completes before the subscription is considered fully deactivated.
const callbacks = this._stopCallbacks;
this._stopCallbacks = [];
for (const callback of callbacks) {
try {
await callback();
} catch (e) {
Meteor._debug("Exception in onStop callback:", e);
}
}
},
// Send remove messages for every document.
@@ -1145,8 +1155,7 @@ Object.assign(Subscription.prototype, {
// destroyed but the deferred call to _deactivateAllSubscriptions hasn't
// happened yet.
_isDeactivated: function () {
var self = this;
return self._deactivated || self._session.inQueue === null;
return this._deactivated || !this._session || this._session.inQueue === null;
},
/**

View File

@@ -593,4 +593,79 @@ function getTestConnections(test) {
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// ============================================================================
// Async onStop cleanup tests (memory leak fix)
// ============================================================================
const asyncCleanupTracker = {};
Meteor.publish('test_async_onstop_cleanup', function (trackerId) {
this.onStop(async function () {
await new Promise(resolve => setTimeout(resolve, 50));
asyncCleanupTracker[trackerId] = true;
});
this.ready();
});
Tinytest.addAsync(
'livedata server - async onStop callbacks complete on unsubscribe',
async function (test) {
const trackerId = Random.id();
asyncCleanupTracker[trackerId] = false;
const { clientConn } = await getTestConnections(test);
const sub = clientConn.subscribe('test_async_onstop_cleanup', trackerId);
await waitUntil(
() => sub.ready(),
{ description: 'subscription is ready' }
);
sub.stop();
await waitUntil(
() => asyncCleanupTracker[trackerId] === true,
{ description: 'async onStop callback completed after unsubscribe' }
);
test.isTrue(
asyncCleanupTracker[trackerId],
'Async onStop callback should have completed'
);
clientConn.disconnect();
delete asyncCleanupTracker[trackerId];
}
);
Tinytest.addAsync(
'livedata server - async onStop callbacks complete on disconnect',
async function (test) {
const trackerId = Random.id();
asyncCleanupTracker[trackerId] = false;
const { clientConn } = await getTestConnections(test);
clientConn.subscribe('test_async_onstop_cleanup', trackerId);
await waitUntil(
() => clientConn.status().connected,
{ description: 'client is connected' }
);
clientConn.disconnect();
await waitUntil(
() => asyncCleanupTracker[trackerId] === true,
{ description: 'async onStop callback completed after disconnect' }
);
test.isTrue(
asyncCleanupTracker[trackerId],
'Async onStop callback should have completed on disconnect'
);
delete asyncCleanupTracker[trackerId];
}
);

View File

@@ -1,8 +1,5 @@
Tinytest.add("spiderable - default hooks registered", function (test, expect) {
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
2
);
test.equal(Spiderable._onReadyHook.size(), 2);
});
Tinytest.add("spiderable - is not ready while initial subscriptions aren't started", function (test, expect) {
@@ -39,21 +36,12 @@ Tinytest.add("spiderable - default hooks can ready", function (test, expect) {
});
Tinytest.add("spiderable - is not ready with a custom hook", function (test, expect) {
var callbacks = {}
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
2
);
test.equal(Spiderable._onReadyHook.size(), 2);
//clear all/default callbacks
_.each(Spiderable._onReadyHook.callbacks, function (value,key,list) {
callbacks[key] = value;
delete list[key];
});
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
0
);
var callbacks = Spiderable._onReadyHook.asArray()
Spiderable._onReadyHook.clear();
test.equal(Spiderable._onReadyHook.size(), 0);
// actually test not ready
@@ -62,41 +50,21 @@ Tinytest.add("spiderable - is not ready with a custom hook", function (test, exp
// clear new callback
_.each(Spiderable._onReadyHook.callbacks, function (value,key,list) {
delete list[key];
});
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
0
);
Spiderable._onReadyHook.clear();
test.equal(Spiderable._onReadyHook.size(), 0);
// restore callbacks
_.each(callbacks, function (value,key,list) {
Spiderable._onReadyHook.callbacks[key] = value;
});
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
2
);
Spiderable._onReadyHook.fromArray(callbacks);
test.equal(Spiderable._onReadyHook.size(), 2);
});
Tinytest.add("spiderable - is ready with a custom hook", function (test, expect) {
var callbacks = {}
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
2
);
test.equal(Spiderable._onReadyHook.size(), 2);
//clear all callbacks
_.each(Spiderable._onReadyHook.callbacks, function (value,key,list) {
callbacks[key] = value;
delete list[key];
});
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
0
);
var callbacks = Spiderable._onReadyHook.asArray();
Spiderable._onReadyHook.clear();
test.equal(Spiderable._onReadyHook.size(), 0);
// actually test ready
Spiderable.addReadyCondition(function () { return true; });
@@ -104,20 +72,10 @@ Tinytest.add("spiderable - is ready with a custom hook", function (test, expect)
// clear new callback
_.each(Spiderable._onReadyHook.callbacks, function (value,key,list) {
delete list[key];
});
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
0
);
Spiderable._onReadyHook.clear();
test.equal(Spiderable._onReadyHook.size(), 0);
// restore callbacks
_.each(callbacks, function (value,key,list) {
Spiderable._onReadyHook.callbacks[key] = value;
});
test.equal(
_.keys(Spiderable._onReadyHook.callbacks).length,
2
);
Spiderable._onReadyHook.fromArray(callbacks);
test.equal(Spiderable._onReadyHook.size(), 2);
});

View File

@@ -16,6 +16,8 @@ const {
setGlobalState
} = require('meteor/tools-core/lib/global-state');
const { applyDelegatedExtensions } = require('./config');
// Helper function to format milliseconds with comma separators
function formatMilliseconds(ms) {
return ms.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
@@ -124,10 +126,15 @@ export function setupCompilationTracking() {
};
// Define separate onCompile callbacks for client and server
const onCompileClient = (data) => {
const onCompileClient = (data, config) => {
// Resolve the promise if it's the first compilation
const clientState = getGlobalState(GLOBAL_STATE_KEYS.CLIENT_FIRST_COMPILE, clientFirstCompile);
if (!clientState?.resolved) {
// Apply delegated extensions before resolving (so they're set before Meteor scans)
if (config?.delegatedExtensions?.length > 0) {
applyDelegatedExtensions(config.delegatedExtensions);
}
clientState.resolved = true;
clientState.resolve();
setGlobalState(GLOBAL_STATE_KEYS.CLIENT_FIRST_COMPILE, clientState);

View File

@@ -386,3 +386,52 @@ export function configureMeteorForRspack() {
}
}
}
/**
* Applies delegated extension ignore patterns for entry folder files.
* Called after rspack's first compilation reports which extensions it handles.
* Since Meteor awaits rspack compilation before scanning files, these patterns
* are in place before Meteor processes any application files.
*
* Uses gitignore semantics: a later positive pattern (client/*.css) overrides
* an earlier negation (!client/*.css) that was set in configureMeteorForRspack.
*
* @param {string[]} extensions - Array of extensions like ['.css', '.less']
*/
export function applyDelegatedExtensions(extensions) {
if (!extensions || extensions.length === 0) return;
const initialEntrypoints = getInitialEntrypoints();
const entrypointContexts = [
initialEntrypoints.mainClient,
initialEntrypoints.mainServer,
]
.filter(Boolean)
.map(entrypoint => path.dirname(entrypoint));
const ignorePatterns = [];
for (const dir of entrypointContexts) {
for (const ext of extensions) {
// ext comes as '.css', glob needs '*.css'
ignorePatterns.push(`${dir}/*${ext}`);
}
}
if (ignorePatterns.length > 0) {
// Re-append meteor.modules unignore patterns after the delegation ignores
// so they take precedence (gitignore semantics: last match wins)
const meteorAppConfig = getMeteorAppConfig();
const unignoredFilesAndFolders = buildUnignorePatterns(
meteorAppConfig?.modules || [],
{ skipLevel: 1 },
);
setMeteorAppIgnore(
[...ignorePatterns, ...unignoredFilesAndFolders].join(' ')
);
if (isMeteorAppDebug() || isMeteorAppConfigModernVerbose()) {
logInfo(`[i] Rspack delegated extensions: ${extensions.join(', ')} (ignored in entry folders)\n ${process.env.METEOR_IGNORE}`);
}
}
}

View File

@@ -7,7 +7,7 @@ import path from 'path';
export const DEFAULT_RSPACK_VERSION = '1.7.1';
export const DEFAULT_METEOR_RSPACK_VERSION = '1.1.0-beta.31';
export const DEFAULT_METEOR_RSPACK_VERSION = '1.1.0-beta.33';
export const DEFAULT_METEOR_RSPACK_REACT_HMR_VERSION = '1.4.3';

View File

@@ -202,7 +202,7 @@ export function checkReactInstalled() {
const appDir = getMeteorAppDir();
// Check if React is a dependency in the project
const isReactInstalled = checkNpmDependencyExists('react', { cwd: appDir });
const isReactInstalled = checkNpmDependencyExists('react', { cwd: appDir }) && !checkNpmDependencyExists('preact', { cwd: appDir });
if (isReactInstalled) {
// Set environment variable to indicate React is enabled

303
scripts/checkout-pr.js Executable file
View File

@@ -0,0 +1,303 @@
#!/usr/bin/env node
//
// checkout-pr.js — prepare a local branch from a fork contribution
//
// Usage:
// node scripts/checkout-pr.js <PR-number>
// node scripts/checkout-pr.js <PR-URL>
// node scripts/checkout-pr.js <user>:<branch>
// node scripts/checkout-pr.js <fork-repo-url> <branch>
// node scripts/checkout-pr.js git@github.com:<user>/<repo>.git <branch>
//
// Examples:
// node scripts/checkout-pr.js 123
// node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/<PR-number>
// node scripts/checkout-pr.js <user>:<branch>
// node scripts/checkout-pr.js <fork-repo-url> <branch>
// node scripts/checkout-pr.js git@github.com:<user>/<repo>.git <branch>
'use strict';
const { execSync } = require('child_process');
const https = require('https');
// Colors (disabled if stdout is not a TTY)
const isTTY = process.stdout.isTTY;
const c = {
red: isTTY ? '\x1b[0;31m' : '',
green: isTTY ? '\x1b[0;32m' : '',
yellow: isTTY ? '\x1b[0;33m' : '',
cyan: isTTY ? '\x1b[0;36m' : '',
bold: isTTY ? '\x1b[1m' : '',
reset: isTTY ? '\x1b[0m' : '',
};
function info(msg) { console.log(`${c.cyan}\u2192${c.reset} ${msg}`); }
function ok(msg) { console.log(`${c.green}\u2713${c.reset} ${msg}`); }
function warn(msg) { console.log(`${c.yellow}\u26A0${c.reset} ${msg}`); }
function err(msg) { console.error(`${c.red}\u2717${c.reset} ${msg}`); }
function die(msg) {
err(msg);
process.exit(1);
}
function usage() {
console.log(`Usage:
npm run checkout:pr -- <PR-number>
npm run checkout:pr -- <PR-URL>
npm run checkout:pr -- <user>:<branch>
npm run checkout:pr -- <fork-repo-url> <branch>
npm run checkout:pr -- git@github.com:<user>/<repo>.git <branch>
Prepares a local branch from a fork contribution for testing and review.
Examples:
npm run checkout:pr -- 123
npm run checkout:pr -- https://github.com/meteor/meteor/pull/<PR-number>
npm run checkout:pr -- <user>:<branch>
npm run checkout:pr -- <fork-repo-url> <branch>
npm run checkout:pr -- git@github.com:<user>/<repo>.git <branch>`);
process.exit(1);
}
function git(cmd, { silent = false } = {}) {
try {
return execSync(`git ${cmd}`, {
encoding: 'utf8',
stdio: silent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'inherit'],
}).trim();
} catch (e) {
if (silent) return null;
throw e;
}
}
function ghCli(args) {
try {
return execSync(`gh ${args}`, { encoding: 'utf8', stdio: 'pipe' }).trim();
} catch {
return null;
}
}
function hasCommand(name) {
try {
execSync(process.platform === 'win32' ? `where ${name}` : `command -v ${name}`, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
function httpsGet(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': 'meteor-checkout-pr' } }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve(data));
}).on('error', reject);
});
}
async function extractFromPrUrl(prUrl) {
const match = prUrl.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
if (!match) die(`could not parse PR URL: ${prUrl}`);
const [, repoPath, prNumber] = match;
// Try gh CLI first
if (hasCommand('gh')) {
const result = ghCli(`pr view "${prUrl}" --json headRepositoryOwner,headRefName`);
if (result) {
try {
const data = JSON.parse(result);
const owner = data.headRepositoryOwner?.login;
const branch = data.headRefName;
if (owner && branch) return { owner, branch };
} catch { /* fall through */ }
}
}
// Fall back to GitHub REST API
const apiUrl = `https://api.github.com/repos/${repoPath}/pulls/${prNumber}`;
let body;
try {
body = await httpsGet(apiUrl);
} catch (e) {
die(`could not fetch PR data from ${apiUrl} (${e.message})`);
}
try {
const data = JSON.parse(body);
const owner = data.head?.user?.login;
const branch = data.head?.ref;
if (owner && branch) return { owner, branch };
} catch { /* fall through */ }
die(`could not extract fork owner/branch from PR #${prNumber}`);
}
function getRepoPathFromOrigin() {
const originUrl = git('remote get-url origin', { silent: true });
if (!originUrl) return null;
// Match HTTPS: https://github.com/owner/repo(.git)
const httpsMatch = originUrl.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
if (httpsMatch) return httpsMatch[1];
// Match SSH: git@github.com:owner/repo(.git)
const sshMatch = originUrl.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
if (sshMatch) return sshMatch[1];
return null;
}
function extractOwnerFromUrl(url) {
const match = url.match(/github\.com[:/]([^/]+)\//);
return match ? match[1] : null;
}
function normalizeUrl(url) {
return url
.replace(/\.git$/, '')
.replace(/\/$/, '')
.replace(/^https?:\/\//, '')
.replace(/^git@github\.com:/, 'github.com/');
}
function buildForkUrl(owner) {
// Match origin's protocol (SSH vs HTTPS)
const originUrl = git('remote get-url origin', { silent: true }) || '';
if (originUrl.startsWith('git@')) {
return `git@github.com:${owner}/meteor.git`;
}
return `https://github.com/${owner}/meteor.git`;
}
async function main() {
const args = process.argv.slice(2);
// Ensure we're inside a git repo
if (!git('rev-parse --is-inside-work-tree', { silent: true })) {
die('not inside a git repository');
}
let forkOwner, forkBranch, forkRepoUrl;
if (args.length === 1) {
const arg = args[0];
const prNumberMatch = arg.match(/^\d+$/);
const prMatch = arg.match(/^https?:\/\/github\.com\/.*\/pull\/\d+/);
const sshMatch = arg.match(/^git@[^:]+:/);
const httpsRepoMatch = arg.match(/^https?:\/\/.*\.git$/);
// user:branch — must not start with git@ (SSH) or contain / before : (URLs)
const shortMatch = !sshMatch && arg.match(/^([^/:]+):(.+)$/);
if (prNumberMatch) {
const repoPath = getRepoPathFromOrigin();
if (!repoPath) die('could not determine repository from origin remote');
const prUrl = `https://github.com/${repoPath}/pull/${arg}`;
info(`resolved PR #${arg}${c.bold}${prUrl}${c.reset}`);
const result = await extractFromPrUrl(prUrl);
forkOwner = result.owner;
forkBranch = result.branch;
forkRepoUrl = buildForkUrl(forkOwner);
} else if (prMatch) {
const result = await extractFromPrUrl(arg);
forkOwner = result.owner;
forkBranch = result.branch;
forkRepoUrl = buildForkUrl(forkOwner);
} else if (sshMatch || httpsRepoMatch) {
die(`repo URL requires a branch argument: node scripts/checkout-pr.js ${arg} <branch>`);
} else if (shortMatch) {
forkOwner = shortMatch[1];
forkBranch = shortMatch[2];
forkRepoUrl = buildForkUrl(forkOwner);
} else {
err(`unrecognized format: ${arg}`);
console.error('');
return usage();
}
} else if (args.length === 2) {
forkRepoUrl = args[0];
forkBranch = args[1];
forkOwner = extractOwnerFromUrl(forkRepoUrl);
if (!forkOwner) die(`could not extract owner from URL: ${forkRepoUrl}`);
} else {
return usage();
}
const previousBranch = git('symbolic-ref --short HEAD', { silent: true })
|| git('rev-parse --short HEAD', { silent: true })
|| 'HEAD';
// Detect if the PR is from the upstream repo (not a fork)
let remoteName = '';
let isUpstream = false;
const originUrl = git('remote get-url origin', { silent: true });
if (originUrl) {
const normFork = normalizeUrl(forkRepoUrl);
const normOrigin = normalizeUrl(originUrl);
if (normFork === normOrigin) {
remoteName = 'origin';
isUpstream = true;
}
}
let localBranch;
if (isUpstream) {
localBranch = forkBranch;
} else {
remoteName = forkOwner;
localBranch = `fork/${forkOwner}/${forkBranch}`;
}
console.log(`${c.bold}--- checkout-pr ---${c.reset}`);
info(`owner: ${c.bold}${forkOwner}${c.reset}`);
info(`branch: ${c.bold}${forkBranch}${c.reset}`);
info(`repo: ${c.bold}${forkRepoUrl}${c.reset}`);
if (isUpstream) {
info(`upstream: yes (using remote '${c.bold}${remoteName}${c.reset}')`);
}
info(`local branch: ${c.bold}${localBranch}${c.reset}`);
console.log('');
// Add remote if needed (skip for upstream PRs)
if (!isUpstream) {
const existingUrl = git(`remote get-url "${remoteName}"`, { silent: true });
if (existingUrl) {
warn(`remote '${remoteName}' already exists, reusing it`);
} else {
info(`adding remote '${remoteName}' \u2192 ${forkRepoUrl}`);
git(`remote add "${remoteName}" "${forkRepoUrl}"`);
ok(`remote '${remoteName}' added`);
}
}
// Fetch the branch
info(`fetching '${forkBranch}' from '${remoteName}'...`);
const fetchResult = git(`fetch "${remoteName}" "${forkBranch}"`, { silent: true });
if (fetchResult === null) {
die(`failed to fetch branch '${forkBranch}' from '${remoteName}' \u2014 check that the fork and branch exist`);
}
ok(`fetched latest from '${remoteName}'`);
// Create or switch to local branch
const branchExists = git(`show-ref --verify "refs/heads/${localBranch}"`, { silent: true });
if (branchExists) {
warn(`branch '${localBranch}' already exists, switching and updating...`);
git(`checkout "${localBranch}"`);
git(`reset --hard "refs/remotes/${remoteName}/${forkBranch}"`);
} else {
info(`creating branch '${localBranch}'...`);
git(`checkout -b "${localBranch}" "refs/remotes/${remoteName}/${forkBranch}"`);
}
console.log('');
ok(`ready on branch: ${c.bold}${localBranch}${c.reset}`);
info(`to switch back: ${c.bold}git checkout ${previousBranch}${c.reset}`);
}
main().catch((e) => {
die(e.message);
});

View File

@@ -689,6 +689,7 @@ export const AVAILABLE_SKELETONS = [
"minimal",
DEFAULT_SKELETON,
"typescript",
"typescript-tailwind",
"vue",
"svelte",
"tailwind",
@@ -707,6 +708,7 @@ const SKELETON_INFO = {
"minimal": "To create an app with as few Meteor packages as possible",
"react": "To create a basic React-based app",
"typescript": "To create an app using TypeScript and React",
"typescript-tailwind": "To create an app using TypeScript, React, and Tailwind",
"vue": "To create a basic Vue3-based app",
"svelte": "To create a basic Svelte app",
"tailwind": "To create an app using React and Tailwind",
@@ -714,7 +716,7 @@ const SKELETON_INFO = {
"solid": "To create a basic Solid app",
"coffeescript": "To create a basic CoffeeScript app",
"babel": "To create a React app with Babel support",
"angular": "To create a basic Angular app",
"angular": "To create a basic Angular app"
};
main.registerCommand({
@@ -733,6 +735,7 @@ main.registerCommand({
react: { type: Boolean },
vue: { type: Boolean },
typescript: { type: Boolean },
'typescript-tailwind': { type: Boolean },
apollo: { type: Boolean },
svelte: { type: Boolean },
tailwind: { type: Boolean },

View File

@@ -11,6 +11,7 @@
"@babel/runtime": "^7.23.5",
"@swc/helpers": "^0.5.17",
"meteor-node-stubs": "^1.2.12",
"unplugin": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},

View File

@@ -0,0 +1,30 @@
const { createUnplugin } = require('unplugin');
const demoUnplugin = createUnplugin(() => {
console.log('[demo-unplugin][factory-created]');
return {
name: 'demo-unplugin',
transformInclude(id) {
// Only process app source files, skip node_modules and .meteor
if (id.includes('node_modules') || id.includes('.meteor')) {
return false;
}
const ok =
id.endsWith('.tsx') ||
id.endsWith('.ts') ||
id.endsWith('.jsx') ||
id.endsWith('.js');
if (ok) {
console.log('[demo-unplugin][transformInclude]', id, '=> true');
}
return ok;
},
transform(code, id) {
console.log('[demo-unplugin][transform-enter]', id);
return { code, map: null };
},
};
});
module.exports = { demoRspackPlugin: demoUnplugin.rspack };

View File

@@ -1,6 +1,7 @@
const { defineConfig } = require('@meteorjs/rspack');
const path = require('path');
const CustomConsoleLogPlugin = require("./plugins/CustomConsoleLogPlugin");
const { demoRspackPlugin } = require("./plugins/demo-unplugin");
/**
* Rspack configuration for Meteor projects.
@@ -39,6 +40,6 @@ module.exports = defineConfig(Meteor => {
},
],
},
plugins: [new CustomConsoleLogPlugin()],
plugins: [new CustomConsoleLogPlugin(), demoRspackPlugin()],
};
});

View File

@@ -22,7 +22,7 @@
"modern": true
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rspack/cli": "^1.4.8",
"@rspack/core": "^1.4.8",
"babel-loader": "10.0.0",

View File

@@ -13,7 +13,7 @@
"meteor-node-stubs": "^1.2.12"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rspack/cli": "^1.4.8",
"@rspack/core": "^1.4.8",
"playwright": "1.58.0",

View File

@@ -1,2 +0,0 @@
# Ignore client/main.css file so that is processed by Rspack import
client/main.css

View File

@@ -17,7 +17,7 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rspack/cli": "^1.4.8",
"@rspack/core": "^1.4.8",
"@tailwindcss/postcss": "^4.1.12",
@@ -34,6 +34,7 @@
"client": "client/main.js",
"server": "server/main.js"
},
"modules": ["client/meteor.css"],
"testModule": "tests/main.js"
}
}

View File

@@ -30,4 +30,8 @@ module.exports = {
}
},
maxWorkers: 1,
reporters: [
'default',
'<rootDir>/summary-reporter.js',
],
};

View File

@@ -1,12 +1,6 @@
// jest.setup.js
import chalk from 'chalk';
const isCI = process.env.GITHUB_ACTIONS === "true";
if (isCI) {
jest.retryTimes(2);
console.log('Set 2 retries on Jest level');
}
// Clear NODE_ENV so meteor commands don't inherit any value from the test runner environment
process.env.NODE_ENV = '';

View File

@@ -78,6 +78,13 @@ describe('React App Bundling /', () => {
buildDir: "_build-local-custom",
env: { METEOR_LOCAL_DIR: ".meteor/local-custom" },
customAssertions: {
afterInit: async ({ result }) => {
// Verify unplugin transform hook is called on first run (fresh cache)
await waitForMeteorOutput(
result.outputLines,
/.*\[demo-unplugin\]\[transform-enter\].*/
);
},
afterRun: async ({ result, tempDir }) => {
const appDir = tempDir; // testMeteorRspackBundler uses tempDir as appDir if not monorepo
@@ -95,11 +102,20 @@ describe('React App Bundling /', () => {
await assertImagesExistAndLoad();
// Check custom plugin is disabled with Meteor.disablePlugins
// Use specific log prefix to avoid matching the filename in buildDependencies
await waitForMeteorOutput(
result.outputLines,
/.*CustomConsoleLogPlugin.*/,
/.*\[CustomConsoleLogPlugin\].*/,
{ negate: true }
);
// Verify unplugin factory is still created on second run (with cache)
// This confirms the plugin is loaded and active even when rspack uses
// cached transform results (#14031 regression test)
await waitForMeteorOutput(
result.outputLines,
/.*\[demo-unplugin\]\[factory-created\].*/
);
},
afterRunRebuildClient: async ({ allConsoleLogs }) => {
// Check for HMR output as enabled by default
@@ -115,11 +131,24 @@ describe('React App Bundling /', () => {
await assertImagesExistAndLoad();
// Check custom plugin is disabled with Meteor.disablePlugins
// Use specific log prefix to avoid matching the filename in buildDependencies
await waitForMeteorOutput(
result.outputLines,
/.*CustomConsoleLogPlugin.*/,
/.*\[CustomConsoleLogPlugin\].*/,
{ negate: true }
);
// Verify demo-unplugin.js is tracked in rspack buildDependencies (#14031)
await waitForMeteorOutput(
result.outputLines,
/.*plugins\/demo-unplugin\.js.*/
);
// Verify unplugin transform hook fires in production (separate cache version)
await waitForMeteorOutput(
result.outputLines,
/.*\[demo-unplugin\]\[transform-enter\].*/
);
},
afterRunProductionRebuildClient: async ({ allConsoleLogs }) => {
// Check for HMR to not be enabled in production-like mode
@@ -133,9 +162,10 @@ describe('React App Bundling /', () => {
await waitForReactEnvs(result.outputLines);
// Check custom plugin is disabled with Meteor.disablePlugins
// Use specific log prefix to avoid matching the filename in buildDependencies
await waitForMeteorOutput(
result.outputLines,
/.*CustomConsoleLogPlugin.*/,
/.*\[CustomConsoleLogPlugin\].*/,
{ negate: true }
);
},
@@ -143,9 +173,10 @@ describe('React App Bundling /', () => {
await waitForReactEnvs(result.outputLines);
// Check custom plugin is disabled with Meteor.disablePlugins
// Use specific log prefix to avoid matching the filename in buildDependencies
await waitForMeteorOutput(
result.outputLines,
/.*CustomConsoleLogPlugin.*/,
/.*\[CustomConsoleLogPlugin\].*/,
{ negate: true }
);
},
@@ -153,9 +184,10 @@ describe('React App Bundling /', () => {
await waitForReactEnvs(result.outputLines, { isJsxEnabled: true });
// Check custom plugin is disabled with Meteor.disablePlugins
// Use specific log prefix to avoid matching the filename in buildDependencies
await waitForMeteorOutput(
result.outputLines,
/.*CustomConsoleLogPlugin.*/,
/.*\[CustomConsoleLogPlugin\].*/,
{ negate: true }
);
},

View File

@@ -216,6 +216,33 @@ describe('Meteor Skeletons /', () => {
}),
);
describe(
"Typescript Tailwind Skeleton /",
testMeteorSkeleton({
skeletonName: "typescript-tailwind",
port: 3221,
filePaths: {
client: "client/main.tsx",
server: "server/main.ts",
test: "tests/main.ts",
},
customAssertions: {
afterCreate({ tempDir }) {
if (isCI) {
const rspackConfigPath = path.join(tempDir, "rspack.config.ts");
// Remove the TsCheckerRspackPlugin plugin as is resource-intense, CI gets exhausted and fails
let configContent = fs.readFileSync(rspackConfigPath, "utf8");
configContent = configContent.replace(
/\s*new\s+TsCheckerRspackPlugin\(\)/,
""
);
fs.writeFileSync(rspackConfigPath, configContent);
}
},
},
})
);
describe(
'Vue Skeleton /',
testMeteorSkeleton({

View File

@@ -0,0 +1,100 @@
const chalk = require('chalk');
/**
* Custom Jest reporter that prints a structured summary of all test results,
* including detailed error logs for failures.
*/
class SummaryReporter {
constructor(globalConfig) {
this._globalConfig = globalConfig;
}
onRunComplete(_contexts, results) {
const passed = [];
const failed = [];
const skipped = [];
for (const suite of results.testResults) {
for (const test of suite.testResults) {
const entry = {
name: test.fullName || test.title,
suite: suite.testFilePath.replace(this._globalConfig.rootDir + '/', ''),
duration: test.duration,
status: test.status,
};
if (test.status === 'passed') {
passed.push(entry);
} else if (test.status === 'failed') {
entry.errors = test.failureMessages || [];
failed.push(entry);
} else {
skipped.push(entry);
}
}
}
this._printConsole(passed, failed, skipped);
}
_printConsole(passed, failed, skipped) {
const hasFails = failed.length > 0;
const divider = chalk.dim('═'.repeat(70));
const thinDivider = chalk.dim('─'.repeat(70));
console.log('\n' + divider);
console.log(hasFails
? chalk.bold.red(' E2E TEST SUMMARY')
: chalk.bold.green(' E2E TEST SUMMARY'));
console.log(divider);
if (passed.length > 0) {
console.log(chalk.green(`\n PASSED (${passed.length}):`));
console.log(thinDivider);
for (const t of passed) {
const duration = t.duration ? chalk.dim(` (${(t.duration / 1000).toFixed(1)}s)`) : '';
console.log(` ${chalk.green('✓')} ${t.name}${duration}`);
}
}
if (skipped.length > 0 && process.env.E2E_SHOW_SKIPPED) {
console.log(chalk.yellow(`\n SKIPPED (${skipped.length}):`));
console.log(thinDivider);
for (const t of skipped) {
console.log(` ${chalk.yellow('○')} ${chalk.dim(t.name)}`);
}
}
if (failed.length > 0) {
console.log(chalk.red(`\n FAILED (${failed.length}):`));
console.log(thinDivider);
for (const t of failed) {
const duration = t.duration ? chalk.dim(` (${(t.duration / 1000).toFixed(1)}s)`) : '';
console.log(`\n ${chalk.red('✕')} ${chalk.bold(t.name)}${duration}`);
console.log(` ${chalk.dim('Suite:')} ${chalk.dim(t.suite)}`);
for (const err of t.errors) {
const indented = err
.split('\n')
.map(line => ` ${chalk.red(line)}`)
.join('\n');
console.log(indented);
}
}
}
const totalTime = [...passed, ...failed, ...skipped]
.reduce((sum, t) => sum + (t.duration || 0), 0);
console.log('\n' + divider);
console.log(
` ${chalk.bold('TOTAL:')} ${passed.length + failed.length + skipped.length} ${chalk.dim('|')} ` +
`${chalk.green('PASSED:')} ${chalk.green(passed.length)} ${chalk.dim('|')} ` +
`${chalk.red('FAILED:')} ${chalk.red(failed.length)} ${chalk.dim('|')} ` +
`${chalk.yellow('SKIPPED:')} ${chalk.yellow(skipped.length)} ${chalk.dim('|')} ` +
`${chalk.dim('TIME:')} ${chalk.dim((totalTime / 1000).toFixed(1) + 's')}`
);
console.log(divider + '\n');
}
}
module.exports = SummaryReporter;

View File

@@ -22,7 +22,7 @@
},
"devDependencies": {
"@angular/compiler-cli": "^20.0.0",
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@nx/angular-rspack": "^21.1.0",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",

View File

@@ -20,7 +20,7 @@
"devDependencies": {
"@graphql-tools/webpack-loader": "^7.0.0",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",
"@rspack/plugin-react-refresh": "^1.4.3",

View File

@@ -17,7 +17,7 @@
"devDependencies": {
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.23.3",
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -14,7 +14,7 @@
"meteor-node-stubs": "^1.2.12"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -21,7 +21,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -15,7 +15,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -12,7 +12,7 @@
"meteor-node-stubs": "^1.2.12"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -15,7 +15,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -14,7 +14,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -13,7 +13,7 @@
"meteor-node-stubs": "^1.2.12"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -16,7 +16,7 @@
"react-dom": "^17.0.2"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -0,0 +1,7 @@
node_modules/
# Meteor Modern-Tools build context directories
_build
*/build-assets
*/build-chunks
.rsdoctor

View File

@@ -0,0 +1 @@
local

View File

@@ -0,0 +1,26 @@
# Meteor packages used by this project, one per line.
# Check this file (and the other files in this directory) into your repository.
#
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
meteor-base # Packages every Meteor app needs to have
mobile-experience # Packages for a great mobile UX
mongo # The database Meteor supports right now
reactive-var # Reactive variable for tracker
standard-minifier-css # CSS minifier run for production mode
standard-minifier-js # JS minifier run for production mode
es5-shim # ECMAScript 5 compatibility for older browsers
ecmascript # Enable ECMAScript2015+ syntax in app code
typescript # Enable TypeScript syntax in .ts and .tsx modules
shell-server # Server-side component of the `meteor shell` command
hot-module-replacement # Update client in development without reloading the page
~prototype~
static-html # Define static page content in .html files
react-meteor-data # React higher-order component for reactively tracking Meteor data
rspack # Integrate Rspack into Meteor for client and server app bundling
zodern:types # Pull in type declarations from other Meteor packages

View File

@@ -0,0 +1,2 @@
server
browser

View File

@@ -0,0 +1,6 @@
@import "tailwindcss";
body {
padding: 10px;
font-family: sans-serif;
}

View File

@@ -0,0 +1,8 @@
<head>
<title>~name~</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div class="bg-gray-100" id="react-target"></div>
</body>

View File

@@ -0,0 +1,11 @@
import { Meteor } from 'meteor/meteor';
import { createRoot } from 'react-dom/client';
import { App } from '/imports/ui/App';
import './main.css';
Meteor.startup(() => {
const target = document.getElementById('react-target');
if (target) {
createRoot(target).render(<App/>);
}
});

View File

@@ -0,0 +1,10 @@
import { Mongo } from 'meteor/mongo';
export interface Link {
_id?: string;
title: string;
url: string;
createdAt: Date;
}
export const LinksCollection = new Mongo.Collection<Link>('links');

View File

@@ -0,0 +1,9 @@
import { Hello } from './Hello';
import { Info } from './Info';
export const App = () => (
<div className="max-w-3xl min-h-screen mx-auto sm:pt-10">
<Hello/>
<Info/>
</div>
);

View File

@@ -0,0 +1,42 @@
import { useState } from 'react';
export const Hello = () => {
const [counter, setCounter] = useState(0);
const increment = () => {
setCounter(counter + 1);
};
return (
<div className="bg-white shadow sm:rounded-lg mb-4">
<div className="px-4 py-5 sm:p-6">
<div className="sm:flex sm:items-start sm:justify-between">
<div>
<div className="flex items-center">
<h1 className="text-3xl text-gray-900 font-bold">
Welcome to Meteor!
</h1>
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 400 400"><g fill="#DE4F4F"><path d="M286.575 306.886L44.755 49.922l256.962 241.82c4.312 4.056 4.518 10.837.46 15.146-4.053 4.31-10.832 4.518-15.144.46-.15-.14-.318-.31-.458-.462M251.032 325.01L68.692 127.528 266.177 309.87c4.35 4.013 4.618 10.794.604 15.144-4.018 4.35-10.794 4.617-15.146.604-.2-.19-.413-.406-.602-.607M214.083 325.542L92.907 194.272 224.18 315.446c2.898 2.676 3.077 7.197.402 10.098-2.677 2.896-7.195 3.082-10.097.402-.136-.125-.277-.272-.402-.405M315.612 234.685L189.102 98.078 325.71 224.585c2.896 2.684 3.067 7.203.387 10.1-2.682 2.895-7.2 3.066-10.098.387-.13-.123-.268-.258-.388-.387M304.697 272.93L121.567 74.655l198.274 183.13c4.35 4.017 4.62 10.796.605 15.144-4.017 4.352-10.797 4.617-15.146.604-.205-.19-.418-.404-.603-.605M176.31 314.783l-57.647-62.695 62.692 57.65c1.453 1.334 1.547 3.596.215 5.045-1.338 1.453-3.598 1.55-5.05.215-.072-.07-.144-.143-.21-.215M311.093 189.297l-57.65-62.694 62.696 57.646c1.45 1.335 1.546 3.597.21 5.048-1.335 1.45-3.595 1.547-5.05.21-.07-.065-.143-.143-.207-.21"/></g></svg>
</div>
</div>
<div className="mt-2 max-w-xl text-gray-500 text-lg">
<p>
You've pressed the button <b>{counter}</b> times.
</p>
</div>
</div>
<div className="mt-5 sm:mt-0 sm:ml-6 sm:shrink-0 sm:flex sm:items-center">
<button
onClick={increment}
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 text-lg cursor-pointer"
>
Click Me
</button>
</div>
</div>
</div>
</div>
)
};

View File

@@ -0,0 +1,46 @@
import { useFind, useSubscribe } from "meteor/react-meteor-data/suspense";
import { LinksCollection } from "../api/links";
export const Info = () => {
useSubscribe("links");
const data = useFind(LinksCollection, []);
return (
<div className="rounded-lg bg-gray-200 overflow-hidden shadow divide-y divide-gray-200 sm:divide-y-0 sm:grid sm:grid-cols-2 sm:gap-px">
{data.map((link) => (
<div
key={link._id}
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-500"
>
<div>
<span className="bg-indigo-50 text-indigo-700 rounded-lg inline-flex p-3 ring-4 ring-white">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</span>
</div>
<div className="mt-8">
<h3 className="text-lg font-medium">
<a href={link.url} target="_blank" className="focus:outline-none">
<span className="absolute inset-0" aria-hidden="true" />
{link.title}
</a>
</h3>
</div>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,47 @@
{
"name": "~name~",
"private": true,
"scripts": {
"start": "meteor lint && meteor run",
"test": "meteor test --once --driver-package meteortesting:mocha",
"test-app": "TEST_WATCH=1 meteor test --full-app --driver-package meteortesting:mocha",
"visualize": "meteor --production --extra-packages bundle-visualizer",
"generate-types": "meteor lint"
},
"dependencies": {
"@babel/runtime": "^7.23.5",
"@swc/helpers": "^0.5.17",
"autoprefixer": "^10.4.4",
"meteor-node-stubs": "^1.2.12",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@meteorjs/rspack": "^1.0.1",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",
"@swc/core": "^1.15.18",
"@rspack/plugin-react-refresh": "^1.4.3",
"@tailwindcss/postcss": "^4.1.12",
"@types/meteor": "^2.9.9",
"@types/mocha": "^8.2.3",
"@types/node": "^22.10.6",
"@types/react": "^18.2.5",
"@types/react-dom": "^18.2.4",
"postcss": "^8.5.6",
"postcss-loader": "^8.1.1",
"react-refresh": "^0.17.0",
"tailwindcss": "^4.1.12",
"ts-checker-rspack-plugin": "^1.1.5",
"typescript": "^5.9.3"
},
"meteor": {
"mainModule": {
"client": "client/main.tsx",
"server": "server/main.ts"
},
"testModule": "tests/main.ts",
"modern": true
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "@meteorjs/rspack";
import { TsCheckerRspackPlugin } from "ts-checker-rspack-plugin";
/**
* 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.
*/
export default defineConfig(Meteor => {
return {
...Meteor.isClient && {
plugins: [
...(!Meteor.isTest && !Meteor.isAppTest ? [new TsCheckerRspackPlugin()] : []),
],
module: {
rules: [
{
test: /\.css$/,
use: ["postcss-loader"],
type: "css",
},
],
},
},
};
});

View File

@@ -0,0 +1,37 @@
import { Meteor } from 'meteor/meteor';
import { Link, LinksCollection } from '/imports/api/links';
async function insertLink({ title, url }: Pick<Link, 'title' | 'url'>) {
await LinksCollection.insertAsync({ title, url, createdAt: new Date() });
}
Meteor.startup(async () => {
// If the Links collection is empty, add some data.
if (await LinksCollection.find().countAsync() === 0) {
await insertLink({
title: 'Do the Tutorial',
url: 'https://react-tutorial.meteor.com/simple-todos/01-creating-app.html',
});
await insertLink({
title: 'Follow the Guide',
url: 'http://guide.meteor.com',
});
await insertLink({
title: 'Read the Docs',
url: 'https://docs.meteor.com',
});
await insertLink({
title: 'Discussions',
url: 'https://forums.meteor.com',
});
}
// We publish the entire Links collection to all clients.
// In order to be fetched in real-time to the clients
Meteor.publish("links", function () {
return LinksCollection.find();
});
});

View File

@@ -0,0 +1,13 @@
import type { Config } from "@swc/core";
const config: Config = {
jsc: {
transform: {
react: {
runtime: "automatic",
},
},
},
};
export default config;

View File

@@ -0,0 +1,21 @@
import assert from "assert";
import { Meteor } from "meteor/meteor";
describe("~name~", function () {
it("package.json has correct name", async function () {
const { name } = await import("../package.json");
assert.strictEqual(name, "~name~");
});
if (Meteor.isClient) {
it("client is not server", function () {
assert.strictEqual(Meteor.isServer, false);
});
}
if (Meteor.isServer) {
it("server is not client", function () {
assert.strictEqual(Meteor.isClient, false);
});
}
});

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react-jsx",
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"allowJs": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"/*": ["./*"],
"meteor/react-meteor-data/suspense": [
".meteor/local/types/node_modules/package-types/react-meteor-data/package/os/suspense/react-meteor-data.d.ts"
],
"meteor/*": [
"node_modules/@types/meteor/*",
".meteor/local/types/packages.d.ts"
]
},
"types": ["meteor", "mocha"]
},
"exclude": ["node_modules", ".meteor"]
}

View File

@@ -1,9 +0,0 @@
{
"jsc": {
"transform": {
"react": {
"runtime": "automatic"
}
}
}
}

View File

@@ -15,10 +15,11 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",
"@swc/core": "^1.15.18",
"@rspack/plugin-react-refresh": "^1.4.3",
"@types/meteor": "^2.9.9",
"@types/mocha": "^8.2.3",

View File

@@ -0,0 +1,13 @@
import type { Config } from "@swc/core";
const config: Config = {
jsc: {
transform: {
react: {
runtime: "automatic",
},
},
},
};
export default config;

View File

@@ -17,7 +17,7 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@meteorjs/rspack": "^1.1.0-beta.31",
"@meteorjs/rspack": "^1.1.0-beta.33",
"@rsdoctor/rspack-plugin": "^1.2.3",
"@rspack/cli": "^1.7.1",
"@rspack/core": "^1.7.1",

View File

@@ -229,7 +229,7 @@ Ensure your app defines these entry files with the correct paths where each modu
Defining entry points improves performance even with the Meteor bundler, as Meteor stops scanning and eagerly loading unnecessary files. For Meteor-Rspack integration, this is required, since it does not support automatic code discovery for efficiency.
In Meteor-Rspack integration, all app code is ignored by Meteor and handled by Rspack. By default, Meteor still processes eagerly CSS and HTML files in the entry folder (e.g. `client/*.[html|css]` in most apps).
In Meteor-Rspack integration, all app code is ignored by Meteor and handled by Rspack. By default, Meteor still processes eagerly HTML files in the entry folder (e.g. `client/*.html` in most apps). CSS files in the entry folder are automatically delegated to Rspack when a CSS loader is configured, see [CSS](#css) for details. If no CSS loader is present, Meteor handles them as before.
If you need Meteor to handle CSS or HTML files outside the main entry folder, add them to the `modules` field. This field accepts an array of strings, each pointing to a file or folder.
@@ -422,6 +422,8 @@ With the MeteorRspack integration, `zodern:melte` no longer works. Use the of
Meteor-Rspack comes with built-in CSS support. You can import any CSS file into your code, and it will be processed and included in your HTML skeleton automatically. In addition, any CSS file placed in the same folder as your Meteor entry point will be processed and added as global styles without the need for explicit imports.
When Rspack is configured with a CSS rule, whether through `postcss-loader`, `type: "css"`, or any other CSS-handling loader, Meteor automatically detects the handled file extensions after Rspack's first compilation and stops processing those files itself. This means you do not need to manually add CSS files to `.meteorignore` or otherwise tell Meteor to skip them. The same automatic delegation applies to Less and SCSS when their respective loaders are configured. If no CSS rule is present in the rspack configuration, Meteor continues to handle stylesheets as it normally would.
### CSS Modules
[CSS Modules](https://rspack.rs/guide/tech/css#css-modules) are supported out of the box — any file named `*.module.css` is automatically scoped locally.
@@ -827,6 +829,8 @@ new GenerateSW({
})
```
During development, the HMR dev server writes `sw.js` to disk by default, so build-generated service workers are served by Meteor's web server without extra configuration. If your service worker uses a different filename, see the [Dev Server](#dev-server) section for how to extend `writeToDisk`.
### Dev Server
You can customize the Rspack dev server much like you would when using meteor run. Any [devServer option listed in the official Rspack guide](https://rspack.rs/config/dev-server) can be applied in your apps [`rspack.config.js`](./rspack-bundler-integration.md#custom-rspackconfigjs).
@@ -840,6 +844,23 @@ RSPACK_DEVSERVER_PORT=3232 meteor run
The reason is that the Rspack dev server is handled by the Meteor so it can make both dev server works together, and the info of the port needs to be properly shared via the env.
During development, the HMR dev server keeps most build assets in memory and only writes HTML files and `sw.js` to disk by default. This means if your build pipeline generates files that need to be served from the root path, like `service-worker.js`, `manifest.json`, or any other output that Meteor's web server should serve directly, you can extend `writeToDisk` in your `rspack.config.js`:
```js
const { defineConfig } = require('@meteorjs/rspack');
module.exports = defineConfig(Meteor => ({
devServer: {
devMiddleware: {
writeToDisk: (filePath) =>
/\.(html)$/.test(filePath) || filePath.endsWith('service-worker.js'),
},
},
}));
```
In production, all build outputs are written to disk normally, so this only affects local development.
### Disable Plugins
Meteor allows disabling Rspack plugins that are added by default or through presets. This is useful when troubleshooting build issues or replacing a plugin with a custom implementation.

View File

@@ -246,6 +246,7 @@ If you run `meteor create` without arguments, Meteor will launch an interactive
Minimal # To create an app with as few Meteor packages as possible
React # To create a basic React-based app
Typescript # To create an app using TypeScript and React
Typescript-tailwind # To create an app using TypeScript, React, and Tailwind
Vue # To create a basic Vue3-based app
Svelte # To create a basic Svelte app
Tailwind # To create an app using React and Tailwind
@@ -316,6 +317,7 @@ meteor create my-app --from https://github.com/meteor/examples --from-branch mig
| `--apollo` | React + Apollo (GraphQL) | [Meteor 2 with GraphQL](https://react-tutorial.meteor.com/simple-todos-graphql/) |
| `--typescript` | React + TypeScript | [TypeScript Guide](/about/build-tool#typescript) |
| `--tailwind` | React + Tailwind CSS | - |
| `--typescript-tailwind` | React + TypeScript + Tailwind CSS | - |
| `--chakra-ui` | React + Chakra UI | [Simple Tasks Example](https://github.com/fredmaiaarantes/simpletasks) |
| `--coffeescript` | CoffeeScript | - |
| `--babel` | React with Babel support | - |