Merge branch 'release-3.4.1' into devel

This commit is contained in:
Nacho Codoñer
2026-03-30 18:04:21 +02:00
committed by GitHub
774 changed files with 54645 additions and 5072 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

111
.github/skills/ai-context/SKILL.md vendored Normal file
View File

@@ -0,0 +1,111 @@
---
name: ai-context
description: Use when creating, updating, or maintaining AI documentation files (AGENTS.md, CLAUDE.md, skills). Covers file structure, conventions, and guidelines for evolving AI context.
---
# AI Context Documentation
How to write and maintain the structured documentation that AI coding assistants consume.
## File Hierarchy
```
AGENTS.md # Root context — always loaded by agents
CLAUDE.md # Required for Claude Code (loads AGENTS.md)
.github/skills/<topic>/SKILL.md # On-demand detailed context
packages/<name>/AGENTS.md # Package-specific context
<any-folder>/AGENTS.md # Folder-specific context
```
## Root Files
### AGENTS.md
Always loaded on every interaction. Keep it **minimal** to save tokens.
Must contain:
- One-line project description
- Essential commands (run, test, build)
- Repository structure overview (top-level dirs only)
- Skills index table linking to each `SKILL.md`
- Key entry points for common tasks
Must **not** contain:
- Detailed explanations (put those in skills)
- Code examples longer than one line
- Duplicated content from skills
### CLAUDE.md
Required because Claude Code doesn't load `AGENTS.md` natively. It bridges Claude Code into the same context system. Contents:
```markdown
Read [AGENTS.md](AGENTS.md) before starting any task.
## Skills
Load these for detailed context on specific topics:
| Skill | When to use |
|-------|-------------|
| [<name>](.github/skills/<name>/SKILL.md) | <description> |
```
Keep in sync with the skills table in `AGENTS.md`.
## Skills
### Creating a Skill
1. Create `.github/skills/<topic>/SKILL.md`
2. Add YAML frontmatter with `name` and `description`
3. Add an entry to the skills table in both `AGENTS.md` and `CLAUDE.md`
### SKILL.md Format
```markdown
---
name: <topic>
description: <when an agent should load this — be specific about triggers>
---
# <Title>
<One-line summary.>
## <Sections organized by task>
```
### Writing Guidelines
- **Frontmatter `description`**: Write it as a trigger — what task or question should cause an agent to load this skill
- **Be concise**: Use tables over prose, code snippets over explanations
- **Be specific**: File paths, command names, function signatures — not vague descriptions
- **No duplication**: If info exists in another skill, reference it instead of repeating
- **Actionable structure**: Organize by what the agent needs to *do*, not by architecture
## Package & Folder Context
Add `AGENTS.md` inside a package or folder when:
- The directory has non-obvious conventions agents keep getting wrong
- There are local commands, patterns, or gotchas not covered by root docs
Keep these files very short — a few lines of context is often enough.
## When to Update
| Trigger | Action |
|---------|--------|
| Agent repeatedly asks about a topic | Create a new skill |
| Agent gets something wrong despite docs | Refine the relevant skill |
| New package/directory with unique patterns | Add a local `AGENTS.md` |
| Architecture or tooling changes | Update affected skills |
| Skill grows too large | Split into multiple skills |
| Skills table changes | Update both `AGENTS.md` and `CLAUDE.md` |
## Principles
1. **Token budget**: Root files stay small; details go in skills
2. **Load on demand**: Skills are only read when relevant to the task
3. **Living docs**: Update when patterns change — stale docs are worse than none
4. **Cross-platform**: `AGENTS.md` + `.github/skills/` is the shared convention; `CLAUDE.md` bridges Claude Code which doesn't load `AGENTS.md` natively

110
.github/skills/codebase/SKILL.md vendored Normal file
View File

@@ -0,0 +1,110 @@
---
name: codebase
description: Use when understanding the build system, modifying CLI commands, working with isobuild, or navigating the tools/ directory. Covers build pipeline flow and file locations.
---
# Codebase
Meteor's build system (Isobuild) and CLI structure.
## Overview
Meteor is a full-stack JavaScript platform with:
- **Core packages** in `/packages`
- **Build system (Isobuild)** in `/tools/isobuild`
- **CLI tool** in `/tools/cli`
- **Real-time data layer** via DDP
- **Mobile support** via Cordova
## Build Pipeline
1. **CLI** (`tools/cli/main.js`) → parses commands
2. **Project Context** (`project-context.js`) → resolves packages, dependencies
3. **Isobuild** (`tools/isobuild/`)
- Bundler (`bundler.js`) → orchestrates build
- Compiler (`compiler.js`) → compiles packages
- Linker (`linker.js`) → wraps modules
- Build plugins (Babel, TypeScript, CSS)
4. **Output**`star.json`, programs
5. **Runners** (`tools/runners/`) → run-app.js, run-mongo.js, run-hmr.js
6. **Live App** → DDP Server ↔ Minimongo ↔ UI
## Directory Structure
```
tools/
├── cli/ # Command-line interface
├── isobuild/ # Build system core
├── packaging/ # Package management
├── runners/ # App execution engines
├── fs/ # File system utilities
├── cordova/ # Mobile/Cordova support
├── static-assets/ # Project templates
└── project-context.js # Dependency resolution
```
## CLI (`tools/cli/`)
| File | Description |
|------|-------------|
| `main.js` | Entry point, command dispatcher |
| `commands.js` | Main command implementations |
| `commands-packages.js` | Package management commands |
| `commands-cordova.js` | Cordova/mobile commands |
**Commands:** `meteor create`, `run`, `build`, `deploy`, `add/remove`, `mongo`, `shell`
## Isobuild (`tools/isobuild/`)
| File | Description |
|------|-------------|
| `bundler.js` | High-level bundling orchestration |
| `compiler.js` | Package compilation |
| `linker.js` | Module wrapping and linking |
| `import-scanner.ts` | Import statement parsing |
| `compiler-plugin.js` | Compiler plugin API |
| `isopack.js` | Package format handling |
## Runners (`tools/runners/`)
| File | Description |
|------|-------------|
| `run-app.js` | Web application runner |
| `run-mongo.js` | MongoDB server runner |
| `run-hmr.js` | Hot module reload runner |
| `run-all.js` | Multi-runner orchestration |
## Build Targets
| Target | Description |
|--------|-------------|
| `web.browser` | Modern browsers |
| `web.browser.legacy` | Legacy browsers (IE11) |
| `web.cordova` | Cordova mobile apps |
| `server` | Node.js server |
## Package Relationships
- `tools-core` → rspack, future integrations
- `accounts-base` → all accounts-* packages
- `ddp-server` + `ddp-client` → realtime communication
- `mongo` → minimongo (client-side)
- `webapp` → all HTTP handling
## Project Templates
Via `meteor create --<template>`: `react`, `vue`, `svelte`, `angular`, `blaze`, `typescript`, `tailwind`, `solid`, `apollo`, `minimal`, `bare`, `full`
## Environment Variables
| Variable | Purpose |
|----------|---------|
| `METEOR_PROFILE` | Build profiling |
| `METEOR_PACKAGE_DIRS` | Additional package paths |
| `METEOR_DEBUG_BUILD` | Verbose build output |
## Troubleshooting
- **Package not found:** Check `package.js` name, run `meteor reset`
- **Build plugin not running:** Check `archMatching`, file extensions
- **npm issues:** Clear `.meteor/local/`, run `meteor npm install`

188
.github/skills/conventions/SKILL.md vendored Normal file
View File

@@ -0,0 +1,188 @@
---
name: conventions
description: Use when writing new packages, adding CLI commands, creating build plugins, or following Meteor code patterns. Covers package.js structure, file naming, and common code patterns.
---
# Code Conventions
Package structure, file naming, and code patterns for the Meteor codebase.
## Package Structure
Every Meteor package follows this structure:
```
packages/my-package/
├── package.js # Package manifest (name, version, dependencies, exports)
├── my-package.js # Main implementation (or split by concern)
├── my-package-server.js # Server-only code (optional)
├── my-package-client.js # Client-only code (optional)
├── my-package-tests.js # Tests (loaded via api.addFiles in test mode)
└── README.md # Documentation (optional)
```
## Package.js Anatomy
```javascript
Package.describe({
name: 'my-package',
version: '1.0.0',
summary: 'Brief description',
git: 'https://github.com/meteor/meteor.git',
documentation: 'README.md'
});
Package.onUse(function(api) {
api.versionsFrom(['3.0']); // Minimum Meteor version
api.use([
'ecmascript', // ES2015+ support
'mongo', // MongoDB integration
'tracker' // Reactivity (client)
]);
api.use('accounts-base', { weak: true }); // Optional dependency
api.mainModule('my-package-server.js', 'server');
api.mainModule('my-package-client.js', 'client');
api.export('MyPackage'); // Global export
});
Package.onTest(function(api) {
api.use(['tinytest', 'my-package']);
api.addFiles('my-package-tests.js');
});
Npm.depends({
'lodash': '4.17.21' // npm dependencies
});
```
## File Naming Conventions
| Pattern | Purpose |
|---------|---------|
| `*-server.js` | Server-only code |
| `*-client.js` | Client-only code |
| `*-common.js` | Shared code |
| `*-tests.js` | Test files |
| `*.d.ts` | TypeScript declarations |
## Common Patterns
### Adding a New Core Package
1. Create directory in `/packages/my-package/`
2. Add `package.js` with proper dependencies
3. Implement functionality with proper exports
4. Add tests in `*-tests.js`
5. Update version numbers if needed
### Modifying Build System
Key files to understand:
- `/tools/isobuild/bundler.js` - High-level bundling
- `/tools/isobuild/compiler.js` - Package compilation
- `/tools/project-context.js` - Dependency resolution
- `/tools/cli/commands.js` - CLI command handlers
### Adding CLI Commands
Edit `/tools/cli/commands.js` or create new command file:
```javascript
main.registerCommand({
name: 'my-command',
options: {
'option-name': { type: String, short: 'o' }
},
catalogRefresh: new catalog.Refresh.Never()
}, function(options) {
// Implementation
});
```
### WebApp Middleware Pattern
```javascript
import { WebApp } from 'meteor/webapp';
// Add middleware before Meteor's default handlers
WebApp.rawConnectHandlers.use('/api', (req, res, next) => {
// Runs before authentication
next();
});
// Add middleware after authentication
WebApp.connectHandlers.use('/api', (req, res, next) => {
// req.userId available if authenticated
next();
});
```
### Build Plugin Pattern
```javascript
// In package.js
Package.registerBuildPlugin({
name: 'compile-my-files',
use: ['ecmascript', 'caching-compiler'],
sources: ['plugin.js'],
npmDependencies: { 'my-compiler': '1.0.0' }
});
// In plugin.js
Plugin.registerCompiler({
extensions: ['myext'],
archMatching: 'web'
}, () => new MyCompiler());
class MyCompiler extends CachingCompiler {
getCacheKey(inputFile) {
return inputFile.getSourceHash();
}
compileOneFile(inputFile) {
const source = inputFile.getContentsAsString();
const compiled = transform(source);
inputFile.addJavaScript({
data: compiled,
path: inputFile.getPathInPackage() + '.js'
});
}
}
```
### Using tools-core in Packages
```javascript
// In package.js
api.use('tools-core');
// In implementation
import {
logProgress,
checkNpmDependencyExists,
getMeteorAppConfig,
spawnProcess
} from 'meteor/tools-core';
// Check and install dependencies
if (!checkNpmDependencyExists('@rspack/core')) {
installNpmDependency(['@rspack/core@^1.7.1']);
}
// Spawn external process
const proc = spawnProcess('npx', ['rspack', 'build'], {
cwd: getMeteorAppDir(),
onStdout: (data) => logProgress(data)
});
```
## Version Patterns
Meteor uses `X.Y.Z-rcN.M` versioning where:
- `X.Y.Z` - Semantic version
- `rcN` - Release candidate number
- `M` - Package-specific revision

50
.github/skills/e2e-coverage/SKILL.md vendored Normal file
View File

@@ -0,0 +1,50 @@
---
name: e2e-coverage
description: Use when adding, modifying, or reviewing E2E test apps/skeletons to keep the test coverage report up to date.
---
# E2E Test Coverage Report
Guidelines for maintaining `dev/modern-tools/rspack/E2E_COVERAGE.md` — a single-page report of what every E2E app and skeleton tests.
## When to Update
| Trigger | Action |
|---------|--------|
| New app added to `apps/` | Add a subsection under **Apps** with a coverage table |
| New skeleton added to `skeleton.test.js` | Add a row to the **Skeletons** table |
| New npm package imported for compatibility testing | Add an entry under **NPM Package Compatibility** with the package name, file, and reason |
| New custom assertion added to a test file | Add a row to that app's coverage table |
| New feature tested across multiple apps | Add a row to the **Feature Coverage Matrix** |
| App or skeleton removed | Remove its entries from all sections |
## Report Structure
The report has five sections, in this order:
1. **Test Lifecycle** — the phases every app/skeleton goes through (init, run, prod, test, test once, build) and what default assertions apply
2. **Apps** — one subsection per `apps/<name>/` with a short description and a `| What is covered | Phase |` table
3. **Skeletons** — single table with one row per skeleton (`| Skeleton | Port | Language | Extra coverage |`)
4. **NPM Package Compatibility** — grouped by app, each entry has the package name, file path, and why it's included (ESM-only, native bindings, subpath exports, etc.)
5. **Feature Coverage Matrix** — cross-reference table (`| Feature | Apps | Skeletons |`) showing where each capability is tested
## How to Gather Information
For each app or skeleton, check these sources:
| Source | What to look for |
|--------|-----------------|
| `<name>.test.js` | Test helper used, options (`env`, `configFile`, `buildDir`, `testFullApp`, `checkBundleFilePaths`), all `customAssertions` callbacks and what they assert |
| `skeleton.test.js` | The `testMeteorSkeleton({ skeletonName: '<name>' })` block for that skeleton |
| `apps/<name>/server/main.js` | npm imports with comments explaining why (ESM-only, native bindings, etc.) |
| `apps/<name>/imports/` | Shared code with special imports (`node:` protocol, JSX packages) |
| `apps/<name>/rspack.config.*` | Custom config features (`compileWithRspack`, `compileWithMeteor`, `disablePlugins`, custom rules) |
| `apps/<name>/package.json` | Dependencies that exist solely for compatibility testing |
## Writing Guidelines
- Keep descriptions short — one line per table row
- Use the phase names from the lifecycle table: Init, Run, Prod, Test, Build, All
- For npm packages, always state the **reason** (what module format issue it validates)
- Don't duplicate info between the per-app table and the feature matrix — the app table has detail, the matrix has the cross-reference
- When an env var is set in a test file, note it as `All (env prefix)` in the phase column

246
.github/skills/modern-tools/SKILL.md vendored Normal file
View File

