@maka/meteor-sdk
v0.2.20
Published
Programmatic Node API over the Meteor build tool (bundle/run/create/etc.) -- for driving Meteor's build system from plain Node code instead of shelling out to a meteor CLI (there isn't one anymore; see README.md). "dependencies" is generated by scripts/wr
Readme
@maka/meteor-sdk
A programmatic Node.js API over Meteor's build
tool -- for driving Meteor's build system (bundle, run, test,
create, ...) from plain Node code instead of shelling out to a meteor
CLI subprocess. There is no CLI in this package; every operation is a
promise-based function you call directly.
This SDK is designed to work with @maka/maka-cli,
which builds its run/build/create commands directly on top of this
package's API. It's also usable standalone by any Node process that wants
programmatic control over a Meteor app's build/run lifecycle.
Requirements
Node.js >=22.5.0. (One core module, node:sqlite, isn't available on
earlier versions -- Node 20 cannot run this package at all.)
Install
npm install @maka/meteor-sdkQuick start
const sdk = require("@maka/meteor-sdk");
await sdk.init({ release: "[email protected]" }); // call once per process,
// matching the app's own
// .meteor/release
const project = await sdk.loadProject("/path/to/app");
const result = await sdk.bundle(project, { outputPath: "/path/to/output" });
console.log(result.starManifest);
process.exit(0); // simplest way to end a one-shot script -- see close()
// below if your process needs to keep running afterwardAPI
init(options)
Call once per process before anything else. options.release must match
the target app's own .meteor/release (e.g. "[email protected]") -- it pins
which Meteor release's package versions and build behavior this process
uses.
Passing a specific release requires that release to be present in the
local package catalog (the Meteor warehouse) -- on a machine that has
never downloaded it, init rejects with a MeteorSdkError naming the
missing release. Embedders that only need connectDdp() (or other
non-build APIs) should call init({}) with no release: that uses
checkout semantics (the tool copy this package itself ships), needs no
warehouse or catalog access, and works on a machine whose only Meteor
footprint is this npm package.
loadProject(appDir)
Resolves an app directory into a Project, ready to bundle or run.
Read-only: resolves package constraints and versions but doesn't build
anything yet.
bundle(project, options)
Produces a deployable bundle (a star.json manifest plus program
output), the same output meteor build produces.
await sdk.bundle(project, {
outputPath: "/path/to/output",
minifyMode: "production", // DEFAULT IS "development" -- pass
// "production" explicitly for deploys.
// Real minification can take minutes and
// multiple GB of RAM on a cold build.
buildMode: "production", // default; also "development"/"test"
serverArch: undefined, // default: the build host's arch
webArchs: undefined, // default: the project's platform list
programs: "all", // "all" (default) | "server" | "client"
});Split server/client bundles (programs)
programs: "server" writes a bundle with no client programs whose
programs/server/config.json still records the intended
clientArchs. It boots normally and serves pages with 404 until the
client programs exist -- webapp logs a note per reload and starts
serving the moment they appear (process restart, or SIGHUP, which
autoupdate already wires to a client-program reload plus a hot-code
push to connected browsers).
programs: "client" writes a standalone client artifact: a star.json
listing only the web programs plus programs/<arch>/ trees that are
byte-identical to a full bundle's -- so they are drop-in siblings for
a server bundle. Copy programs/<arch> into the server bundle's
programs/ directory (never untar the client artifact wholesale over a
server bundle: its root star.json would clobber the server's), or
point the server at an external location with
METEOR_CLIENT_BUNDLE_DIR=/path/to/client-artifact/programs (honored
by both webapp's static serving and dynamic import()).
Swap discipline for live servers: activate a new client release with an
atomic symlink flip, never by copying into the live directory -- a
half-written program.json observed by a reload is a fatal error by
design. Client-only deploys are for client-safe changes; anything
touching methods, publications or schemas ships server-first or as a
full bundle.
createAppRunner(project, options)
Runs an app the way meteor run does, with a stoppable handle instead of
a blocking process.
const handle = await sdk.createAppRunner(project, {
port: 3000,
mongoUrl: "mongodb://127.0.0.1:27017/myapp", // required -- see below
once: false, // if true, exits after one run instead of rebuild-on-change
buildOptions: { minifyMode: "development" },
});
handle.on("exit", (result) => { /* a run ended: crash, rebuild, ... */ });
await handle.start(); // resolves once the proxy + app are both listening
// app is now serving http://localhost:3000
await handle.stop(); // stops the proxy, then the app; no orphaned processesNo Mongo lifecycle management. Unlike meteor run, createAppRunner()
does not start or stop a mongod for you -- supply a running Mongo's
connection string via mongoUrl. For an app that doesn't use the mongo
package at all, pass mongoUrl: null explicitly: no MONGO_URL is set
for the app process. (An app that does load the mongo package will
crash at startup without one -- the explicit null is how you take
responsibility for that.)
There is no separate stdout/stderr event -- all app log output is
observable via sdk.on("log", ...) (see below).
testRun(project, options)
Runs an app's own tests, the way plain meteor test does (app test mode
-- not meteor test-packages' synthetic-app mode, which this package
doesn't cover).
const handle = await sdk.testRun(project, {
port: 3000,
mongoUrl: "mongodb://127.0.0.1:27017/myapp",
driverPackage: "test-in-browser", // required -- must already be in the project
fullApp: false, // matches `meteor test`'s --full-app
});
// same handle shape as createAppRunner(): start() / stop() / on("exit", ...)create(targetDir, options)
Scaffolds a new Meteor app from a built-in skeleton template, the way
meteor create does.
const result = await sdk.create("/path/to/new-app", {
skeleton: "react", // default; see sdk.AVAILABLE_SKELETONS for the full list
appName: "my-app",
installDependencies: true,
});
// => { appPath, appName, skeleton }connectDdp(url, options)
Opens a long-lived DDP client connection to a running Meteor server --
the same protocol and client (ddp-client, bundled in this package's
isopackets) the tool itself uses to talk to Meteor services. Resolves
once the first DDP handshake completes; after that, ddp-client's own
reconnect machinery (exponential backoff, automatic re-subscribe,
re-send of in-flight method calls) keeps the connection alive until you
close() it.
const conn = await sdk.connectDdp("https://app.example.com", {
headers: { Authorization: "Bearer ..." }, // extra WS handshake headers
tls: { ca, cert, key, rejectUnauthorized }, // Node tls.connect() options
firstConnectTimeoutMs: 30000, // reject if never connected by then
retry: true, // default; false = no auto-reconnect
});
const result = await conn.call("someMethod", arg1, arg2); // concurrent-safe
const sub = await conn.subscribe("pubName", ...args); // resolves on ready
conn.on("connected", ({ reconnect }) => { /* every (re)connect */ });
conn.on("disconnected", ({ error }) => { /* transport drop */ });
conn.status(); // ddp-client status snapshot
conn.disconnect(); // temporary offline (stops retrying)...
conn.reconnect(); // ...and back
conn.raw; // the underlying ddp-client connection
conn.close(); // permanent -- no reconnects after thisThe URL's scheme picks the transport: https:///wss:// gives you a
TLS websocket; HTTPS_PROXY/NO_PROXY are honored. call() rejects
with the server's own Meteor.Error (structured error/reason/
details fields intact); subscribe() rejects with the server's sub
error if the subscription fails before first becoming ready. Logging in
to an accounts-enabled server is a plain method call:
await conn.call("login", { resume: token }).
close()
Releases file-watch resources opened by loadProject()/bundle()/etc.
For a one-shot script/CLI command that's about to exit anyway, prefer
skipping close() entirely and calling process.exit() directly once
your work is done, rather than awaiting close() first -- verified
to sidestep the issue below cleanly, with no downside for a process that
isn't doing anything else afterward. close() still exists for
longer-lived embedders that need to release watch handles mid-process
without exiting.
If you do call it: with the default native watcher backend, the process
can occasionally hang on Windows due to a @parcel/watcher native-addon
limitation outside this package's control (its background thread doesn't
release on unsubscribe(), and its public API has no lower-level
shutdown hook) -- and, observed specifically when the host process is
launched from Git Bash/MSYS2 on Windows (not from PowerShell/cmd), that
same untorn-down thread can crash the process outright (SIGSEGV)
during teardown instead of just hanging. Workarounds, in addition to
skipping close() above: call process.exit() after close() resolves
regardless of whether it hangs, or set METEOR_MODERN='{"watcher":false}'
to use the plain polling watcher instead, which doesn't exhibit either
symptom.
setConsoleOutput(enabled)
By default the tool's Console renders informational output to this
process's stdout/stderr and emits it as log events. An embedder
with its own progress UI (spinners, status lines) can call
sdk.setConsoleOutput(false) to suppress the terminal rendering --
newline-terminated Console writes landing between spinner redraws
garble the terminal otherwise -- and paint the emitted events itself.
Scoping it to a phase (off around a build, back on after) is the
intended use; events keep flowing either way.
setProgressDisplay(enabled)
The tool's own interactive progress bar (the classic meteor run
"Building the application" bar) is disabled by default for embedders.
sdk.setProgressDisplay(true) turns it back on for long build phases --
it renders only when this process's stdout is a real TTY, and falls back
to no display otherwise, so it's always safe to call. Turn it off again
with false when your own UI takes over the terminal.
Events
sdk.on("log", ({ level, args }) => { /* ... */ });
sdk.off("log", listener);Structured log events for the handful of informational messages Meteor's build tooling emits outside of any build result.
Errors
Every exported function rejects with a real Error -- MeteorSdkError
for usage errors (calling something before init(), an unknown
skeleton, ...) or MeteorBuildError for build failures (with a
.messages array of the underlying build errors) -- never a raw exit
code or an unstructured message set.
npm dependency cache
Every bundled Meteor core package ships with its npm-shrinkwrap.json
but without node_modules, so the first build after a fresh SDK install
historically paid a serial npm install per package (the wall of
"<package>: updating npm dependencies ..." lines). The SDK now keeps a
per-user cache of completed node_modules trees at ~/.meteor-npm-cache,
keyed by shrinkwrap content + Node compatibility version + platform/arch.
On the first build after an SDK update, packages whose dependencies
didn't change restore from the cache by parallel directory copy instead
of spawning npm -- silently and near-instantly. Cache misses are
handled in parallel too: once loadProject() knows the app's package
set (from .meteor/versions), the remaining installs run through a
small npm worker pool (bounded to 3 processes -- concurrent npm
invocations contend on npm's shared content cache), and build plugins'
own npm trees (.npm/plugin/<name>) are covered the same way.
Both prewarm stages (the global restore pass in init(), and the
app-scoped restore+install pass in loadProject()) are fully awaited
before any build proceeds -- this matters specifically for
createAppRunner()'s watched run: its file watcher treats each
package's npm-shrinkwrap.json as a build dependency, so an install
finishing after the watcher was already set up would look like a
source change and trigger a spurious restart. Resolving every
dependency up front, before the watcher exists, means there's nothing
left for it to react to. Cache writes happen in the background off the
build's critical path, entries are pruned to the 3 newest per package,
and everything is best-effort: any cache problem falls back to a normal
npm install, and a stale restore is caught by the existing
shrinkwrap-vs-installed verification.
Control it with METEOR_PACKAGE_NPM_CACHE: unset means enabled at the
default location, 0/false/no/off disables it, and any other
value is used as the cache root directory. Set METEOR_WATCH_DEBUG=1
to log detailed file-watcher activity (which path changed, what fired,
why) if you ever need to diagnose unexpected rebuilds.
Known limitations
- No Cordova, no
publish/package-server admin operations, no springboarding/multi-release-per-process. Out of scope for this SDK. createAppRunner()/testRun()don't manage a Mongo process -- bring your ownmongoUrl.bundle()defaults tominifyMode: "development"(fast, no real minification) -- deploys must pass"production"explicitly, and that mode is legitimately slow on a cold build. (Earlier revisions of this README claimed production was the default; the code never agreed.)- A rare, environment-specific gap, out of scope for this package to fix: a from-scratch install can hit an npm/Windows spawn issue when a dependency needs a native rebuild for the first time.
- Two different SDK processes each doing a from-scratch install at the
same time can still hit npm's own global-cache
database is lockederror -- the npm worker pool above only bounds concurrency within one process.
License
MIT