@@ -0,0 +1,246 @@
---
name: modern-tools
description: Use when working with tools-core utilities, rspack integration, or modern tooling. Covers logging, npm management, process spawning, git helpers, and Meteor app configuration APIs.
---
# Modern Tools
Utility packages for modern tooling, bundler integrations, and native solutions.
## tools-core (`/packages/tools-core`)
Central utility package providing helpers for npm, logging, process management, and Meteor configuration. This is the foundation for modern tool integrations.
### Logging Module (`lib/log.js`)
```javascript
import { logProgress, logError, logInfo, logSuccess } from 'meteor/tools-core';
logProgress('Building application...'); // Blue
logSuccess('Build complete'); // Green
logError('Build failed'); // Red
logInfo('Using Rspack bundler'); // Purple
```
Respects `METEOR_DISABLE_COLORS` environment variable.
### NPM Management Module (`lib/npm.js`)
| Function | Description |
|----------|-------------|
| `getNodeBinaryPath(binaryName)` | Gets path to Node binaries (npm, npx, node) |
| `checkNpmDependencyExists(dep, opts)` | Checks if npm package is installed |
| `checkNpmBinaryExists(binary, opts)` | Checks if binary exists in node_modules/.bin |
| `checkNpmDependencyVersion(dep, opts)` | Validates semver with conditions (gte, lt, eq) |
| `installNpmDependency(deps, opts)` | Installs dependencies (npm/yarn, dev/exact flags) |
| `getNpmCommand(args)` | Returns npm command with `meteor npm` fallback |
| `getNpxCommand(args)` | Returns npx command with `meteor npx` fallback |
| `getYarnCommand(args)` | Gets yarn command path |
| `isYarnProject(opts)` | Detects yarn projects (yarn.lock, packageManager) |
| `getMonorepoPath(opts)` | Detects monorepo root (workspaces, lerna, pnpm) |
| `isMonorepo(opts)` | Boolean monorepo detection |
### Process Management Module (`lib/process.js`)
| Function | Description |
|----------|-------------|
| `spawnProcess(cmd, args, opts)` | Spawns process with streaming output, color preservation |
| `stopProcess(proc, opts)` | Graceful termination with SIGTERM/SIGKILL fallback |
| `isProcessRunning(proc)` | Checks if process is still running |
| `isPortAvailable(port, host)` | Checks if port is free |
| `waitForPort(port, opts)` | Waits for port availability with timeout |
Options for `spawnProcess`: `env`, `cwd`, `detached`, `onStdout`, `onStderr`, `onExit`, `onError`
### Meteor Configuration Module (`lib/meteor.js`)
**Application Configuration:**
| Function | Description |
|----------|-------------|
| `getMeteorAppDir()` | Gets application root directory |
| `getMeteorAppPackageJson()` | Parses app's package.json |
| `getMeteorAppConfig()` | Retrieves Meteor config from package.json or Plugin |
| `getMeteorAppPort()` | Gets app port from environment |
| `getMeteorAppConfigModern()` | Gets modern bundler configuration |
| `isMeteorAppConfigModernVerbose()` | Checks verbose flag |
| `hasMeteorAppConfigAutoInstallDeps()` | Auto-install deps flag |
**Entry Points:**
| Function | Description |
|----------|-------------|
| `getMeteorAppEntrypoints()` | Gets main/test modules for client/server |
| `getMeteorInitialAppEntrypoints()` | Gets initial entry points with HTML detection |
| `isMeteorAppTestModule()` | Checks if project is test module |
| `setMeteorAppEntrypoints(opts)` | Sets entry points via environment variables |
| `setMeteorAppIgnore(pattern)` | Sets file ignore patterns |
| `setMeteorAppCustomScriptUrl(url)` | Sets custom script URLs |
**Command Detection:**
| Function | Description |
|----------|-------------|
| `isMeteorAppRun()` | Running in 'run' mode |
| `isMeteorAppBuild()` | Running in 'build' or 'deploy' |
| `isMeteorAppUpdate()` | Running in 'update' |
| `isMeteorAppTest()` | In test mode |
| `isMeteorAppTestFullApp()` | Test mode with full-app flag |
| `isMeteorAppTestWatch()` | Test mode in watch mode |
| `isMeteorAppNativeAndroid()` | Native Android mode |
| `isMeteorAppNativeIos()` | Native iOS mode |
| `isMeteorAppNative()` | Any native mode |
| `isMeteorAppDevelopment()` | Development mode |
| `isMeteorAppProduction()` | Production mode |
| `isMeteorAppDebug()` | Debug mode |
**Package Detection:**
| Function | Description |
|----------|-------------|
| `isMeteorBlazeProject()` | Has blaze/blaze-html-templates |
| `isMeteorBlazeHotProject()` | Blaze with hot reload |
| `isMeteorCoffeescriptProject()` | Has CoffeeScript |
| `isMeteorLessProject()` | Has Less CSS |
| `isMeteorScssProject()` | Has SCSS/Sass |
| `isMeteorTypescriptProject()` | Has TypeScript |
| `isMeteorBundleVisualizerProject()` | Has bundle visualizer |
| `isMeteorPackagesTest()` | test-packages command |
**File Operations:**
| Function | Description |
|----------|-------------|
| `getMeteorAppFilesAndFolders(opts)` | Scans app directory (recursive, with ignore) |
| `getMeteorAppPackages()` | Lists all loaded packages |
| `getMeteorEnvPackageDirs()` | Gets package directories from env vars |
| `getMeteorToolsRequire(filePath)` | Requires module relative to Meteor tools |
### Global State Module (`lib/global-state.js`)
Maintains persistent state across file changes during development:
```javascript
import { getGlobalState, setGlobalState, removeGlobalState, clearGlobalState } from 'meteor/tools-core';
setGlobalState('buildStartTime', Date.now());
const startTime = getGlobalState('buildStartTime');
```
### Git Management Module (`lib/git.js`)
| Function | Description |
|----------|-------------|
| `isGitRepository(dir)` | Checks if directory is git repo |
| `gitignoreExists(dir)` | Checks .gitignore existence |
| `ensureGitignoreExists(dir, entries)` | Creates .gitignore with initial entries |
| `getMissingGitignoreEntries(dir, entries)` | Finds missing entries |
| `addGitignoreEntries(dir, entries, ctx)` | Adds entries with context logging |
### String Utilities (`lib/string.js`)
| Function | Description |
|----------|-------------|
| `capitalizeFirstLetter(str)` | Capitalizes first character |
| `shuffleString(str)` | Shuffles string characters |
| `joinWithAnd(items, opts)` | Human-readable list ("a, b, and c") |
---
## Rspack Integration (`/packages/rspack`)
Modern bundler integration using Rspack (Rust-based Webpack alternative).
### Package Structure
| File | Description |
|------|-------------|
| `lib/constants.js` | Default versions, global state keys, build contexts |
| `lib/dependencies.js` | Dependency checking and auto-installation |
| `lib/build-context.js` | Build directory management |
| `lib/config.js` | Meteor configuration for Rspack |
| `lib/processes.js` | Rspack process spawning |
| `lib/compilation.js` | Compilation tracking |
### Build Contexts
| Context | Directory | Purpose |
|---------|-----------|---------|
| `RSPACK_BUILD_CONTEXT` | `_build` | Build output |
| `RSPACK_ASSETS_CONTEXT` | `build-assets` | Static assets |
| `RSPACK_CHUNKS_CONTEXT` | `build-chunks` | Chunk bundles |
| `RSPACK_DOCTOR_CONTEXT` | `.rsdoctor` | Analysis/diagnostics |
### Key Dependencies
- `@rspack/core` ^1.7.1
- `@meteorjs/rspack` ^0.3.56 (configuration logic)
- `@rspack/plugin-react-refresh` ^1.4.3
- `swc-loader` ^0.2.6
### Integration with tools-core
- Uses `getMeteorInitialAppEntrypoints()` for entry points
- Uses command detection functions for build mode awareness
- Uses process spawning and npm utilities
---
## TypeScript Compiler (`/packages/typescript`)
Compiler plugin for TypeScript/TSX file compilation.
**Registered Plugin:** `compile-typescript`
**Supported Extensions:** `.ts`, `.tsx`
**Implied Packages:** `modules`, `ecmascript-runtime`, `babel-runtime`, `promise`, `dynamic-import`
**Features:**
- Transpiles TypeScript before Babel processing
- Supports client/server/legacy browser targets
- Integrates with React Fast Refresh for HMR
**Limitations:**
- Per-file transpilation (no cross-file type analysis)
- No tsconfig.json support (Meteor manages settings)
- No type checking during compilation
- No .d.ts generation
---
## WebApp & Express (`/packages/webapp`)
HTTP server integration using Express.js 5.x framework.
### Key APIs
```javascript
import { WebApp } from 'meteor/webapp';
// Middleware registration
WebApp.connectHandlers.use('/api', myMiddleware);
WebApp.handlers.use(compression());
// Direct Express access
WebApp.expressApp.get('/health', (req, res) => res.send('OK'));
// Server instance
WebApp.httpServer;
// Hooks
WebApp.onListening(() => console.log('Server ready'));
```
### Express Exports
| Property | Description |
|----------|-------------|
| `WebApp.connectHandlers` | Express middleware registry (legacy name) |
| `WebApp.handlers` | Current middleware registry |
| `WebApp.rawConnectHandlers` | Raw Express handlers |
| `WebApp.expressApp` | Direct Express app instance |
| `WebApp.httpServer` | HTTP server instance |
| `WebApp.express` | Express module export |
**Dependencies:** express@5.1.0, cookie-parser@1.4.6, compression@1.7.4, errorhandler@1.5.1

152
.github/skills/packages/SKILL.md vendored Normal file
View File

@@ -0,0 +1,152 @@
---
name: packages
description: Use when exploring the package ecosystem, finding which package handles a feature, understanding package relationships, or adding dependencies. Lists all core packages by domain.
---
# Core Packages
Overview of Meteor's package ecosystem organized by domain.
## Authentication & Accounts
| Package | Description |
|---------|-------------|
| `accounts-base` | Foundation for the user account system |
| `accounts-password` | Password-based authentication |
| `accounts-passwordless` | Magic-link/token-based authentication |
| `accounts-2fa` | Two-factor authentication support |
| `accounts-ui` / `accounts-ui-unstyled` | Pre-built UI components for auth |
| `accounts-oauth` | OAuth protocol support |
| `oauth` / `oauth1` / `oauth2` | OAuth implementation |
| `oauth-encryption` | Encrypted OAuth token storage |
| `service-configuration` | OAuth provider configuration |
**Social Login Providers:**
- `accounts-facebook`, `accounts-github`, `accounts-google`
- `accounts-twitter`, `accounts-meetup`, `accounts-weibo`
- `accounts-meteor-developer`
## Data & Database
| Package | Description |
|---------|-------------|
| `mongo` | MongoDB integration and collection API |
| `minimongo` | Client-side MongoDB emulation |
| `mongo-id` | MongoDB ObjectID generation |
| `mongo-livedata` | Reactive MongoDB queries |
| `npm-mongo` | MongoDB Node.js driver wrapper |
| `mongo-dev-server` | Development MongoDB server |
| `ddp` | Distributed Data Protocol meta-package |
| `ddp-common` | Shared DDP utilities |
| `ddp-client` | DDP client implementation |
| `ddp-server` | DDP server implementation |
| `ddp-rate-limiter` | Rate limiting for DDP methods/subscriptions |
| `ejson` | Extended JSON serialization |
## Build System & Compilation
| Package | Description |
|---------|-------------|
| `babel-compiler` | JavaScript transpilation via Babel |
| `babel-runtime` | Babel runtime helpers |
| `ecmascript` | ECMAScript 2015+ support |
| `ecmascript-runtime` | ES6+ runtime polyfills |
| `typescript` | TypeScript compilation support |
| `modules` | ES modules system |
| `modules-runtime` | Module runtime implementation |
| `modules-runtime-hot` | Hot module reloading runtime |
| `hot-code-push` | Live code updates |
| `hot-module-replacement` | HMR support |
| `rspack` | Rspack bundler integration |
| `boilerplate-generator` | HTML boilerplate generation |
| `dynamic-import` | Dynamic `import()` support |
| `caching-compiler` | Build cache management |
## Minification & Assets
| Package | Description |
|---------|-------------|
| `minifier-js` | JavaScript minification (terser) |
| `minifier-css` | CSS minification |
| `standard-minifier-js` | Default JS minifier package |
| `standard-minifier-css` | Default CSS minifier package |
| `standard-minifiers` | Meta-package for minifiers |
| `static-html` | Static HTML file processing |
## Web & Server
| Package | Description |
|---------|-------------|
| `webapp` | HTTP server and request handling |
| `webapp-hashing` | Asset fingerprinting |
| `reload` | Client-side app reload mechanism |
| `reload-safetybelt` | Reload failure recovery |
| `autoupdate` | Automatic client updates |
| `browser-policy` | Content Security Policy |
| `force-ssl` | HTTPS enforcement |
| `allow-deny` | Collection permission rules |
| `fetch` | HTTP Fetch API polyfill |
| `routepolicy` | Route-based policies |
## Client-Side Utilities
| Package | Description |
|---------|-------------|
| `tracker` | Reactive dependency tracking |
| `reactive-var` | Single reactive value |
| `reactive-dict` | Reactive key-value store |
| `session` | Client-side session storage |
| `localstorage` | LocalStorage wrapper |
| `socket-stream-client` | WebSocket client |
| `random` | Cryptographic random generation |
| `check` | Runtime type checking |
| `underscore` | Utility library |
| `base64` | Base64 encoding/decoding |
| `diff-sequence` | Array diffing algorithm |
| `id-map` | ID-based mapping |
| `ordered-dict` | Ordered dictionary |
## Testing (6 packages)
| Package | Description |
|---------|-------------|
| `tinytest` | Meteor's built-in test framework |
| `tinytest-harness` | Test harness utilities |
| `test-helpers` | Testing utility functions |
| `test-in-browser` | Browser-based test runner |
| `test-in-console` | Console-based test runner |
## Context & Roles
| Package | Description |
|---------|-------------|
| `context` | Request context management (AsyncLocalStorage) |
| `roles` | User roles and permissions system |
## Deprecated Packages (`packages/deprecated/`)
40+ legacy packages maintained for backward compatibility:
- UI libraries: `amplify`, `backbone`, `d3`, `handlebars`
- Legacy OAuth: `facebook`, `github`, `google` (use `accounts-*` instead)
- Config UIs: `*-config-ui` packages
- Others: `jquery-history`, `jshint`, `jsparse`, `deps` (use `tracker`)
## Development-Only Packages
| Package | Description |
|---------|-------------|
| `autopublish` | Auto-publish all collections (remove in production) |
| `insecure` | Allow all database writes (remove in production) |
## NPM Packages (`/npm-packages`)
Packages published to npm for external use:
| Package | npm Name | Description |
|---------|----------|-------------|
| `meteor-babel` | `@meteorjs/babel` | Babel wrapper for ES2015+ transpilation |
| `babel-preset-meteor` | `@meteorjs/babel-preset-meteor` | Babel preset with Meteor-specific transforms |
| `meteor-rspack` | `@meteorjs/rspack` | Rspack configuration builder |
| `meteor-promise` | `meteor-promise` | ES6 Promise with Fiber support |
| `meteor-node-stubs` | `meteor-node-stubs` | Node.js core module polyfills for browser |
| `eslint-plugin-meteor` | `eslint-plugin-meteor` | Meteor-specific ESLint rules |

147
.github/skills/testing/SKILL.md vendored Normal file
View File

@@ -0,0 +1,147 @@
---
name: testing
description: Use when writing tests, debugging test failures, running the test suite, or setting up test infrastructure. Covers self-test, package tests, and modern E2E tests.
---
# Testing
Test patterns, commands, and utilities for the Meteor codebase.
## Test Commands
```bash
# CLI self-tests
./meteor self-test # Run all CLI tests
./meteor self-test "test name" # Run specific test
./meteor self-test --list # List available tests
./meteor self-test --exclude "^[a-b]" # Exclude tests by regex
./meteor self-test --retries 0 # Skip retries in development
# Package tests (TinyTest — view results at http://localhost:3000)
./meteor test-packages # Test all core packages
./meteor test-packages mongo # Test specific package
TINYTEST_FILTER="collection" ./meteor test-packages # Filter specific tests
# Package tests in console (headless via Puppeteer — prints results to terminal)
# Use this for automation or when you need terminal output without a browser.
PUPPETEER_DOWNLOAD_PATH=~/.npm/chromium ./packages/test-in-console/run.sh
./packages/test-in-console/run.sh # Test all core packages
./packages/test-in-console/run.sh "mongo" # Test specific package
# E2E tests (Jest + Playwright)
npm run install:e2e # Install dependencies
npm run test:e2e # Run all E2E tests
npm run test:e2e -- -t="React" # Run specific test
```
## E2E Tests (`tools/e2e-tests/`)
Jest + Playwright suite for verifying bundler integrations (rspack). Tests cover framework skeletons and build scenarios.
**Test apps:** `apps/{react,vue,svelte,solid,blaze,typescript,babel,coffeescript,monorepo}`
## Test Helpers Package (`packages/test-helpers`)
Comprehensive testing utilities for Meteor applications.
### Async Testing
```javascript
import { testAsyncMulti, simplePoll, waitUntil } from 'meteor/test-helpers';
// Wait for condition
await waitUntil(() => someCondition, { timeout: 5000, interval: 100 });
// Poll until ready
simplePoll(() => isReady(), successCallback, failCallback);
```
### DOM/UI Testing
```javascript
import { clickElement, simulateEvent, canonicalizeHtml, renderToDiv } from 'meteor/test-helpers';
clickElement(button);
simulateEvent(input, 'keydown', { keyCode: 13 });
const normalized = canonicalizeHtml(html);
```
### Connection Testing
```javascript
import { makeTestConnection, captureConnectionMessages } from 'meteor/test-helpers';
const conn = makeTestConnection(clientId);
const messages = captureConnectionMessages(server);
```
### Utilities
| Function | Description |
|----------|-------------|
| `SeededRandom` | Predictable random for deterministic tests |
| `try_all_permutations()` | Test all permutations of inputs |
| `withCallbackLogger()` | Track callback invocations |
| `mockBehaviours()` | Behavior mocking |
## Tinytest (`packages/tinytest`)
Meteor's built-in test framework.
```javascript
Tinytest.add('my test', function (test) {
test.equal(1 + 1, 2);
test.isTrue(true);
test.throws(function () { throw new Error(); });
});
Tinytest.addAsync('async test', async function (test) {
const result = await asyncOperation();
test.equal(result, expected);
});
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `TEST_METADATA` | Test configuration JSON |
| `METEOR_TEST_PACKAGES` | Packages to test |
## Debug Commands
```bash
# Verbose build output
METEOR_DEBUG_BUILD=1 ./meteor run
# Profile build performance
METEOR_PROFILE=1 ./meteor build
# Force rebuild
./meteor reset && ./meteor run
# Debug Meteor tool with Chrome inspector
TOOL_NODE_FLAGS="--inspect-brk" ./meteor
```
## Writing Package Tests
In `package.js`:
```javascript
Package.onTest(function(api) {
api.use(['tinytest', 'test-helpers', 'my-package']);
api.addFiles('my-package-tests.js');
});
```
In `my-package-tests.js`:
```javascript
import { MyPackage } from 'meteor/my-package';
Tinytest.add('MyPackage - basic functionality', function (test) {
const result = MyPackage.doSomething();
test.equal(result, expected);
});
```

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

@@ -0,0 +1,95 @@
name: E2E Tests
on:
pull_request:
paths:
- 'meteor'
- 'tools/e2e-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:
TOOL_NODE_FLAGS: "--max_old_space_size=12288"
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
- Monorepo
- Other
- 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/e2e-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:e2e
- name: Set NPM_LINK_RSPACK=false for release branches
run: |
echo "Current branch: ${{ github.head_ref || github.ref_name }}"
if [[ "${{ github.head_ref || github.ref_name }}" == release-* ]]; then
echo "NPM_LINK_RSPACK=false" >> $GITHUB_ENV
echo "::warning::NPM_LINK_RSPACK=false on release branch. E2E tests will install @meteorjs/rspack from npm — make sure the latest version is published or tests may fail."
fi
- 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:e2e -- -t="${{ matrix.category }}"

View File

@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v3
- name: Manage inactive issues
uses: actions/github-script@v6
uses: actions/github-script@v8
with:
script: |
const script = require('./.github/scripts/inactive-issues.js')

52
.github/workflows/test-packages.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Package tests
on:
pull_request:
jobs:
test-packages:
runs-on: ubuntu-22.04
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
timeout-minutes: 90
env:
CXX: g++-12
phantom: false
PUPPETEER_DOWNLOAD_PATH: /home/runner/.npm/chromium
TEST_PACKAGES_EXCLUDE: stylus
METEOR_MODERN: true
NODE_ENV: CI
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.17.0
- name: Restore caches
uses: actions/cache@v4
with:
path: |
~/.npm
.meteor
.babel-cache
dev_bundle
/home/runner/.npm/chromium
key: ${{ runner.os }}-node-22.17-${{ hashFiles('meteor', '**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-22.17-
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y g++-12 libnss3
- name: Install npm dependencies
run: npm install
- name: Run test-in-console suite
run: ./packages/test-in-console/run.sh

36
.github/workflows/unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Unit Tests
on:
pull_request:
paths:
- 'tools/**'
- 'scripts/**'
- 'package.json'
- '.github/workflows/unit-tests.yml'
push:
branches:
- devel
concurrency:
group: unit-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Install unit test deps
run: npm run install:unit
- name: Run unit tests
run: npm run test:unit

View File

@@ -1,4 +1,4 @@
name: Meteor Selftest Windows
name: Windows Selftest
on:
pull_request:
@@ -6,10 +6,19 @@ on:
- opened
- reopened
- synchronize
paths:
- 'meteor'
- 'meteor.bat'
- 'tools/**'
- 'packages/babel-compiler/**'
- 'packages/dynamic-import/**'
- 'packages/meteor/**'
- 'packages/meteor-tool/**'
- '.github/workflows/windows-selftest.yml'
push:
branches:
- devel
- 2.x.x
env:
METEOR_PRETTY_OUTPUT: 0
@@ -28,7 +37,7 @@ jobs:
cancel-in-progress: true
steps:
- name: cleanup
- name: Cleanup
shell: powershell
run: Remove-Item -Recurse -Force ${{ github.workspace }}\*
@@ -36,27 +45,47 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Cache dependencies
id: meteor-cache
uses: actions/cache@v4
with:
path: |
dev_bundle/
.babel-cache/
.meteor/
~/.npm
node_modules/
packages/**/.npm
key: ${{ runner.os }}-meteor-${{ hashFiles('meteor', 'meteor.bat') }}
restore-keys: |
${{ runner.os }}-meteor-
- name: Reset submodules (force refetch)
shell: pwsh
run: |
git submodule deinit -f --all
if (Test-Path ".git/modules") { Remove-Item -Recurse -Force ".git/modules" }
- name: Install dependencies
shell: pwsh
run: |
$env:PATH = "C:\Program Files\7-Zip;$env:PATH"
.\scripts\windows\ci\install.ps1
# Run ONLY when the cache was NOT restored
- name: Prepare Meteor (cache miss)
if: steps.meteor-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$env:PATH = "C:\Program Files\7-Zip;$env:PATH"
.\meteor.bat --get-ready
- name: Run tests
shell: pwsh
run: |
$env:PATH = "C:\Program Files\7-Zip;$env:PATH"
.\scripts\windows\ci\test.ps1
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
.\dev_bundle
.\.babel-cache
.\.meteor
key: ${{ runner.os }}-meteor-${{ hashFiles('**/package-lock.json') }}

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ node_modules
\#*\#
.\#*
.idea
!.idea/icon.svg
*.iml
*.sublime-project
*.sublime-workspace

21
.idea/icon.svg generated Executable file
View File

@@ -0,0 +1,21 @@
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" id="svg2" preserveAspectRatio="xMidYMid meet" version="1.1" viewBox="0 0 160.10664 156.98515" height="156.98515" width="160.10664">
<metadata id="metadata26">
<rdf:rdf xmlns="http://www.w3.org/1999/xhtml">
<cc:work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"></dc:type>
<dc:title></dc:title>
</cc:work>
</rdf:rdf>
</metadata>
<defs id="defs24"/>
<g transform="matrix(0.62649123,0,0,0.62649123,-0.27477954,-0.27455194)" id="g6" style="fill:#de4f4f">
<path style="fill:#de4f4f; fill-opacity:1; stroke:none" d="M 0.43860078,0.43823749 219.30039,232.26587 c 0,0 7.45622,5.25885 13.15803,-0.87647 5.70181,-6.13533 1.3158,-12.27065 1.3158,-12.27065 L 0.43860078,0.43823749 l 0,0 z" id="path8"/>
<path style="fill:#de4f4f; fill-opacity:1; stroke:none" d="M 69.737525,22.350112 236.40582,202.02749 c 0,0 7.45622,5.25884 13.15803,-0.87648 5.70181,-6.13533 1.3158,-12.27065 1.3158,-12.27065 L 69.737525,22.350112 l 0,0 z" id="path10"/>
<path style="fill:#de4f4f; fill-opacity:1; stroke:none" d="M 21.052838,69.241524 187.72114,248.9189 c 0,0 7.45621,5.25885 13.15802,-0.87648 5.70181,-6.13532 1.3158,-12.27065 1.3158,-12.27065 L 21.052838,69.241524 l 0,0 z" id="path12"/>
<path style="fill:#de4f4f; fill-opacity:1; stroke:none" d="M 128.32077,41.194324 244.76195,166.72418 c 0,0 5.20922,3.67404 9.19273,-0.61234 3.98351,-4.28639 0.91927,-8.57278 0.91927,-8.57278 L 128.32077,41.194324 l 0,0 z" id="path14"/>
<path style="fill:#de4f4f; fill-opacity:1; stroke:none" d="M 37.091803,123.58297 153.53299,249.11282 c 0,0 5.20921,3.67405 9.19273,-0.61234 3.98351,-4.28638 0.91927,-8.57277 0.91927,-8.57277 L 37.091803,123.58297 l 0,0 z" id="path16"/>
<path style="fill:#de4f4f; fill-opacity:1; stroke:none" d="m 188.15974,68.365049 52.77506,57.067161 c 0,0 2.57683,1.72156 4.54735,-0.28693 1.97051,-2.00849 0.45473,-4.01699 0.45473,-4.01699 l -57.77714,-52.763241 0,0 z" id="path18"/>
<path style="fill:#de4f4f; fill-opacity:1; stroke:none" d="m 66.228719,181.43032 52.775071,57.06716 c 0,0 2.57682,1.72156 4.54734,-0.28693 1.97051,-2.00849 0.45473,-4.01698 0.45473,-4.01698 l -57.777141,-52.76325 0,0 z" id="path20"/>
</g>
</svg>

68
AGENTS.md Normal file
View File

@@ -0,0 +1,68 @@
# Meteor
Full-stack JavaScript platform for modern web and mobile applications.
## Commands
```bash
./meteor run # Run from source
./meteor create my-app # Create app
./meteor self-test # CLI tests
./meteor test-packages ./packages/<name> # Package tests (browser UI at localhost:3000)
./packages/test-in-console/run.sh "<name>" # Package tests (terminal output via Puppeteer)
npm run test:unit # Unit tests (Jest)
npm run test:e2e # E2E tests (Jest + Playwright)
```
> **Note:** `./meteor test-packages` starts a web server and waits for a browser —
> it produces no terminal output. For automated/headless runs, use
> `./packages/test-in-console/run.sh "<package>"` instead, which runs the same tests
> via Puppeteer and prints pass/fail results to stdout.
## Structure
```
packages/ # Core Meteor packages (~100+)
tools/ # CLI & build system (Isobuild)
npm-packages/ # Published @meteorjs/* packages
scripts/ # Build & release automation
```
## Key Entry Points
| Task | Location |
|------|----------|
| CLI commands | `tools/cli/commands.js` |
| Build system | `tools/isobuild/bundler.js` |
| Package lookup | `packages/<name>/package.js` |
| Modern bundler | `packages/rspack/`, `packages/tools-core/` |
## Skills
Load these for detailed context on specific topics:
| Skill | When to use |
|-------|-------------|
| [codebase](.github/skills/codebase/SKILL.md) | Build system, CLI, isobuild, tools/ directory |
| [conventions](.github/skills/conventions/SKILL.md) | Writing packages, CLI commands, code patterns |
| [testing](.github/skills/testing/SKILL.md) | Writing tests, debugging failures, test infrastructure |
| [packages](.github/skills/packages/SKILL.md) | Finding packages by feature, understanding dependencies |
| [modern-tools](.github/skills/modern-tools/SKILL.md) | tools-core utilities, rspack, modern integrations |
| [e2e-coverage](.github/skills/e2e-coverage/SKILL.md) | Updating the E2E test coverage report when apps/skeletons change |
| [ai-context](.github/skills/ai-context/SKILL.md) | Creating, updating, or maintaining AI documentation files |
## Package Domains
| Category | Packages |
|----------|----------|
| Auth | `accounts-base`, `accounts-password`, `accounts-oauth` |
| Database | `mongo`, `minimongo`, `ddp-server`, `ddp-client` |
| Build | `babel-compiler`, `ecmascript`, `typescript`, `rspack` |
| Web | `webapp`, `autoupdate`, `reload` |
| Reactivity | `tracker`, `reactive-var`, `reactive-dict` |
## Notes
- `docs/` and `guide/` are the public documentation website, not agent context
- `v3-docs/` contains Meteor 3.x documentation
- See [DEVELOPMENT.md](DEVELOPMENT.md) for contributor setup

15
CLAUDE.md Normal file
View File

@@ -0,0 +1,15 @@
Read [AGENTS.md](AGENTS.md) before starting any task.
## Skills
Load these for detailed context on specific topics:
| Skill | When to use |
|-------|-------------|
| [codebase](.github/skills/codebase/SKILL.md) | Build system, CLI, isobuild, tools/ directory |
| [conventions](.github/skills/conventions/SKILL.md) | Writing packages, CLI commands, code patterns |
| [testing](.github/skills/testing/SKILL.md) | Writing tests, debugging failures, test infrastructure |
| [packages](.github/skills/packages/SKILL.md) | Finding packages by feature, understanding dependencies |
| [modern-tools](.github/skills/modern-tools/SKILL.md) | tools-core utilities, rspack, modern integrations |
| [e2e-coverage](.github/skills/e2e-coverage/SKILL.md) | Updating the E2E test coverage report when apps/skeletons change |
| [ai-context](.github/skills/ai-context/SKILL.md) | Creating, updating, or maintaining AI documentation files |

View File

@@ -33,6 +33,14 @@ can run Meteor directly from a Git checkout using these steps:
$ ./meteor --help
```
> **Note for Windows (PowerShell):**
>
> * In PowerShell, use `.\meteor` (not `./meteor`).
> * Meteor may need `7z.exe` available in your `PATH` to download/extract binaries (dev_bundle).
> * Verify: `where.exe 7z`
> * If missing, install 7-Zip and ensure it is on your PATH (for example via `choco install 7zip -y` or `scoop install 7zip`).
3. **Ready to Go!**
Your local Meteor checkout is now ready to use! You can use this `./meteor`
@@ -115,79 +123,98 @@ For the rest, try looking nearby for a `README.md`. For example, [`isobuild`](t
## Tests
### Test against the local meteor copy
When running tests that use `./meteor`, be sure to run them against the checked-out copy of Meteor instead of the globally-installed version. This ensures tests run against your local development version.
When running any tests, be sure to run them against the checked-out copy of Meteor instead of
the globally-installed version. This means ensuring that the command is `path-to-meteor-checkout/meteor` and not just `meteor`.
The repository has four test layers, each covering a different scope:
This is important so that tests are run against your local development version and not the stable (installed) Meteor release.
| Command | Layer | Scope |
|---------|-------|-------|
| `npm run test:unit` | **Unit** (Jest) | Pure logic in `tools/`, `scripts/`, and helpers: fast, no Meteor runtime needed |
| `npm run test:e2e` | **E2E** (Jest + Playwright) | Bundler integration and skeleton apps: creates real Meteor projects, launches a browser |
| `./meteor self-test` | **Self-test** (custom) | Meteor CLI tool itself, spawns sandboxed Meteor processes to verify commands end-to-end |
| `./meteor test-packages` | **Package** (TinyTest) | Atmosphere packages in `packages/`, runs inside a Meteor app with the full reactive runtime |
### Running tests on Meteor core
### Unit tests (Jest)
When you are working with code in the core Meteor packages, you will want to make sure you run the
full test-suite (including the tests you added) to ensure you haven't broken anything in Meteor. The
`test-packages` command will do just that for you:
Unit tests cover pure helpers, scripts, and tool logic that does not require the Meteor runtime. They use [Jest](https://jestjs.io/) configured in `tools/unit-tests/`, targeting `tools/**/*.test.js` and `scripts/**/*.test.js`.
./meteor test-packages
```sh
# Install dependencies (first time)
npm run install:unit
Exactly in the same way that [`test-packages` works in standalone Meteor apps](https://guide.meteor.com/writing-atmosphere-packages.html#testing), the `test-packages` command will start up a Meteor app with [TinyTest](./packages/tinytest/README.md). To view the results, just connect to `http://localhost:3000`.
# Run all unit tests
npm run test:unit
If you want to see results in the console you can use:
# Run a specific test file
npm run test:unit -- tools/path/to/file.test.js
PUPPETEER_DOWNLOAD_PATH=~/.npm/chromium ./packages/test-in-console/run.sh
# Run tests matching a name pattern
npm run test:unit -- -t "my test name"
```
> [PUPPETEER_DOWNLOAD_PATH](https://github.com/dfernandez79/puppeteer/blob/main/README.md#q-chromium-gets-downloaded-on-every-npm-ci-run-how-can-i-cache-the-download) is optional but this is useful to skip Downloading Chromium on every run
Place test files next to the module they test using the `*.test.js` naming convention. Jest will pick them up automatically.
> We run our tests on Travis like above.
### E2E tests (Jest + Playwright)
#### Running specific tests
End-to-end tests in `tools/e2e-tests/` validate that Meteor skeletons and bundler integrations work correctly. They create real Meteor apps, start dev servers, and assert behavior in a headless Chromium browser.
Specific package tests can be run by passing a `<package name>` or `<package path>` to the `test-packages` command. For example, to run `mongo` tests, it's possible to run:
```sh
# Install dependencies (first time)
npm run install:e2e
./meteor test-packages mongo
# Run all E2E tests
npm run test:e2e
For more fine-grained control, if you're interested in running only the specific tests that relate to the functionality you're working on, you can filter individual tests by using the `TINYTEST_FILTER` environment variable (which supports regex's). For example, to run only the package tests that verify `new Mongo.Collection` behavior, try:
# Run a specific suite
npm run test:e2e -- -t="React"
```
TINYTEST_FILTER="collection - call new Mongo.Collection" ./meteor test-packages
Each test has a corresponding app fixture in `tools/e2e-tests/apps/`. See that directory for examples when adding new E2E tests.
You can also provide the same filters for `./packages/test-in-console/run.sh` explained above.
### Self-tests (Meteor tool)
### Running Meteor Tool self-tests
The Meteor CLI has its own "self-test" framework that spawns sandboxed Meteor processes. It tests commands like `create`, `build`, `deploy`, and `publish`.
While TinyTest and the `test-packages` command can be used to test internal Meteor packages, they cannot be used to test the Meteor Tool itself. The Meteor Tool is a node app that uses a home-grown "self test" system.
```sh
# List all self-tests
./meteor self-test --list
#### Listing available tests
# Run all self-tests
./meteor self-test
To see a list of tests included in the self-test system, use the `--list` option:
# Run tests matching a regex
./meteor self-test "^[a-b]"
./meteor self-test --list
# Exclude tests matching a regex
./meteor self-test --exclude "^[a-b]"
#### Running specific tests
# Skip retries during development
./meteor self-test --retries 0
```
The self-test commands support a regular-expression syntax in order to specific/search for specific tests. For example, to search for tests starting with `a` or `b`, it's possible to run:
### Package tests (TinyTest)
./meteor self-test "^[a-b]" --list
When working with core Atmosphere packages, use `test-packages` to run their tests via [TinyTest](./packages/tinytest/README.md). This starts a Meteor app, view results at `http://localhost:3000`.
Simply remove the `--list` flag to actually run the matching tests.
```sh
# Test all packages
./meteor test-packages
#### Excluding specific tests
# Test a specific package
./meteor test-packages mongo
In a similar way to the method of specifying which tests TO run, there is a way to specify which tests should NOT run. Again, using regular-expressions, this command will NOT list any tests which start with `a` or `b`:
# Filter by test name (supports regex), using --filter or -f
./meteor test-packages --filter "collection - call new Mongo.Collection"
./meteor self-test --exclude "^[a-b]" --list
# Equivalent using the environment variable
TINYTEST_FILTER="collection - call new Mongo.Collection" ./meteor test-packages
```
Simply remove the `--list` flag to actually run the matching tests.
For headless console output:
#### Avoiding retries
On CI we want to retry the tests to avoid false failures but in development can take some time if you retry every time a test is failing. So to avoid retries use:
./meteor self-test --retries 0
#### More reading
For even more details on how to run Meteor Tool "self tests", please refer to the [Testing section of the Meteor Tool README](https://github.com/meteor/meteor/blob/master/tools/README.md#testing).
```sh
PUPPETEER_DOWNLOAD_PATH=~/.npm/chromium ./packages/test-in-console/run.sh
```
### Continuous integration

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

@@ -0,0 +1,300 @@
# E2E Test Coverage
> To update this report, follow the [e2e-coverage skill](/.github/skills/e2e-coverage/SKILL.md).
End-to-end tests using Jest + Playwright that verify Meteor apps with the Rspack bundler across frameworks, build modes, and features.
Test infrastructure lives in `tools/e2e-tests/`, with app fixtures in `tools/e2e-tests/apps/` and matching test files at `tools/e2e-tests/<name>.test.js`.
## Test Lifecycle
Every app and skeleton goes through these phases (unless skipped):
| Phase | What it does |
|-------|-------------|
| **Init** | Copies app, installs deps, adds rspack, generates config |
| **Run (dev)** | `meteor run` — asserts build artifacts, app loads, client/server hot rebuild |
| **Run (prod)** | `meteor run --production` — same checks in production mode |
| **Test** | `meteor test` — runs mocha test driver, verifies test rebuild |
| **Test once** | `meteor test --once` — runs tests to completion, checks exit code |
| **Build** | `meteor build` — verifies bundle structure (main.js, programs/server, web.browser, web.browser.legacy) |
| **Reset** | `meteor reset` — clears rspack build artifacts, caches, asset/chunk context dirs, and `.meteor/local` subdirectories |
Default assertions on every run phase: build artifacts exist, page title matches, body styles render, `__rspack__` script tag is present.
---
## Apps
Each app lives in `apps/<name>/` and has a matching `<name>.test.js`.
### react
Core React integration with custom Meteor local directory.
| What is covered | Phase |
|----------------|-------|
| Custom `METEOR_LOCAL_DIR` (`.meteor/local-custom`) | All (env prefix) |
| Custom build dir (`_build-local-custom`) created | Run |
| `.gitignore` updated with custom local dir | Run |
| 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 |
### react-router
Full-featured React Router app with custom packages, Less, and advanced rspack config.
| What is covered | Phase |
|----------------|-------|
| `METEOR_PACKAGE_DIRS` custom packages dir | All (env prefix) |
| `babel-plugin-react-compiler` integration | Init, Prod, Build |
| 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 |
| Custom HTML meta tags (`theme-color`) | Run, Prod |
| Default + custom package loading | Run |
| `resolve.extensions` loading (`.jsx`) | Run |
| `rspack.config.override.js` custom plugin loading | Run, Test, Build |
| React + TSX environment detection | Run, Prod, Test, Build |
| Full-app test mode (`--full-app`) | Test |
| Static assets in bundle (png, md) | Build |
| HMR works in dev, disabled in prod | Run, Prod |
### blaze
Blaze templating engine integration.
| What is covered | Phase |
|----------------|-------|
| Blaze environment detection (`isBlazeEnabled`) | Run, Prod, Test, Build |
| HMR disabled (incompatible with Blaze) | Run, Prod |
### full-blaze
Full Blaze app (with `imports/` structure for tests).
| What is covered | Phase |
|----------------|-------|
| Blaze environment detection | Run, Prod, Test, Build |
| `imports/api/` test path structure | Test |
| HMR disabled (incompatible with Blaze) | Run, Prod |
### typescript
TypeScript with SCSS, type checking, `.ts` rspack config, and `.ts` SWC config.
| What is covered | Phase |
|----------------|-------|
| TypeScript rspack config (`rspack.config.ts`) | All |
| TypeScript SWC config (`swc.config.ts`) with automatic JSX runtime | All |
| `@swc/core` type-only import for SWC config typings | All |
| Custom build dir (`build`) | All |
| Custom asset/chunk context dirs (`assets`, `chunks`) | All |
| SCSS styles support (`white-space: break-spaces`) | Run, Prod |
| TypeScript + TSX environment detection | Run, Prod, Test, Build |
| Portable build (Meteor.isDevelopment/isProduction not defined) | Run, Prod, Build |
| `Meteor.extendSwcConfig` with path aliases (`@ui/*`, `@api/*`) | All |
| `TsCheckerRspackPlugin` type checking (no errors) | Run |
| `.meteor/local/types` directory generated | Run |
| Separate client/server test files | Test |
| CI: removes TsCheckerRspackPlugin (resource limits) | Init |
| HMR works in dev, disabled in prod | Run, Prod |
### babel
Babel transpilation with custom module rules and `.mjs` rspack config.
| What is covered | Phase |
|----------------|-------|
| Custom rspack config (`rspack.config.mjs`) | All |
| Custom `NODE_ENV` compilation per phase | All (env prefix) |
| Rspack mode assertion (development/production) | Run, Prod, Test, Build |
| `Meteor.isDevelopment`/`Meteor.isProduction` defines | Run, Prod, Test, Build |
| Module rules for `.js`/`.jsx` files | Run, Prod, Test, Build |
| Module rules for `.tsx`/`.ts`/`.mts`/`.cts`/`.mjs`/`.cjs` | Run, Prod, Test, Build |
| Module rules for `.graphql`/`.gql` files | Run, Prod, Test, Build |
| Default rules negated (custom rules override) | Run, Prod, Test, Build |
| HMR works in dev, disabled in prod | Run, Prod |
### coffeescript
CoffeeScript language support.
| What is covered | Phase |
|----------------|-------|
| `.coffee` file compilation (client + server + test) | All |
| CoffeeScript-specific conditional syntax | Run, Prod |
| HMR works in dev, disabled in prod | Run, Prod |
### vue
Vue.js framework with Tailwind CSS.
| What is covered | Phase |
|----------------|-------|
| Vue single-file components | All |
| Tailwind CSS styles (`.p-8` padding) | Run, Prod |
| HMR works in dev, disabled in prod | Run, Prod |
### solid
SolidJS framework integration.
| What is covered | Phase |
|----------------|-------|
| SolidJS compilation and rendering | All |
| HMR works in dev, disabled in prod | Run, Prod |
### svelte
Svelte framework integration.
| What is covered | Phase |
|----------------|-------|
| Svelte compilation and rendering | All |
| HMR works in dev, disabled in prod | Run, Prod |
### monorepo
Monorepo structure with app in subdirectory.
| What is covered | Phase |
|----------------|-------|
| Monorepo layout (`app/` subdirectory) | All |
| Custom rspack config (`rspack.config.cjs`) | All |
| `rspack.config.override.cjs` custom plugin loading | Run, Test, Build |
| Static assets in bundle (png, md) | Build |
| HMR works in dev, disabled in prod | Run, Prod |
### server-only
Server-only app (no client entry point).
| What is covered | Phase |
|----------------|-------|
| No client bundle (client skipped) | All |
| No client tests (test client skipped) | Test |
| Server entry loads (`server/main.js loaded`) | Run |
---
## Skeletons
Tested via `skeleton.test.js` using `meteor create --<skeleton>`. Each skeleton verifies: app creation, dev run, production run, test once, build, and reset.
| Skeleton | Port | Language | Extra coverage |
|----------|------|----------|----------------|
| angular | 3213 | TypeScript | |
| apollo | 3201 | JSX | |
| babel | 3212 | JSX | |
| bare | 3219 | JS | No title/style checks, no client tests, skip build cache check |
| blaze | 3202 | JS | |
| chakra-ui | 3203 | JSX | No body style checks (custom UI library) |
| coffeescript | 3211 | CoffeeScript | |
| full | 3204 | JS | `imports/api/` test structure |
| react | 3205 | JSX | Custom body styles (Inter font, padding) |
| solid | 3206 | JS | |
| svelte | 3207 | JS | |
| tailwind | 3208 | TypeScript | Tailwind `bg-gray-100` styles (dev + prod color formats) |
| typescript | 3209 | TypeScript | CI: removes TsCheckerRspackPlugin |
| vue | 3210 | JS | |
---
## NPM Package Compatibility
Several apps import specific npm packages to verify that Meteor + Rspack handles different module formats and edge cases without errors. The app boots successfully only if these imports resolve correctly.
### react-router (`apps/react-router/server/main.js`)
| Package | Reason |
|---------|--------|
| `s3mini` | ESM-only package (no CJS fallback) |
| `@modelcontextprotocol/sdk/client/streamableHttp.js` | ESM subpath export (deep path into ESM package) |
| `bcrypt` | Native Node.js bindings (compiled C++ addon) |
| `puppeteer` | Large ESM-compatible package with complex dependency tree (`server/browser-tests/browser.app-test.js`) |
### monorepo (`apps/monorepo/app/`)
| Package | File | Reason |
|---------|------|--------|
| `pino` + `pino-pretty` | `server/main.js` | ESM-first logger; `pino-pretty` uses `thread-stream` which has worker file resolution issues — needs `Meteor.compileWithMeteor(["thread-stream"])` in rspack config |
| `grubba-rpc` | `server/main.js` | Untranspiled npm dependency — needs `Meteor.compileWithRspack(["grubba-rpc"])` to compile it through rspack |
| `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 |
|---------|--------|
| `@apollo/server` | ESM-first GraphQL server |
| `@apollo/server/express4` | ESM subpath export (middleware from deep path) |
| `graphql` | Peer dependency, dual CJS/ESM package |
### typescript (`apps/typescript/rspack.config.ts`, `apps/typescript/swc.config.ts`)
| Package | Reason |
|---------|--------|
| `node:module` (`createRequire`) | Node.js built-in in a `.ts` config file — tests CJS interop via `createRequire(import.meta.url)` in an ESM context |
| `@swc/core` | Type-only import (`import type { Config }`) — provides typings for `swc.config.ts`, stripped at compile time |
---
## Feature Coverage Matrix
Where each feature is tested across apps and skeletons.
| Feature | Apps | Skeletons |
|---------|------|-----------|
| HMR (dev) | react, react-router, babel, coffeescript, vue, solid, svelte, monorepo, typescript | |
| HMR disabled (prod) | all apps with HMR | |
| HMR incompatible | blaze, full-blaze | |
| Custom rspack config | react (.cjs), react-router, babel (.mjs), monorepo (.cjs), typescript (.ts) | |
| Custom SWC config (.ts) | typescript | |
| Config override file | react-router, monorepo | |
| Custom build dir | react, typescript | |
| Custom asset/chunk context dirs | typescript | |
| Custom env vars | react (METEOR_LOCAL_DIR), react-router (METEOR_PACKAGE_DIRS) | |
| Static asset bundling | react-router, monorepo | |
| Less styles | react-router | |
| SCSS styles | typescript | |
| Tailwind CSS | vue | 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 | |
| Monorepo layout | monorepo | |
| Full-app test mode | react-router | |
| Module rules override | babel | |
| Custom NODE_ENV compilation | babel | |
| Portable build (no isDev/isProd defines) | typescript | |
| `Meteor.extendSwcConfig` (path aliases) | typescript | |
| `meteor reset` cleanup | all apps | all skeletons |
| Skeleton creation | | all 14 skeletons |
| Body style assertions | | react, tailwind (custom); most others (default) |
| Custom .gitignore entries | react | |
| ESM-only packages | react-router, monorepo, babel | |
| ESM subpath exports | react-router, babel | |
| Native bindings (C++ addon) | react-router | |
| `node:` protocol imports | monorepo, typescript | |
| Untranspiled npm deps (`compileWithRspack`) | monorepo | |
| Worker resolution (`compileWithMeteor`) | monorepo | |

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>

View File

@@ -36,6 +36,9 @@ same as `meteor run`.
To pass additional options to Node.js use the `SERVER_NODE_OPTIONS` environment variable. E.g. for Windows PowerShell:
`$env:SERVER_NODE_OPTIONS = '--inspect' | meteor run`. Or for Linux: `SERVER_NODE_OPTIONS=--inspect-brk meteor run`.
Quoted values are supported, so you can pass options that contain spaces or special characters:
`SERVER_NODE_OPTIONS='--test-name-pattern="my test"' meteor run`.
To specify a port to listen on (instead of the default 3000), use `--port [PORT]`.
(The development server also uses port `N+1` for the default MongoDB instance)

View File

@@ -4,7 +4,7 @@ description: How to use Meteor's build system to compile your app.
discourseTopicId: 19669
---
The Meteor build system is the actual command line tool that you get when you install Meteor. You run it by typing the `meteor` command in your terminal, possibly followed by a set of arguments. Read the [docs about the command line tool](https://docs.meteor.com/commandline.html) or type `meteor help` in your terminal to learn about all of the commands.
The Meteor build system is the actual command line tool that you get when you install Meteor. You run it by typing the `meteor` command in your terminal, possibly followed by a set of arguments. Read the [docs about the command line tool](https://docs.meteor.com/cli/) or type `meteor help` in your terminal to learn about all of the commands.
<h2 id="what-it-does">What does it do?</h2>
@@ -16,7 +16,7 @@ After executing the `meteor` command to start the build tool you should leave it
<h3 id="compiles-with-build-plugins">Compiles files with build plugins</h3>
The main function of the Meteor build tool is to run "build plugins". These plugins define different parts of your app build process. Meteor puts heavy emphasis on reducing or removing build configuration files, so you won't see any large build process config files like you would in Gulp or Webpack. The Meteor build process is configured almost entirely through adding and removing packages to your app and putting files in specially named directories. For example, to get all of the newest stable ES2015 JavaScript features in your app, you add the [`ecmascript` package](http://docs.meteor.com/#/full/ecmascript). This package provides support for ES2015 modules, which gives you even more fine grained control over file load order using ES2015 `import` and `export`. As new Meteor releases add new features to this package you get them for free.
The main function of the Meteor build tool is to run "build plugins". These plugins define different parts of your app build process. Meteor puts heavy emphasis on reducing or removing build configuration files, so you won't see any large build process config files like you would in Gulp or Webpack. The Meteor build process is configured almost entirely through adding and removing packages to your app and putting files in specially named directories. For example, to get all of the newest stable ES2015 JavaScript features in your app, you add the [`ecmascript` package](https://docs.meteor.com/packages/ecmascript.html). This package provides support for ES2015 modules, which gives you even more fine grained control over file load order using ES2015 `import` and `export`. As new Meteor releases add new features to this package you get them for free.
<h4 id="controlling-build-files">Controlling which files to build</h4>
@@ -224,7 +224,7 @@ For more examples and details on importing styles and using `@imports` with pack
<h3 id="sass">Sass</h3>
The best Sass build plugin for Meteor is [`fourseven:scss`](https://atmospherejs.com/fourseven/scss).
The best Sass build plugin for Meteor is [`leonardoventurini:scss`](https://atmospherejs.com/leonardoventurini/scss). An alternative to the previous recommended [`fourseven:scss`](https://atmospherejs.com/fourseven/scss) package.
<h3 id="less">Less</h3>

View File

@@ -695,3 +695,9 @@ From this point on, the process for submitting the app to the Play Store is the
Because Crosswalk bundles native code for Chromium, you will end up with APKs for both ARM and x86. You can find the generated APKs in the `<build-output-directory>/android/project/build/outputs/apk` directory.
You will have to sign and `zipalign` both APKs. You will also have to submit both to the Play Store, see [submitting multiple APKs](http://developer.android.com/google/play/publishing/multiple-apks.html) for more information.
<h2>Other tips</h2>
The back gesture is disabled by default on iOS, but it can be enabled at runtime like this:
```window.WkWebView.allowsBackForwardNavigationGestures(true);```

View File

@@ -196,6 +196,7 @@ MONGO_URL=mongodb://localhost:27017/myapp ROOT_URL=http://my-app.com PORT=3000 n
* `ROOT_URL` is the base URL for your Meteor project
* `PORT` is the port at which the application is running
* `MONGO_URL` is a [Mongo connection string URI](https://docs.mongodb.com/manual/reference/connection-string/) supplied by the MongoDB provider.
* `METEOR_SETTINGS` is a JSON object containing your application settings (can also be set via --settings flag). **Warning:** Any settings under the `public` key will be sent to the client - never put secrets there.
Unless you have a specific need to roll your own hosting environment, the other options here are definitely easier, and probably make for a better setup than doing everything from scratch. Operating a Meteor app in a way that it works correctly for everyone can be complex, and [Galaxy](#galaxy) handles a lot of the specifics like routing clients to the right containers and handling coordinated version updates for you.

2
meteor
View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
BUNDLE_VERSION=22.18.0.3
BUNDLE_VERSION=22.22.1.0
# 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": {

View File

@@ -1,12 +1,12 @@
{
"name": "meteor-node-stubs",
"version": "1.2.23",
"version": "1.2.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meteor-node-stubs",
"version": "1.2.23",
"version": "1.2.26",
"bundleDependencies": [
"@meteorjs/crypto-browserify",
"assert",
@@ -171,9 +171,9 @@
}
},
"node_modules/@meteorjs/create-ecdh/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"inBundle": true,
"license": "MIT"
},
@@ -254,9 +254,9 @@
}
},
"node_modules/asn1.js/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"inBundle": true,
"license": "MIT"
},
@@ -319,9 +319,9 @@
"license": "MIT"
},
"node_modules/bn.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz",
"integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==",
"inBundle": true,
"license": "MIT"
},
@@ -654,9 +654,9 @@
}
},
"node_modules/diffie-hellman/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"inBundle": true,
"license": "MIT"
},
@@ -1184,9 +1184,9 @@
}
},
"node_modules/miller-rabin/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"inBundle": true,
"license": "MIT"
},
@@ -1459,9 +1459,9 @@
}
},
"node_modules/public-encrypt/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"inBundle": true,
"license": "MIT"
},
@@ -1473,9 +1473,9 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"inBundle": true,
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -2,7 +2,7 @@
"name": "meteor-node-stubs",
"author": "Ben Newman <ben@meteor.com>",
"description": "Stub implementations of Node built-in modules, a la Browserify",
"version": "1.2.24",
"version": "1.2.26",
"main": "index.js",
"license": "MIT",
"homepage": "https://github.com/meteor/meteor/blob/devel/npm-packages/meteor-node-stubs/README.md",

View File

@@ -0,0 +1,141 @@
# @meteorjs/rspack
The default [Rspack](https://rspack.dev) configuration for Meteor applications. This package provides everything you need to bundle your Meteor app with Rspack out of the box: client and server builds, SWC transpilation, React/Blaze/Angular support, hot module replacement, asset management, and all the Meteor-specific wiring so you don't have to.
When Meteor runs with the Rspack bundler enabled, this package is what generates the underlying Rspack configuration. It detects your project setup (TypeScript, React, Blaze, Angular), sets up the right loaders and plugins, defines `Meteor.isClient`/`Meteor.isServer` and friends, configures caching, and exposes a set of helpers you can use in your own `rspack.config.js` to customize the build without breaking Meteor integration.
## What it provides
- **Dual client/server builds** with the correct targets, externals, and output paths
- **SWC-based transpilation** for JS/TS/JSX/TSX with automatic framework detection
- **React Fast Refresh** in development when React is enabled
- **Blaze template handling** via ignore-loader when Blaze is enabled
- **Persistent filesystem caching** for fast rebuilds
- **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
## Installation
[Rspack integration](https://docs.meteor.com/about/modern-build-stack/rspack-bundler-integration.html) is automatically managed by the rspack Atmosphere package.
```bash
meteor add rspack
```
By doing this, your Meteor app will automatically serve `@meteorjs/rspack` and the required `@rspack/cli`, `@rspack/core`, among others.
## Usage
In your project's `rspack.config.js`, use the `defineConfig` helper to customize the build. The factory function receives a `env` object with Meteor environment flags and helper utilities:
```js
const { defineConfig } = require('@meteorjs/rspack');
module.exports = defineConfig((env, argv) => {
// env.isClient, env.isServer, env.isDevelopment, env.isProduction
// env.isReactEnabled, env.isBlazeEnabled, etc.
return {
// Your custom Rspack configuration here.
// It gets safely merged with the Meteor defaults.
};
});
```
More information is available in the official docs: [Rspack Bundler Integration](https://docs.meteor.com/about/modern-build-stack/rspack-bundler-integration.html#custom-rspack-config-js).
## Development
### Install dependencies
```bash
npm install
```
### Version bumping
Use `npm run bump` to update the version in `package.json` before publishing.
```bash
npm run bump -- <major|minor|patch> [--beta]
```
**Standard bumps** increment the version and remove any prerelease suffix:
```bash
npm run bump -- patch # 1.0.1 -> 1.0.2
npm run bump -- minor # 1.0.1 -> 1.1.0
npm run bump -- major # 1.0.1 -> 2.0.0
```
**Beta bumps** append or increment a `-beta.N` prerelease suffix:
```bash
npm run bump -- patch --beta # 1.0.1 -> 1.0.2-beta.0
npm run bump -- patch --beta # 1.0.2-beta.0 -> 1.0.2-beta.1
npm run bump -- patch --beta # 1.0.2-beta.1 -> 1.0.2-beta.2
```
If you change the bump level while on a beta, the base version updates and the beta counter resets:
```bash
npm run bump -- minor --beta # 1.0.2-beta.2 -> 1.1.0-beta.0
npm run bump -- major --beta # 1.1.0-beta.0 -> 2.0.0-beta.0
```
### Publishing a beta release
After bumping to a beta version, publish to the `beta` dist-tag:
```bash
npm run bump -- patch --beta
npm run publish:beta
```
Users can then install the beta with:
```bash
npm install @meteorjs/rspack@beta
```
You can pass extra flags to `npm publish` through the script:
```bash
npm run publish:beta -- --dry-run
```
### Publishing an official release
After bumping to a stable version, publish with the default `latest` tag:
```bash
npm run bump -- patch
npm publish
```
### Typical workflows
**Beta iteration**: ship multiple beta builds for the same upcoming patch:
```bash
npm run bump -- patch --beta # 1.0.1 -> 1.0.2-beta.0
npm run publish:beta
# ... fix issues ...
npm run bump -- patch --beta # 1.0.2-beta.0 -> 1.0.2-beta.1
npm run publish:beta
```
**Promote beta to stable**: once the beta is ready, bump to the stable version and publish:
```bash
npm run bump -- patch # 1.0.2-beta.1 -> 1.0.3
npm publish
```
**Direct stable release**: skip the beta phase entirely:
```bash
npm run bump -- minor # 1.0.1 -> 1.1.0
npm publish
```

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

@@ -0,0 +1,116 @@
/**
* 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 the SWC loader config by smart-merging custom options on top of
* Meteor's defaults. Only the properties you specify are overridden;
* everything else is preserved.
* @param swcConfig - SWC loader options to merge with defaults
* @returns A config object with SWC loader config
*/
extendSwcConfig: (swcConfig: SwcLoaderOptions) => Record<string, object>;
/**
* Replace the SWC loader config entirely, discarding Meteor's defaults.
* Use this when you need full control over SWC options and don't want any
* automatic merging with Meteor's built-in configuration.
* @param swcConfig - Complete SWC loader options (replaces defaults)
* @returns A config object with SWC loader config
*/
replaceSwcConfig: (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>;
/**
* Omit `Meteor.isDevelopment` and `Meteor.isProduction` from the DefinePlugin so
* the bundle is not tied to a specific Meteor environment (portable / isomorphic builds).
* @returns A config fragment with `meteor.enablePortableBuild: true`
*/
enablePortableBuild: () => 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,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

@@ -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,121 @@
const path = require('path');
const fs = require('fs');
const { cleanOmittedPaths } = require("./mergeRulesSplitOverlap.js");
const { mergeMeteorRspackFragments } = require("./meteorRspackConfigFactory.js");
// Helper function to load and process config files
async function loadAndProcessConfig(configPath, configType, Meteor, argv, disableWarnings) {
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 (disableWarnings) 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;
}
}
/**
* Loads both the user's Rspack configuration and its potential override.
*
* @param {string|undefined} projectConfigPath
* @param {object} Meteor
* @param {object} argv
* @returns {Promise<{ nextUserConfig: object|null, nextOverrideConfig: object|null }>}
*/
async function loadUserAndOverrideConfig(projectConfigPath, Meteor, argv) {
let nextUserConfig = null;
let nextOverrideConfig = null;
const projectDir = process.cwd();
const isMeteorPackageConfig = projectDir.includes("/packages/rspack");
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)) {
nextOverrideConfig = await loadAndProcessConfig(
overrideConfigPath,
configNameFull,
Meteor,
argv,
Meteor.isAngularEnabled
);
}
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;
}
nextUserConfig = await loadAndProcessConfig(
projectConfigPathToUse,
'rspack.config.js',
Meteor,
argv,
Meteor.isAngularEnabled
);
}
}
return { nextUserConfig, nextOverrideConfig };
}
module.exports = {
loadAndProcessConfig,
loadUserAndOverrideConfig,
};

View File

@@ -0,0 +1,264 @@
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 by smart-merging custom options on top of Meteor's
* defaults (via `mergeSplitOverlap`). Only the properties you specify are
* overridden; everything else is preserved.
*
* Usage: Meteor.extendSwcConfig({ jsc: { parser: { decorators: true } } })
*
* @param {object} swcConfig - SWC loader options to merge with defaults
* @returns {Record<string, object>} config fragment for spreading into rspack config
*/
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,
},
],
},
});
}
/**
* Replace the SWC loader config entirely, discarding Meteor's defaults.
* Use this when you need full control over SWC options and don't want any
* automatic merging with Meteor's built-in configuration.
*
* Usage: Meteor.replaceSwcConfig({ jsc: { parser: { syntax: 'typescript' }, target: 'es2020' } })
*
* @param {object} swcConfig - Complete SWC loader options (replaces defaults)
* @returns {Record<string, object>} config fragment for spreading into rspack config
*/
function replaceSwcConfig(swcConfig) {
return prepareMeteorRspackConfig({
module: {
rules: [
{
test: /\.(?:[mc]?js|jsx|[mc]?ts|tsx)$/i,
exclude: /node_modules|\.meteor\/local/,
loader: 'builtin:swc-loader',
options: swcConfig,
},
],
},
});
}
/**
* Signal that `Meteor.isDevelopment` and `Meteor.isProduction` should be omitted
* from DefinePlugin, making the bundle portable across Meteor environments.
* Usage: return Meteor.enablePortableBuild() in your rspack.config.js
*
* @returns {Record<string, object>} config fragment with `meteor.enablePortableBuild: true`
*/
function enablePortableBuild() {
return prepareMeteorRspackConfig({
"meteor.enablePortableBuild": true,
});
}
/**
* 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;
}
function outputMeteorRspack(data) {
const jsonString = JSON.stringify(data);
const output = `[Meteor-Rspack]${jsonString}[/Meteor-Rspack]`;
console.log(output);
}
module.exports = {
compileWithMeteor,
compileWithRspack,
setCache,
splitVendorChunk,
extendSwcConfig,
replaceSwcConfig,
makeWebNodeBuiltinsAlias,
disablePlugins,
outputMeteorRspack,
enablePortableBuild,
};

View File

@@ -0,0 +1,97 @@
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') || file.endsWith('.ts')) {
let content = fs.readFileSync(filePath, 'utf-8');
if (file.endsWith('.ts')) {
try {
const swc = require('@swc/core');
const result = swc.transformSync(content, {
jsc: {
parser: {
syntax: 'typescript',
},
target: 'es2015',
},
});
content = result.code;
} catch (swcError) {
content = content
.replace(/import\s+type\s+.*?from\s+['"][^'"]+['"];?/g, '')
.replace(/import\s+.*?from\s+['"][^'"]+['"];?/g, '')
.replace(/import\s+['"][^'"]+['"];?/g, '')
.replace(/export\s+default\s+/, 'module.exports = ')
.replace(/export\s+/g, '')
.replace(/:\s*\w+(\[\])?(\s*=)/g, '$2')
.replace(/\(([^)]*?):\s*\w+(\[\])?\)/g, '($1)')
.replace(/\):\s*\w+(\[\])?\s*\{/g, ') {')
.replace(/interface\s+\w+\s*\{[^}]*\}/g, '')
.replace(/type\s+\w+\s*=\s*[^;]+;/g, '')
.replace(/as\s+\w+(\[\])?/g, '');
}
}
if (content.includes('export default')) {
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 });
const result = script.runInContext(context);
// Handle CJS interop wrapper (e.g. { __esModule: true, default: config })
return result && result.__esModule && result.default ? result.default : result;
} 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`);
const hasSwcTs = !hasSwcRc && !hasSwcJs && fs.existsSync(`${process.cwd()}/swc.config.ts`);
if (!hasSwcRc && !hasSwcJs && !hasSwcTs) {
return undefined;
}
const swcFile = hasSwcTs ? 'swc.config.ts' : 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,106 @@
const fs = require('fs');
const path = require('path');
const { createIgnoreRegex, createIgnoreGlobConfig } = require("./ignore.js");
// Normalize a path to always use forward slashes (POSIX style).
// Module identifiers in bundled JS must use '/' regardless of OS.
const toPosix = (p) => p.replace(/\\/g, '/');
/**
* 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.meteorIgnoreEntries - Array of meteor 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 = [],
meteorIgnoreEntries: inMeteorIgnoreEntries = [],
prefix: inPrefix = '',
extraEntry,
globalImportPath,
}) => {
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)
);
console.log("inMeteorIgnoreEntries", inMeteorIgnoreEntries);
// Create regex from meteor ignore entries
const excludeMeteorIgnoreRegex = inMeteorIgnoreEntries.length > 0
? createIgnoreRegex(createIgnoreGlobConfig(inMeteorIgnoreEntries))
: null;
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 = `${
globalImportPath ? `import '${toPosix(globalImportPath)}';\n\n` : ""
}${
excludeMeteorIgnoreRegex
? `const MeteorIgnoreRegex = ${excludeMeteorIgnoreRegex.toString()};`
: ""
}
{
const ctx = import.meta.webpackContext('${toPosix(projectDir)}', {
recursive: true,
regExp: ${regExp},
exclude: ${excludeFoldersRegex.toString()},
mode: 'eager',
});
ctx.keys().filter((k) => {
${
excludeMeteorIgnoreRegex
? `// Only exclude based on *relative* path segments.
return !MeteorIgnoreRegex.test(k);`
: "return true;"
}
}).forEach(ctx);
${
extraEntry
? `const extra = import.meta.webpackContext('${toPosix(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,20 @@
{
"name": "@meteorjs/rspack",
"version": "1.1.0-beta.31",
"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",
"@swc/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,36 @@
// MeteorRspackOutputPlugin.js
//
// This plugin outputs a JSON stringified with a tag delimiter each time
// a new Rspack compilation happens. The JSON content is configurable
// via plugin instantiation options.
const { outputMeteorRspack } = require('../lib/meteorRspackHelpers');
class MeteorRspackOutputPlugin {
constructor(options = {}) {
this.pluginName = 'MeteorRspackOutputPlugin';
this.options = options;
this.compilationCount = 0;
// The data to be output as JSON, can be a static object or a function
this.getData =
typeof options.getData === 'function'
? options.getData
: () => options.data || {};
}
apply(compiler) {
// Hook into the 'done' event which fires after each compilation completes
compiler.hooks.done.tap(this.pluginName, stats => {
this.compilationCount++;
const data = {
...(this.getData(stats, {
compilationCount: this.compilationCount,
isRebuild: this.compilationCount > 1,
}) || {}),
};
outputMeteorRspack(data);
});
}
}
module.exports = { MeteorRspackOutputPlugin };

View File

@@ -0,0 +1,502 @@
// 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');
// Normalize a path to always use forward slashes (POSIX style).
// Module identifiers in bundled JS must use '/' regardless of OS.
const toPosix = (p) => p.replace(/\\/g, '/');
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('.\\') ||
pkg.startsWith('../') ||
pkg.startsWith('..\\') ||
!!depInfo.ext)
) {
const module = this.externalsMeta.get(pkg);
if (module) {
return `${this.backRoot}${toPosix(module.relativeRequest)}`;
}
return `${this.backRoot}${toPosix(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: toPosix(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,868 @@
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 { MeteorRspackOutputPlugin } = require('./plugins/MeteorRspackOutputPlugin.js');
const { generateEagerTestFile } = require("./lib/test.js");
const { getMeteorIgnoreEntries, createIgnoreGlobConfig } = require("./lib/ignore");
const {
compileWithMeteor,
compileWithRspack,
setCache,
splitVendorChunk,
extendSwcConfig,
replaceSwcConfig,
makeWebNodeBuiltinsAlias,
disablePlugins,
outputMeteorRspack,
enablePortableBuild,
} = 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) {
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, buildContext } = {},
) {
// 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 swcTsPath = path.join(process.cwd(), 'swc.config.ts');
const hasSwcTsConfig = fs.existsSync(swcTsPath);
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);
// 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] : []),
...(hasSwcrcConfig ? [swcrcPath] : []),
...(hasSwcJsConfig ? [swcJsPath] : []),
...(hasSwcTsConfig ? [swcTsPath] : []),
...(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/${
[buildContext, side].filter(Boolean).join('-') || 'default'
}`,
},
...(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 isTestLike = !!Meteor.isTestLike;
const swcExternalHelpers = !!Meteor.swcExternalHelpers;
const isNative = !!Meteor.isNative;
const devServerPort = Meteor.devServerPort || 8080;
const projectDir = process.cwd();
const projectConfigPath =
Meteor.projectConfigPath || path.resolve(projectDir, "rspack.config.js");
// Determine context for bundles and assets
const meteorLocalDirName = process.env.METEOR_LOCAL_DIR
? path.basename(process.env.METEOR_LOCAL_DIR.replace(/\\/g, "/"))
: "";
const buildContext =
Meteor.buildContext ||
process.env.RSPACK_BUILD_CONTEXT ||
`_build${(meteorLocalDirName && `-${meteorLocalDirName}`) || ""}`;
const assetsContext =
Meteor.assetsContext ||
process.env.RSPACK_ASSETS_CONTEXT ||
`build-assets${(meteorLocalDirName && `-${meteorLocalDirName}`) || ""}`;
const chunksContext =
Meteor.chunksContext ||
process.env.RSPACK_CHUNKS_CONTEXT ||
`build-chunks${(meteorLocalDirName && `-${meteorLocalDirName}`) || ""}`;
// Compute build paths before loading user config (needed by Meteor helpers below)
const outputPath = Meteor.outputPath;
const outputDir = path.dirname(Meteor.outputPath || "");
Meteor.buildOutputDir = path.resolve(projectDir, buildContext, outputDir);
// Meteor flags derived purely from input; independent of loaded user/override configs
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 isProfile = !!Meteor.isProfile;
const isVerbose = !!Meteor.isVerbose;
const configPath = Meteor.configPath;
const testEntry = Meteor.testEntry;
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;
const enableSwcExternalHelpers = !isServer && swcExternalHelpers;
// Defined here so it can be called both before and after the first config load;
// without loaded configs it falls through to argv/Meteor flags.
const getModeFromConfig = (userConfig, overrideConfig) => {
if (overrideConfig?.mode) return overrideConfig.mode;
if (userConfig?.mode) return userConfig.mode;
if (argv.mode) return argv.mode;
if (Meteor.isProduction) return "production";
if (Meteor.isDevelopment) return "development";
return null;
};
// Initial mode before user/override configs are loaded
const initialCurrentMode = getModeFromConfig();
const initialIsProd = initialCurrentMode
? initialCurrentMode === "production"
: !!Meteor.isProduction;
const initialIsDev = initialCurrentMode
? initialCurrentMode === "development"
: !!Meteor.isDevelopment || !initialIsProd;
const initialMode = initialIsProd ? "production" : "development";
// Initialized with pre-load values so helpers work during the first config load;
// reassigned after load once mode is fully resolved.
let cacheStrategy = createCacheStrategy(
initialMode,
(Meteor.isClient && "client") || "server",
{ projectConfigPath, configPath, buildContext }
);
let swcConfigRule = createSwcConfig({
isTypescriptEnabled,
isReactEnabled,
isJsxEnabled,
isTsxEnabled,
externalHelpers: enableSwcExternalHelpers,
isDevEnvironment: isRun && initialIsDev && !isTest && !isNative,
isClient,
isAngularEnabled,
});
Meteor.swcConfigOptions = swcConfigRule.options;
// 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(
mergeSplitOverlap(Meteor.swcConfigOptions, customSwcConfig)
);
Meteor.replaceSwcConfig = (customSwcConfig) =>
replaceSwcConfig(customSwcConfig);
Meteor.extendConfig = (...configs) => mergeSplitOverlap(...configs);
Meteor.disablePlugins = (matchers) =>
prepareMeteorRspackConfig({
disablePlugins: matchers,
});
Meteor.enablePortableBuild = () => enablePortableBuild();
// 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,
});
};
// First pass: resolve user/override configs early so mode overrides (e.g. "production")
// are available before computing isProd/isDev and the rest of the build flags.
// Skipped for Angular since it manages its own mode via the second pass.
let { nextUserConfig, nextOverrideConfig } = isAngularEnabled
? {}
: await loadUserAndOverrideConfig(projectConfigPath, Meteor, argv);
// Determine the final mode with loaded configs
const currentMode = getModeFromConfig(nextUserConfig, nextOverrideConfig);
const isProd = currentMode
? currentMode === "production"
: !!Meteor.isProduction;
const isDev = currentMode
? currentMode === "development"
: !!Meteor.isDevelopment || !isProd;
const mode = isProd ? "production" : "development";
const isPortableBuild = !!(
nextUserConfig?.["meteor.enablePortableBuild"] ||
nextOverrideConfig?.["meteor.enablePortableBuild"]
);
// Determine entry points
const entryPath = Meteor.entryPath || "";
// Determine output points
const outputFilename = Meteor.outputFilename;
cacheStrategy = createCacheStrategy(
mode,
(Meteor.isClient && "client") || "server",
{ projectConfigPath, configPath }
);
// 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");
// 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 isDevEnvironment = isRun && isDev && !isTest && !isNative;
swcConfigRule = createSwcConfig({
isTypescriptEnabled,
isReactEnabled,
isJsxEnabled,
isTsxEnabled,
externalHelpers: enableSwcExternalHelpers,
isDevEnvironment,
isClient,
isAngularEnabled,
});
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 shouldLogVerbose = isProfile || isVerbose;
const loggingConfig = shouldLogVerbose
? {}
: { stats: "errors-warnings", infrastructureLogging: { level: "warn" } };
const clientEntry =
isClient && isTest && isTestEager && isTestFullApp
? generateEagerTestFile({
isAppTest: true,
projectDir,
buildContext,
ignoreEntries: ["**/server/**"],
meteorIgnoreEntries,
prefix: "client",
extraEntry: path.resolve(process.cwd(), Meteor.mainClientEntry),
globalImportPath: path.resolve(projectDir, buildContext, entryPath),
})
: isClient && isTest && isTestEager
? generateEagerTestFile({
isAppTest: false,
isClient: true,
projectDir,
buildContext,
ignoreEntries: ["**/server/**"],
meteorIgnoreEntries,
prefix: "client",
globalImportPath: path.resolve(projectDir, buildContext, entryPath),
})
: isClient && isTest && testEntry
? path.resolve(process.cwd(), testEntry)
: 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),
...(!isPortableBuild && {
"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: devServerPort,
devMiddleware: {
writeToDisk: (filePath) =>
/\.(html)$/.test(filePath) || filePath.endsWith('sw.js'),
},
onListening(devServer) {
if (!devServer) return;
const { host, port } = devServer.options;
const protocol =
devServer.options.server?.type === "https" ? "https" : "http";
const devServerUrl = `${protocol}://${host || "localhost"}:${port}`;
outputMeteorRspack({ devServerUrl });
},
},
}),
...merge(cacheStrategy, { experiments: { css: true } }),
...lazyCompilationConfig,
...loggingConfig,
};
const serverEntry =
isServer && isTest && isTestEager && isTestFullApp
? generateEagerTestFile({
isAppTest: true,
projectDir,
buildContext,
ignoreEntries: ["**/client/**"],
meteorIgnoreEntries,
prefix: "server",
globalImportPath: path.resolve(projectDir, buildContext, entryPath),
})
: isServer && isTest && isTestEager
? generateEagerTestFile({
isAppTest: false,
projectDir,
buildContext,
ignoreEntries: ["**/client/**"],
meteorIgnoreEntries,
prefix: "server",
globalImportPath: path.resolve(projectDir, buildContext, entryPath),
})
: isServer && isTest && testEntry
? path.resolve(process.cwd(), testEntry)
: 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),
...(!isPortableBuild && {
"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),
...(!isPortableBuild && {
"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,
...loggingConfig,
};
// Establish Angular overrides to ensure proper integration
const angularExpandConfig = isAngularEnabled
? {
mode: isProd ? "production" : "development",
devServer: { port: 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()],
}
: {};
// Second pass: re-run only when a mode override was detected, so the user config
// can depend on fully-computed Meteor flags and helpers (swcConfigOptions, buildOutputDir, etc.).
if (nextUserConfig?.mode || nextOverrideConfig?.mode || isAngularEnabled) {
({ nextUserConfig, nextOverrideConfig } = await loadUserAndOverrideConfig(
projectConfigPath,
Meteor,
argv
));
}
let statsOverrided = false;
let config = isClient ? clientConfig : serverConfig;
if (nextUserConfig) {
config = mergeSplitOverlap(config, nextUserConfig);
if (nextUserConfig.stats != null) {
statsOverrided = true;
}
}
config = mergeSplitOverlap(config, angularExpandConfig);
config = mergeSplitOverlap(config, testClientExpandConfig);
if (nextOverrideConfig) {
config = mergeSplitOverlap(config, nextOverrideConfig);
if (nextOverrideConfig.stats != null) {
statsOverrided = true;
}
}
const shouldDisablePlugins = config?.disablePlugins != null;
if (shouldDisablePlugins) {
config = disablePlugins(config, config.disablePlugins);
delete config.disablePlugins;
}
delete config["meteor.enablePortableBuild"];
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"
);
}
// Add MeteorRspackOutputPlugin as the last plugin to output compilation info
const meteorRspackOutputPlugin = new MeteorRspackOutputPlugin({
getData: (stats, { isRebuild, compilationCount }) => ({
name: config.name,
mode: config.mode,
hasErrors: stats.hasErrors(),
hasWarnings: stats.hasWarnings(),
timestamp: Date.now(),
statsOverrided,
compilationCount,
isRebuild,
}),
});
config.plugins = [meteorRspackOutputPlugin, ...(config.plugins || [])];
return [config];
}

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const semver = require('semver');
const VALID_LEVELS = ['major', 'minor', 'patch'];
function usage() {
console.log(`Usage: node ${path.basename(__filename)} <major|minor|patch> [--beta]`);
console.log('');
console.log('Examples:');
console.log(' patch # 1.0.1 -> 1.0.2');
console.log(' minor # 1.0.1 -> 1.1.0');
console.log(' major # 1.0.1 -> 2.0.0');
console.log(' patch --beta # 1.0.1 -> 1.0.2-beta.0');
console.log(' patch --beta # 1.0.2-beta.0 -> 1.0.2-beta.1 (already beta, bumps beta number)');
console.log(' minor --beta # 1.0.2-beta.1 -> 1.1.0-beta.0 (different bump level resets)');
process.exit(1);
}
const args = process.argv.slice(2);
const level = args[0];
const beta = args.includes('--beta');
if (!level || !VALID_LEVELS.includes(level)) {
if (level) console.error(`Error: first argument must be major, minor, or patch`);
usage();
}
const pkgPath = path.resolve(__dirname, '..', 'package.json');
const raw = fs.readFileSync(pkgPath, 'utf8');
const pkg = JSON.parse(raw);
const current = pkg.version;
const parsed = semver.parse(current);
if (!parsed) {
console.error(`Error: invalid current version "${current}"`);
process.exit(1);
}
let newVersion;
if (beta) {
const isBeta = parsed.prerelease.length > 0 && parsed.prerelease[0] === 'beta';
if (isBeta) {
// Already a beta. The base version already has a prior bump applied.
// Check if the same bump level is being requested by inspecting which
// components are zeroed out (major resets minor+patch, minor resets patch).
// If the same level, just increment the beta number.
const { major, minor, patch } = parsed;
const betaNum = typeof parsed.prerelease[1] === 'number' ? parsed.prerelease[1] : 0;
let sameLevel = false;
if (level === 'patch') {
sameLevel = true;
} else if (level === 'minor') {
sameLevel = patch === 0 && minor > 0;
} else if (level === 'major') {
sameLevel = minor === 0 && patch === 0;
}
if (sameLevel) {
newVersion = `${major}.${minor}.${patch}-beta.${betaNum + 1}`;
} else {
const bumped = semver.inc(`${major}.${minor}.${patch}`, level);
newVersion = `${bumped}-beta.0`;
}
} else {
// Not a beta yet: bump the base and start at beta.0
const bumped = semver.inc(current, level);
newVersion = `${bumped}-beta.0`;
}
} else {
if (parsed.prerelease.length > 0) {
// Currently a prerelease: bump base version from the clean base
const cleanBase = `${parsed.major}.${parsed.minor}.${parsed.patch}`;
newVersion = semver.inc(cleanBase, level);
} else {
newVersion = semver.inc(current, level);
}
}
pkg.version = newVersion;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`Bumped version: ${current} -> ${newVersion}`);

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
npm publish --tag beta "$@"

1139
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,11 @@
"typescript": "^5.4.5"
},
"scripts": {
"install:unit": "cd tools/unit-tests && npm install",
"test:unit": "cd tools/unit-tests && npm test",
"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"
},
"jshintConfig": {

View File

@@ -123,7 +123,7 @@ Meteor.methods({
);
},
async has2faEnabled() {
return await Accounts._is2faEnabledForUser();
return Accounts._is2faEnabledForUser();
},
});

View File

@@ -3,11 +3,20 @@ import { Meteor } from 'meteor/meteor';
import { Configuration } from 'meteor/service-configuration';
import { DDP } from 'meteor/ddp';
/**
* Object containing functions that generate URLs for account-related emails.
* Override these to customize URLs in password reset, enrollment, and verification emails.
* URL methods can return either a string or a Promise that resolves to a string.
*/
export interface URLS {
resetPassword: (token: string) => string;
verifyEmail: (token: string) => string;
loginToken: (token: string) => string;
enrollAccount: (token: string) => string;
/** Generates the URL for password reset emails. Can return a Promise for async URL generation. */
resetPassword: (token: string, extraParams?: Record<string, string>) => string | Promise<string>;
/** Generates the URL for email verification emails. Can return a Promise for async URL generation. */
verifyEmail: (token: string, extraParams?: Record<string, string>) => string | Promise<string>;
/** Generates the URL for login token emails. Can return a Promise for async URL generation. */
loginToken: (selector: string, token: string, extraParams?: Record<string, string>) => string | Promise<string>;
/** Generates the URL for account enrollment emails. Can return a Promise for async URL generation. */
enrollAccount: (token: string, extraParams?: Record<string, string>) => string | Promise<string>;
}
export interface EmailFields {
@@ -362,11 +371,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 +396,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

@@ -205,7 +205,7 @@ export class AccountsCommon {
* @locus Anywhere
* @param {Object} options
* @param {Boolean} options.sendVerificationEmail New users with an email address will receive an address verification email.
* @param {Boolean} options.forbidClientAccountCreation Calls to [`createUser`](#accounts_createuser) from the client will be rejected. In addition, if you are using [accounts-ui](#accountsui), the "Create account" link will not be available.
* @param {Boolean} options.forbidClientAccountCreation Calls to [`createUser`](#accounts_createuser) from the client will be rejected. In addition, if you are using [accounts-ui](#accountsui), the "Create account" link will not be available. **Important**: This option must be set on both the client and server to take full effect. If only set on the server, account creation will be blocked but the UI will still show the "Create account" link.
* @param {String | Function} options.restrictCreationByEmailDomain If set to a string, only allows new users if the domain part of their email address matches the string. If set to a function, only allows new users if the function returns true. The function is passed the full email address of the proposed new user. Works with password-based sign-in and external services that expose email addresses (Google, Facebook, GitHub). All existing users still can log in after enabling this option. Example: `Accounts.config({ restrictCreationByEmailDomain: 'school.edu' })`.
* @param {Number} options.loginExpiration The number of milliseconds from when a user logs in until their token expires and they are logged out, for a more granular control. If `loginExpirationInDays` is set, it takes precedent.
* @param {Number} options.loginExpirationInDays The number of days from when a user logs in until their token expires and they are logged out. Defaults to 90. Set to `null` to disable login expiration.
@@ -226,6 +226,19 @@ export class AccountsCommon {
* @param {Number} options.loginTokenExpirationHours When using the package `accounts-2fa`, use this to set the amount of time a token sent is valid. As it's just a number, you can use, for example, 0.5 to make the token valid for just half hour. The default is 1 hour.
* @param {Number} options.tokenSequenceLength When using the package `accounts-2fa`, use this to the size of the token sequence generated. The default is 6.
* @param {'session' | 'local'} options.clientStorage By default login credentials are stored in local storage, setting this to true will switch to using session storage.
*
* @example
* // For UI-related options like forbidClientAccountCreation, call Accounts.config on both client and server
* // Create a shared configuration file (e.g., lib/accounts-config.js):
* import { Accounts } from 'meteor/accounts-base';
*
* Accounts.config({
* forbidClientAccountCreation: true,
* sendVerificationEmail: true,
* });
*
* // Then import this file in both client/main.js and server/main.js:
* // import '../lib/accounts-config.js';
*/
config(options) {
// We don't want users to accidentally only call Accounts.config on the

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
@@ -89,6 +83,25 @@ export class AccountsServer extends AccountsCommon {
return Meteor._isPromise(value) ? await value : value;
};
/**
* @summary Object containing functions that generate URLs for account-related emails.
* Override these to customize URLs in emails sent by
* [`Accounts.sendResetPasswordEmail`](#Accounts-sendResetPasswordEmail),
* [`Accounts.sendEnrollmentEmail`](#Accounts-sendEnrollmentEmail), and
* [`Accounts.sendVerificationEmail`](#Accounts-sendVerificationEmail).
*
* By default, URLs use hash fragments (e.g., `#/reset-password/:token`) for security:
* hash fragments are not sent to the server in HTTP requests, preventing tokens from
* appearing in server logs or referrer headers.
* @locus Server
* @memberof Accounts
* @name urls
* @type {Object}
* @property {Function} resetPassword - `(token, extraParams) => string` - Generates password reset URL.
* @property {Function} verifyEmail - `(token, extraParams) => string` - Generates email verification URL.
* @property {Function} enrollAccount - `(token, extraParams) => string` - Generates account enrollment URL.
* @property {Function} loginToken - `(selector, token, extraParams) => string` - Generates login token URL.
*/
this.urls = {
resetPassword: (token, extraParams) => this.buildEmailUrl(`#/reset-password/${token}`, extraParams),
verifyEmail: (token, extraParams) => this.buildEmailUrl(`#/verify-email/${token}`, extraParams),
@@ -99,6 +112,16 @@ export class AccountsServer extends AccountsCommon {
this.addDefaultRateLimit();
/**
* @summary Builds a URL for account-related emails by combining the app's
* root URL with a path and optional extra parameters.
* @locus Server
* @memberof Accounts
* @name buildEmailUrl
* @param {String} path - The path to append to the root URL (e.g., `#/reset-password/TOKEN`).
* @param {Object} [extraParams={}] - Additional query parameters to include in the URL.
* @returns {String} The complete URL.
*/
this.buildEmailUrl = (path, extraParams = {}) => {
const url = new URL(Meteor.absoluteUrl(path));
const params = Object.entries(extraParams);
@@ -537,7 +560,7 @@ export class AccountsServer extends AccountsCommon {
type,
fn
) {
return await this._attemptLogin(
return this._attemptLogin(
methodInvocation,
methodName,
methodArgs,
@@ -668,7 +691,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 = {};
@@ -685,7 +707,7 @@ export class AccountsServer extends AccountsCommon {
const result = await accounts._runLoginHandlers(this, options);
//console.log({result});
return await accounts._attemptLogin(this, "login", arguments, result);
return accounts._attemptLogin(this, "login", arguments, result);
};
methods.logout = async function () {
@@ -698,6 +720,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
@@ -727,7 +760,7 @@ export class AccountsServer extends AccountsCommon {
const newStampedToken = accounts._generateStampedLoginToken();
newStampedToken.when = currentStampedToken.when;
await accounts._insertLoginToken(this.userId, newStampedToken);
return await accounts._loginUser(this, this.userId, newStampedToken);
return accounts._loginUser(this, this.userId, newStampedToken);
};
// Removes all tokens except the token associated with the current
@@ -961,8 +994,8 @@ export class AccountsServer extends AccountsCommon {
_clearAllLoginTokens(userId) {
this.users.updateAsync(userId, {
$set: {
'services.resume.loginTokens': []
}
'services.resume.loginTokens': [],
},
});
};
@@ -1565,9 +1598,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");
@@ -1647,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

@@ -32,7 +32,7 @@ Meteor.methods({
},
}
);
return await getTokenFromSecret({ selector, secret });
return getTokenFromSecret({ selector, secret });
},
getTokenFromSecret,
});

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

@@ -55,7 +55,7 @@ Meteor.methods(
check(email, String);
const userId = await Accounts.createUser({ email });
await Accounts.sendEnrollmentEmail(userId);
return await Meteor.users.findOneAsync(userId);
return Meteor.users.findOneAsync(userId);
}
}
);

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,14 +289,8 @@ 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), {
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')
}
@@ -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: {
@@ -478,7 +472,7 @@ Meteor.methods(
Accounts.setPasswordAsync =
async (userId, newPlaintextPassword, options) => {
check(userId, String);
check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256));
check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= (Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)));
check(options, Match.Maybe({ logout: Boolean }));
options = { logout: true, ...options };
@@ -513,6 +507,7 @@ Meteor.methods({forgotPassword: async options => {
const user = await Accounts.findUserByEmail(options.email, { fields: { emails: 1 } });
if (!user) {
if (Accounts._options.ambiguousErrorMessages) return;
Accounts._handleError("User not found");
}
@@ -1006,9 +1001,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 +1045,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 +1156,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,4 +1,6 @@
Accounts._connectionCloseDelayMsForTests = 1000;
Accounts._options.ambiguousErrorMessages = false;
const makeTestConnAsync =
(test) =>
new Promise((resolve, reject) => {
@@ -1136,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 => {
@@ -1415,9 +1467,8 @@ if (Meteor.isServer) (() => {
);
Accounts._options.ambiguousErrorMessages = true;
await test.throwsAsync(
async () => await Meteor.callAsync('forgotPassword', wrongOptions),
'Something went wrong. Please check your credentials'
await test.doesNotThrowsAsync(
async () => await Meteor.callAsync("forgotPassword", wrongOptions)
);
Accounts._options.ambiguousErrorMessages = false;

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

@@ -28,6 +28,9 @@ const getData = ({ createdAt }) => {
};
Tinytest.add('passwordless - time expired', test => {
// The test suite for accounts-passwordless includes testing whether it gets the right error messages from the server.
// So, we need this disabled, otherwise those tests incorrectly fail when you run them.
Accounts._options.ambiguousErrorMessages = false;
const createdAt = new Date('July 17, 2022 13:00:00');
const currentDate = new Date('July 17, 2022 14:01:00');

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

@@ -101,17 +101,18 @@ let lastModifiedSwcConfigTime;
BCp.initializeMeteorAppSwcrc = function () {
const hasSwcRc = fs.existsSync(`${getMeteorAppDir()}/.swcrc`);
const hasSwcJs = !hasSwcRc && fs.existsSync(`${getMeteorAppDir()}/swc.config.js`);
if (!lastModifiedSwcConfig && !hasSwcRc && !hasSwcJs) {
const hasSwcTs = !hasSwcRc && !hasSwcJs && fs.existsSync(`${getMeteorAppDir()}/swc.config.ts`);
if (!lastModifiedSwcConfig && !hasSwcRc && !hasSwcJs && !hasSwcTs) {
return;
}
const swcFile = hasSwcJs ? 'swc.config.js' : '.swcrc';
const swcFile = hasSwcTs ? 'swc.config.ts' : hasSwcJs ? 'swc.config.js' : '.swcrc';
const filePath = `${getMeteorAppDir()}/${swcFile}`;
const fileStats = fs.statSync(filePath);
const fileModTime = fileStats?.mtime?.getTime();
let currentLastModifiedConfigTime;
if (hasSwcJs) {
// For dynamic JS files, first get the resolved configuration
if (hasSwcJs || hasSwcTs) {
// For dynamic JS/TS files, first get the resolved configuration
const resolvedConfig = lastModifiedSwcConfigTime?.includes(`${fileModTime}`)
? lastModifiedSwcConfig || getMeteorAppSwcrc(swcFile)
: getMeteorAppSwcrc(swcFile);
@@ -142,16 +143,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 +187,6 @@ BCp.processFilesForTarget = function (inputFiles) {
this.initializeMeteorAppConfig();
this.initializeMeteorAppSwcrc();
this.initializeMeteorAppLegacyConfig();
this.initializeMeteorAppSwcHelpersAvailable();
inputFiles.forEach(function (inputFile) {
@@ -242,6 +232,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 +282,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 +358,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 +372,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 +420,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;
@@ -1029,8 +1074,37 @@ function getMeteorAppPackageJson() {
function getMeteorAppSwcrc(file = '.swcrc') {
try {
const filePath = `${getMeteorAppDir()}/${file}`;
if (file.endsWith('.js')) {
if (file.endsWith('.js') || file.endsWith('.ts')) {
let content = fs.readFileSync(filePath, 'utf-8');
if (file.endsWith('.ts')) {
try {
const swc = require('@meteorjs/swc-core');
const result = swc.transformSync(content, {
jsc: {
parser: {
syntax: 'typescript',
},
target: 'es2015',
},
});
content = result.code;
} catch (swcError) {
content = content
.replace(/import\s+type\s+.*?from\s+['"][^'"]+['"];?/g, '')
.replace(/import\s+.*?from\s+['"][^'"]+['"];?/g, '')
.replace(/import\s+['"][^'"]+['"];?/g, '')
.replace(/export\s+default\s+/, 'module.exports = ')
.replace(/export\s+/g, '')
.replace(/:\s*\w+(\[\])?(\s*=)/g, '$2')
.replace(/\(([^)]*?):\s*\w+(\[\])?\)/g, '($1)')
.replace(/\):\s*\w+(\[\])?\s*\{/g, ') {')
.replace(/interface\s+\w+\s*\{[^}]*\}/g, '')
.replace(/type\s+\w+\s*=\s*[^;]+;/g, '')
.replace(/as\s+\w+(\[\])?/g, '');
}
}
// Check if the content uses ES module syntax (export default)
if (content.includes('export default')) {
// Transform ES module syntax to CommonJS
@@ -1047,7 +1121,9 @@ function getMeteorAppSwcrc(file = '.swcrc') {
})()
`);
const context = vm.createContext({ process });
return script.runInContext(context);
const result = script.runInContext(context);
// Handle CJS interop wrapper (e.g. { __esModule: true, default: config })
return result && result.__esModule && result.default ? result.default : result;
} else {
// For .swcrc and other JSON files, parse as JSON
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -1096,14 +1172,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 +1204,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

@@ -1120,27 +1120,6 @@ export class Connection {
return Object.values(invokers).some((invoker) => !!invoker.sentMessage);
}
async _processOneDataMessage(msg, updates) {
const messageType = msg.msg;
// msg is one of ['added', 'changed', 'removed', 'ready', 'updated']
if (messageType === 'added') {
await this._process_added(msg, updates);
} else if (messageType === 'changed') {
this._process_changed(msg, updates);
} else if (messageType === 'removed') {
this._process_removed(msg, updates);
} else if (messageType === 'ready') {
this._process_ready(msg, updates);
} else if (messageType === 'updated') {
this._process_updated(msg, updates);
} else if (messageType === 'nosub') {
// ignore this
} else {
Meteor._debug('discarding unknown livedata data message type', msg);
}
}
_prepareBuffersToFlush() {
const self = this;
if (self._bufferedWritesFlushHandle) {

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

@@ -168,9 +168,24 @@ Tinytest.addAsync('livedata server - async publish cursor', function(
connection: clientConn,
});
clientConn.subscribe('asyncPublishCursor', async () => {
const actual = await remoteCollection.find().fetch();
test.equal(actual[0].name, 'async');
onComplete();
// Wait for data to arrive - the subscription is ready but data may still be in transit
// This can happen when a previous test run was interrupted (page reload) and the
// server is still processing the old session's grace period
let attempts = 0;
const maxAttempts = 50; // 5 seconds max wait
const checkData = async () => {
const actual = await remoteCollection.find().fetch();
if (actual.length > 0) {
test.equal(actual[0].name, 'async');
onComplete();
} else if (attempts++ < maxAttempts) {
setTimeout(checkData, 100);
} else {
test.fail('Timed out waiting for data in async publish cursor test');
onComplete();
}
};
await checkData();
});
});
});

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,7 @@
try {
Symbol = exports.Symbol = require("core-js/es/symbol");
Map = exports.Map = require("core-js/es/map");
Set = exports.Set = require("core-js/es/set");
Symbol = exports.Symbol = global.Symbol || require("core-js/es/symbol");
Map = exports.Map = global.Map || require("core-js/es/map");
Set = exports.Set = global.Set || require("core-js/es/set");
} catch (e) {
throw new Error([
"The core-js npm package could not be found in your node_modules ",

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

@@ -7,46 +7,25 @@
Visit <a href="https://developers.facebook.com/apps" target="_blank">https://developers.facebook.com/apps</a>
</li>
<li>
Click "Add a New App".
Click "Create App" and fill out the required information.
</li>
<li>
Add a "Display Name" for your app and click on "Create App ID".
In "Use cases" select "Authenticate and request data from users with Facebook Login".
</li>
<li>
Answer the "Security Check" CAPTCHA and click on "Submit".
In the app dashboard, click "Add Product" and find "Facebook Login", then click "Set Up".
</li>
<li>
When the new app dashboard loads, click on "Settings" in the left hand menu.
Select "Web" as your platform.
</li>
<li>
From the top of the "Basic" settings page, note down your "App ID" and "App Secret" (you will be asked for them at the bottom of this popup).
In the "Facebook Login > Settings" from the left sidebar, set "Valid OAuth Redirect URIs" to <span class="url">{{siteUrl}}_oauth/facebook</span> and click "Save Changes".
</li>
<li>
Click on the "Add Platform" button, and select "Website".
Go to "Settings > Basic" in the left sidebar.
</li>
<li>
In the "Website" section, set the "Site URL" to <span class="url">{{siteUrl}}</span> and click on "Save Changes".
</li>
<li>
Click on "Add Product" in the left hand menu.
</li>
<li>
Hover over "Facebook Login", click on "Set Up".
</li>
<li>
Click on "Facebook Login > Settings" from the left hand menu.
</li>
<li>
Set "Valid OAuth redirect URIs" to <span class="url">{{siteUrl}}_oauth/facebook</span> and click on "Save Changes".
</li>
<li>
Select "App Review" from the left hand menu.
</li>
<li>
Toggle the "Make app public" switch to "Yes".
</li>
<li>
Select a "Category" in the "Make app public" popup and click on "Confirm".
Note down your "App ID" and "App Secret" (click "Show" to reveal the App Secret). You'll need these for configuration.
</li>
</ol>
</template>

View File

@@ -4,28 +4,37 @@
</p>
<ol>
<li>
Visit <a href="https://console.developers.google.com/" target="blank">https://console.developers.google.com/</a>
Visit <a href="https://console.cloud.google.com/" target="blank">https://console.cloud.google.com/</a>
</li>
<li>
"Create Project", if needed. Wait for Google to finish provisioning.
Create a new project or select an existing one.
</li>
<li>
On the left sidebar, go to "Credentials" and, on the right, "OAuth consent screen". Make sure to enter an email address and a product name, and save.
In the left sidebar, go to "APIs & Services" > "OAuth consent screen".
</li>
<li>
On the left sidebar, go to "Credentials". Click the "Create credentials" button, then select "OAuth client ID" as the type.
Configure the consent screen: select "External" user type, enter your app name, user support email, and developer contact email, then click "Save and Continue".
</li>
<li>
Select "Web application" as your application type.
Skip the "Scopes" step (or add scopes if needed) and click "Save and Continue".
</li>
<li>
Set Authorized Javascript Origins to: <span class="url">{{siteUrl}}</span>
Add test users if needed, then click "Save and Continue".
</li>
<li>
Set Authorized Redirect URI to: <span class="url">{{siteUrl}}_oauth/google?close</span>
In the left sidebar, go to "Credentials" and click "Create Credentials" > "OAuth client ID".
</li>
<li>
Finish by clicking "Create".
Select "Web application" as the application type.
</li>
<li>
Add your site URL to "Authorized JavaScript origins": <span class="url">{{siteUrl}}</span>
</li>
<li>
Add to "Authorized redirect URIs": <span class="url">{{siteUrl}}_oauth/google</span>
</li>
<li>
Click "Create" and note down your "Client ID" and "Client Secret" from the popup.
</li>
</ol>
</template>

View File

@@ -4,20 +4,25 @@
</p>
<ol>
<li>
Visit <a href="http://www.meetup.com/meetup_api/oauth_consumers/create/" target="blank">http://www.meetup.com/meetup_api/oauth_consumers/create/</a>
Visit <a href="https://www.meetup.com/api/oauth/list/" target="blank">https://www.meetup.com/api/oauth/list/</a> and sign in.
</li>
<li>
Click on "Create New Consumer".
Click "Create new client".
</li>
<li>
Set the Consumer name to the name of your application.
Set the "Client name" to the name of your application.
</li>
<li>
Optionally set the Application Website to the URL of your
website. You can leave this blank.
Set the "Application Website" to your site URL.
</li>
<li>
Set the <b>Redirect URI</b> to: <span class="url">{{siteUrl}}</span> (Do not append a path to this URL.)
Set the <b>Redirect URI</b> to: <span class="url">{{siteUrl}}</span> (Do not append a path to this URL.)
</li>
<li>
Fill out all the other required fields.
</li>
<li>
Click "Create" and note down your "Key" (Client ID) and "Secret" (Client Secret).
</li>
</ol>
</template>

View File

@@ -4,15 +4,17 @@
Follow these steps:
</p>
<ol>
<li> Visit <a href="https://www.meteor.com/account-settings" target="_blank">https://www.meteor.com/account-settings</a> and sign in.
<li>
Visit <a href="https://beta.galaxycloud.app/" target="_blank">https://beta.galaxycloud.app/</a> and sign in.
</li>
<li> Click "NEW APPLICATION" in the "Meteor Account Services" section
and give your app a name.</li>
<li> Add
<span class="url">
{{siteUrl}}_oauth/meteor-developer
</span>
as the Redirect URL.
<li>
Go to "Settings" -> "Authorized Domains" and "Add New Domain".
</li>
<li>
Set the "OAuth Redirect URL" to: <span class="url">{{siteUrl}}_oauth/meteor-developer</span>
</li>
<li>
Click "Create" and note down your "Client ID" and "Client Secret".
</li>
</ol>
</template>

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

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